Harden your Dockerfile: the anti-patterns that give an insecure container away
A container that starts is not a secure container. Most Dockerfiles pass CI, serve the app… and at the same time run as root, drag secrets in their layers and trust images that shift under your feet. These are the anti-patterns a linter checks, why they matter and how to fix them.
1. Running as root (the most common)
By default a container runs as root. If an attacker gets execution inside the container and then finds an escape (or a mounted volume), they inherit root on the host. The defense is one line:
RUN adduser --system --no-create-home app
USER app
Declare USER near the end, after installing dependencies (which do need privileges).
2. Unpinned base image (:latest or no tag)
FROM node or FROM node:latest mean "whatever exists today". Tomorrow the build differs and you don't know what changed: reproducibility is broken and the surface moves on its own. Pin a concrete tag —or better, an immutable digest:
FROM node:20.11-alpine
# or, bulletproof:
FROM node:20.11-alpine@sha256:e4b2f...
3. Secrets in ENV / ARG
A token in ENV DB_PASSWORD=... doesn't "vanish" at runtime: it's baked into the image layers and its history. Anyone with the image extracts it with docker history. Don't put secrets in the Dockerfile: use build secrets (--secret) or inject them at runtime.
4. curl | sh and remote ADD
RUN curl https://get.example.com | sh runs remote code unverified: if that server is compromised, so is your build. Same with ADD https://…, which downloads and trusts. Download, verify the checksum, then run; and use COPY (not ADD) for local files.
5. Fat image = more surface
apt-get install without --no-install-recommends drags extra packages (more CVEs to patch). Install only what you need and clean up in the same layer:
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*
6. No HEALTHCHECK
Without a healthcheck, the orchestrator can't tell whether your app is alive or hung. A container that's "up" but broken keeps receiving traffic. Add one.
docker-compose or Kubernetes manifest into the Dockerfile / IaC linter and it gives you an A–F grade with these anti-patterns flagged. All in your browser; the config is never sent anywhere.And at deploy time: compose and Kubernetes
Hardening doesn't end at the Dockerfile. In docker-compose and K8s, the usual suspects are:
privileged: true: near-total host access. It's root on the machine; remove it unless strictly required.hostPath/hostNetwork: mounting host paths or sharing its network breaks isolation.allowPrivilegeEscalation: true: lets a process gain privileges via setuid. Set it tofalse.- No
resources.limits: a pod can exhaust the node (noisy-neighbor DoS). - No
runAsNonRoot: true: the same problem as point 1, at the orchestrator level.
A decent reference Dockerfile
FROM node:20.11-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY . .
USER node
HEALTHCHECK CMD wget -qO- http://localhost:3000/health || exit 1
CMD ["node", "server.js"]
Pinned base, no secrets, non-root user, healthcheck and production dependencies only. Nothing exotic: it's five habits.
Checklist
- ✅ Non-privileged
USER; nothing runs as root. - ✅ Base image pinned by tag or digest, never
:latest. - ✅ Zero secrets in
ENV/ARG: build secrets or runtime. - ✅ No
curl|shor remoteADDwithout a checksum. - ✅ Install only what you need and clean the package cache.
- ✅
HEALTHCHECKdefined. - ✅ On K8s: no
privileged, withrunAsNonRootandlimits.
A good Dockerfile isn't longer: it's more boring. And boring, in security, is exactly what you want.