← All news
configuration3 min read

Your Dockerfile has no USER instruction, so your app is running as root

By Seaworthy · 31 August 2026

Stacked shipping containers at a port

Photo by Garakhan Safarli on Unsplash

Most Dockerfiles for a Node app look roughly like this, and most of them run their application as root:

FROM node:22-slim
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
CMD ["node", "server.js"]

There's no USER instruction anywhere in it. Docker's default is root (UID 0), so that's what the process gets, and nothing in the build output or the running container tells you that's what happened. It starts, it serves traffic, it looks done.

Root in the container is not the same risk as root on your laptop

A container isn't a full virtual machine. Its isolation from the host comes from Linux namespaces and cgroups, not a hardware boundary, and that isolation has had real, exploitable holes: container breakout vulnerabilities in the runtime, the kernel, or a misconfigured mount are not hypothetical, they've shipped as actual CVEs. Root inside the container is the difference between "an attacker who found a breakout gets a normal user" and "an attacker who found a breakout already has root" on the host. You're not relying on one layer of defense here, you're relying on two, and running as root spends the second one before anyone's asked to.

It also matters without a breakout. If your app has a path traversal or arbitrary file write bug, and plenty of AI-scaffolded file upload or template rendering code does, root lets that bug touch anything the container's filesystem exposes, including bind-mounted host paths, instead of being constrained to files your app's own user actually owns.

Add the user, then own the files it needs

FROM node:22-slim
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
RUN chown -R node:node /app
USER node
CMD ["node", "server.js"]

The official node images already ship a non-root node user, so you don't need to create one yourself in most cases, just switch to it and make sure the files it needs to read are actually readable by it. That chown step is the part people skip: switching USER without it leaves the app running as an unprivileged user that can't read its own files, which fails loudly enough in local testing that it usually gets caught, but it's worth calling out because the fix people reach for under time pressure is switching back to root rather than fixing the ownership.

Binding to port 80 is not a reason to stay root

The one legitimate-sounding argument for root is binding to a privileged port below 1024. It doesn't apply here: your container's internal port doesn't need to be 80, it can listen on 3000 like every Node example does, and the port mapping happens at the Docker or orchestrator level (-p 80:3000), which runs with the host's own privileges, not the container's. There's no case where a Node app inside a container needs root to bind its own listening port.

Check what's actually running, not what the Dockerfile implies

A USER node line in the Dockerfile is a strong signal, but it's worth confirming directly rather than trusting the file:

docker exec <container> whoami

Multi-stage builds and base images that reset USER partway through are a common way for this to quietly regress: a FROM in a later stage without its own USER instruction resets back to root, even if an earlier stage had it right.

Seaworthy flags Dockerfiles with no USER instruction before the final CMD or ENTRYPOINT.

This article was generated by AI and summarises publicly available sources.

From the makers of Seaworthy

Seaworthy scans your repo for the issues covered in articles like this one.

Security gaps, exposed secrets, and misconfigurations — caught before you deploy. Free to run, no account needed.

$ npx seaworthycode