Short version for the impatient: your container is probably running as root, it probably has more Linux capabilities than it needs, and its filesystem is probably writable. Three small changes fix most of that. If you want to know why I care, read on.
I got a scare about eight months ago. Not a breach, thankfully. A client asked for a security questionnaire to be filled in and one of the questions was “do application containers run as a non-root user?” I said yes, because obviously they did, and then I went and checked, and they did not. Every service. Every image. Root all the way down, for about two years, because nobody had ever typed USER into a Dockerfile and Docker doesn’t nag you about it.
That’s the annoying thing about container security. Almost none of it is hard. It’s just invisible by default. Docker will happily run whatever you give it with the most permissive settings available and never mention that there was a choice.
So here’s the checklist I run now, in the order I run it.
Containers aren’t a security boundary, and I wish people said that louder
The mental model a lot of us start with is “container equals tiny VM”. It isn’t. Your container shares the host kernel. A VM has a hypervisor between the guest and the hardware. A container has namespaces and cgroups, which are kernel features doing bookkeeping, not isolation in the hardware sense.
Practically: a kernel bug that lets a process escalate inside the container can put that process on the host. That’s a much shorter path than escaping a hypervisor. It doesn’t mean containers are unsafe. It means the defence has to be “this process can’t do much even if it gets out”, not “this process can’t get out”.
Everything below follows from that one idea.
Running as root is the default and it’s a two-line fix
By default, the process inside your container runs as uid 0. If you’ve bind-mounted a host directory into that container, uid 0 inside is uid 0 outside. Files it writes are owned by root on your host. That’s not theoretical; I’ve spent real time running sudo chown on directories a container mangled.
The fix is boring:
FROM node:22-slim
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
USER node
CMD ["node", "server.js"]
The official Node images already ship a node user, so you don’t even have to create one. If your base image doesn’t, add it yourself:
RUN groupadd --system --gid 1001 app \
&& useradd --system --uid 1001 --gid app app
USER app
Order matters. Anything that needs root, like installing system packages, has to happen before the USER line. That trips people up and then they give up and delete the line. Don’t. Just move it down.
If you want the stronger version of this, Docker supports rootless mode, where the daemon itself runs as an unprivileged user. I run it on my own machine. On shared build servers it’s been more of a fight, mostly around networking and storage drivers, so I won’t pretend it’s free.
Drop every capability, add back what breaks
Linux capabilities split root’s powers into pieces: bind to low ports, change file ownership, load kernel modules, and so on. Docker grants a default set to every container. Your Express app does not need CAP_CHOWN.
Start from nothing:
services:
api:
image: ghcr.io/me/api:sha-9f2c1ab
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
Then run your test suite and see what explodes. Usually nothing does. If you need port 80 directly, you’ll want NET_BIND_SERVICE back, though I’d rather listen on 8080 and let the reverse proxy handle 80.
no-new-privileges is the one people skip. It stops a process inside the container from gaining privileges through setuid binaries. There’s basically no downside for an application container, and it’s one line.
Make the filesystem read-only
If your app doesn’t write to disk, don’t let it. If it does write, let it write to exactly one place.
services:
api:
read_only: true
tmpfs:
- /tmp
volumes:
- uploads:/var/app/uploads
This one catches things. An attacker who lands remote code execution but can’t drop a file anywhere persistent is having a much worse day. It also surfaces sloppiness in your own code, because you find out what’s quietly scribbling into the image at runtime.
Expect a couple of surprises the first time. Some frameworks cache compiled templates or config into the app directory on boot. I hit this with a PHP service that wanted to write its container cache at runtime, and the fix was to warm the cache at build time instead, which was better anyway. I wrote about the queue side of that same app in my Laravel Horizon setup.
Build secrets do not belong in ARG or ENV
This is the mistake I see most in other people’s repos, and it’s the one with the worst blast radius.
Wrong:
ARG NPM_TOKEN
RUN echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" > .npmrc \
&& npm ci
That token is now baked into a layer. Deleting the file in a later layer doesn’t remove it. Anyone who can pull the image can run docker history or just unpack the tarball and read it.
Right, using BuildKit’s secret mounts:
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \
npm ci --omit=dev
docker build --secret id=npmrc,src=$HOME/.npmrc -t api:latest .
The secret is available during that one command and never written to a layer. Runtime secrets are a separate problem: keep them out of ENV too, because docker inspect prints environment variables in full, and so do a lot of logging agents.
Never mount the Docker socket into a container
-v /var/run/docker.sock:/var/run/docker.sock is root on the host. Not “close to root”. Root. A process with the socket can start a new container with the host filesystem mounted at /host and do whatever it wants.
Plenty of CI tools and monitoring agents ask for it. Sometimes there’s no alternative and you accept the risk knowingly, on a box that does nothing else. What you shouldn’t do is mount it into an app container next to your customer data because a tutorial said to.
The OWASP Docker Security Cheat Sheet is good on this and worth twenty minutes if you want the fuller list.
Pin by digest, then scan
Scanning gets all the attention because vendors sell scanners. Pinning matters more and costs nothing.
FROM node:22-slim is a moving target. The image behind that tag changes. Your reproducible build isn’t reproducible, and “it worked in CI last week” stops being evidence of anything.
FROM node:22-slim@sha256:c0e8f2ff2b3f4d7dc1c6ea0d9e0d3f5b1a5f2f0d3d9b8e1a4c7d2e5f8a1b4c7d
Yes, it’s ugly. Yes, you need Renovate or Dependabot to bump it. That’s the point: upgrades become a reviewed change instead of something that happens to you at 3am.
Then scan. Trivy is what I use, mostly because it runs in CI without an account:
trivy image --severity HIGH,CRITICAL --exit-code 1 api:latest
One warning from experience: don’t gate your pipeline on zero findings on day one. You’ll get four hundred CVEs in your base image, half of them unreachable from your code, and within a week someone adds --exit-code 0 and nobody looks again. Start by failing on CRITICAL only, get that to zero, then tighten.
Smaller images help here more than any scanner does. A multi-stage build that ships only the compiled binary and its runtime has almost no packages to have CVEs about. That’s a real chunk of why I moved some services onto leaner runtimes, which I got into in a year of running Bun in production.
What I’d actually do this week
Pick your busiest service. Open its Dockerfile and its compose file. Add USER, add cap_drop: ALL, add no-new-privileges, add read_only: true with a tmpfs for /tmp. Run the test suite. Fix what breaks, which will be less than you think.
That’s an hour of work and it removes most of the easy paths off a compromised container. Digest pinning and scanning can wait until next week; they’re maintenance, and maintenance without the basics in place is just paperwork.
I do this kind of hardening on client infrastructure fairly often, and it’s usually the same four lines missing every time. If that’s the sort of thing you need a hand with, it’s what I spend most of my time on.