Docker Security Mistakes I Keep Seeing in Production
Root containers, mounted sockets, exposed debug ports. I've made most of these mistakes — here's what I do now.
Let me show you the worst Dockerfile I ever shipped. It ran a Node.js API as root, mounted the Docker socket so it could "manage containers," exposed the debug port 9229 so I could attach a debugger in an emergency, and baked a real API key into an image layer. I audited it later and realized I'd built a remote code execution service with a nice API wrapper around it.
I was seventeen and learning. But here's what bothers me now, at nineteen, after auditing friends' projects and reading far too many production compose files: those exact mistakes are everywhere, because Docker feels secure. Your app is in a container! It's isolated! Except it isn't, not in the way people assume — and the defaults are far more permissive than the marketing implies.
So here are the mistakes I've made and keep seeing, in rough order of how much damage they can do.
Running as root because you never added a USER line
If your Dockerfile doesn't contain a USER instruction, your app runs as root inside the container. That means a single exploited vulnerability in your app gives an attacker root within that container — and depending on your configuration, a path toward the host.
The fix is two lines:
dockerfileRUN addgroup --system app && adduser --system --ingroup app app USER app
It won't save you from everything, but it instantly shrinks the blast radius of any compromise. There is no good reason for a web app to run as root, and most tutorials that skip USER do it by accident, not by design.
Chasing latest
dockerfileFROM node:latest
This one breaks your builds twice. First, reproducibility: latest moves, so the image that built fine last month may not build today. Second, security: you can't pin to a base image you've actually scanned. You're trusting that whatever latest resolves to right now doesn't ship a CVE that wasn't in the version you tested against.
Pin to a version tag at minimum, a digest when you're being careful:
dockerfileFROM node:20-alpine@sha256:abc123...
And yes, Alpine's smaller footprint matters here too — node:latest bundles hundreds of packages you'll never use, and each one is a potential vulnerability.
COPY . . and the secrets you didn't know were there
dockerfileCOPY . .
This copies everything: your .env with database credentials, your .git directory with every secret that was ever committed and "removed," your node_modules (which you're about to reinstall anyway), your test fixtures, your notes. A .dockerignore file is not optional:
.git
.env
.env.*
node_modules
*.md
tests/
.vscode/
I once found a production container with the full .git history baked in. Anyone who pulled the image could run git log and watch every API key the team thought they'd scrubbed come marching back out of history.
Mounting the Docker socket because DinD sounds hard
yamlvolumes: - /var/run/docker.sock:/var/run/docker.sock
If you see this in a compose file and the service isn't a container-management tool (Portainer, Traefik), something is wrong. Mounting the socket hands the container control of the Docker daemon itself: it can start and stop other containers, mount host directories, do essentially anything root on the host can do. "We need Docker-in-Docker for CI" is how half of these end up in production. If you genuinely need nested containers, use rootless mode or --userns-remap — don't hand your build pipeline the keys to the infrastructure.
Shipping images you've never scanned
Every non-trivial image contains known CVEs. That's not a judgment call, it's arithmetic. The tools are free and fast:
trivy image your-image:tag(Aqua Security) — my default- Docker Scout, if you're already in Docker Desktop
- Grype, if you prefer Anchore's tooling
The first scan you run will alarm you. Most of what it finds will be in transitive dependencies you've never heard of — but some of it will be real, and exploitable in your context, which is the only part that matters. The trick is making it part of the pipeline: scan on every build and fail on HIGH/CRITICAL findings, so "we'll scan it later" stops being an option.
Exposing every port to the world
dockerfileEXPOSE 3000 EXPOSE 5432 EXPOSE 6379 EXPOSE 9229
EXPOSE is documentation, but people routinely do the real thing with -p in their run commands. If your API talks to Postgres over an internal Docker network, there's zero reason to publish 5432 to the host. Same for Redis. And the debug port — 9229 with --inspect=0.0.0.0 is a remote code execution endpoint with a friendly label on it. I've seen it in production containers more than once, and every time it was someone's "temporary" debugging setup that they forgot to remove.
Hardcoding secrets into image layers
dockerfileENV DATABASE_URL=postgres://admin:supersecret@db:5432/myapp ENV API_KEY=sk-live-abc123xyz
These get baked into the image layer permanently. Anyone with pull access to your registry can run docker history and read them — and ARG doesn't save you either, since build-time args are visible in history unless you use BuildKit's --mount=type=secret. Secrets belong in runtime injection: environment variables set at deploy time, Docker secrets, or a proper secrets manager. Not in the file that builds the image.
The part I keep having to re-learn
Docker's underlying security model is genuinely solid — namespaces, cgroups, seccomp, capability dropping. The problem was never the kernel features. It's that developers (me included) treat containers as magic security boxes and skip the boring basics, then act surprised when the defaults bite.
My current checklist is boring on purpose: non-root user, pinned base image, .dockerignore that mirrors .gitignore and then some, multi-stage builds for anything with a build step, a Trivy scan in CI that blocks on HIGH/CRITICAL, no socket mounts, only the app port exposed, secrets injected at runtime. Each item takes about thirty seconds. The insecure defaults are the default because they're convenient — overriding them is the actual job.
Enjoyed this read?
Share it with your network.