Most Docker security problems don't come from a zero-day in the container runtime — they come from images running as root, base images nobody's updated in eight months, and secrets baked into layers that survive long after anyone remembers they're there. The fixes are mostly mechanical and cheap; the hard part is making them the default instead of an afterthought.
Don't run as root inside the container
A container process running as root inside the container is still root if it escapes the container boundary through a kernel or runtime vulnerability — container isolation is not a substitute for least privilege. Every Dockerfile should create and switch to a non-root user before the `CMD` or `ENTRYPOINT` runs. Many official base images already ship an unprivileged user for this (`node` on the `node` images, `nonroot` on distroless images) so it's often a one-line `USER node` rather than defining your own.
FROM node:20-slim
WORKDIR /app
COPY --chown=node:node . .
RUN npm ci --omit=dev
USER node
EXPOSE 3000
CMD ["node", "server.js"]
Pin base image versions and scan them
`FROM node:latest` means your build silently pulls a different image every time it runs, with no record of what actually shipped. Pin to a specific version tag (`node:20.14.0-slim`) or, for maximum reproducibility, a digest (`node@sha256:...`). Run a vulnerability scanner — Trivy, Grype, or Docker Scout — against every built image in CI, and fail the build on criticals. Base images accumulate CVEs over time even when your own code doesn't change, so rebuilding periodically against an updated base matters even for an application with no new commits.
trivy image --severity HIGH,CRITICAL --exit-code 1 myapp:latest is enough to gate a pipeline. Start there before reaching for a heavier commercial scanning platform — most of the value is in catching known CVEs early, and the free tools cover that well.
Secrets in a Dockerfile leak even if you delete them later
`ENV API_KEY=xyz` or a `COPY` of a credentials file, followed by a later `RUN rm` to "clean up," does not remove the secret from the image — Docker layers are immutable and additive, so the secret still exists in the earlier layer and is extractable with `docker history` or by inspecting the layer tarballs directly. Use BuildKit secret mounts (`RUN --mount=type=secret`) for build-time secrets that never persist in a layer, and inject runtime secrets through environment variables or mounted files at container start, not at build time.
Run docker history --no-trunc myimage against any image you've built and check what shows up. It's a common and unpleasant surprise the first time you find a leaked token in a layer that was supposedly "removed" three instructions later.
Drop Linux capabilities you don't need
A container gets a default set of Linux capabilities that most applications never use — `NET_RAW` for raw sockets, `SYS_ADMIN` for a wide range of administrative operations. Run with `--cap-drop=ALL` and add back only what's needed (`--cap-add=NET_BIND_SERVICE` if you're binding to a low port, for example). Never run with `--privileged` in production; it disables the container isolation almost entirely and is a debugging convenience, not a deployment configuration.
Read-only root filesystem for stateless services
A stateless service that writes nothing to disk at runtime can run with `--read-only`, mounting only the specific directories it needs (like `/tmp`) as writable tmpfs volumes. This closes off an entire class of post-exploitation persistence — an attacker with code execution can't drop a webshell or modify the application binary on disk if the filesystem won't accept the write.
| Practice | Risk it addresses |
|---|---|
| Non-root USER | Container-escape privilege escalation |
| Pinned + scanned base images | Known CVEs in OS/runtime packages |
| BuildKit secret mounts | Credentials leaking into image layers |
| --cap-drop=ALL | Unused kernel capabilities as attack surface |
| --read-only filesystem | Runtime tampering / webshell persistence |
Wrapping up
None of this requires new tooling most teams don't already have — a non-root user, a pinned and scanned base image, BuildKit secret mounts, and dropped capabilities cover the majority of real-world Docker security incidents. Bake them into a base Dockerfile template once and every service built from it inherits the defaults for free.
Independent software engineer in Nairobi specialising in Acumatica customisations, Laravel backends, and tax fiscalisation integrations across East and Southern Africa.