Ship and operate

Run as a non-root user

Reduce the impact of an application compromise by giving the container process only the filesystem access it needs.

Most base images run your process as root. Not root on your machine, root inside the container. Isolation limits what that can do, but it’s still more power than a web server needs. If someone finds a bug in your app and gets code execution, I’d rather they land as an unprivileged user.

Use the user the image gives you

The official Node.js images include a user called node, with UID 1000. You don’t need to create one. Switch to it at the end of the runtime stage:

COPY --chown=node:node --from=build /app/dist ./dist
USER node
CMD ["node", "dist/server.js"]

USER node changes the user for every instruction after it and for the running container. --chown=node:node sets the owner of the copied files while copying, so the node user can read them.

Check it:

docker run --rm notes-api:prod id

The output is uid=1000(node) gid=1000(node) groups=1000(node). Not uid=0(root).

Match the user to the files

Switching users is only half the job. The files have to be readable by that user, and writable only where the app really writes.

Files copied as root earlier in the Dockerfile are readable by everyone, so application code is fine. Anything the app writes to is the problem: an uploads folder, a cache directory. Those need explicit ownership:

RUN mkdir -p /app/uploads && chown node:node /app/uploads

When you hit EACCES: permission denied, resist the urge to chmod -R 777. That makes everything writable by everyone and throws away the protection you just added. Fix the ownership of the one directory that needs it.

And test both directions. Write a file to /app/uploads and confirm it works. Then try to write to /app/dist and confirm it fails. Checking the UID alone doesn’t tell you the filesystem matches.

Non-root is one layer

A non-root user is a good default, not a complete defense. A few more habits that cost little:

  • Drop Linux capabilities you don’t use: --cap-drop ALL at run time.
  • Mount the root filesystem read-only where practical: --read-only, plus --tmpfs /tmp for scratch space.
  • Never mount /var/run/docker.sock into an application container. Whoever controls that socket controls the Docker engine, and with it the host.

Root inside a container isn’t automatically root on the host. But a kernel or runtime bug hurts a lot more when the process that triggers it already has every privilege. Start small.

Try this: run the image and print its identity with docker run --rm notes-api:prod id.

Lesson completed