docker build succeeding tells you the image compiles. It tells you nothing about whether that image is safe or sane to run in production. Here's the gap we check before it goes anywhere near go-live.
Run as a non-root user
By default, a container runs as root unless told otherwise — which means a container escape hands an attacker root on the host, not a restricted user. There's rarely a real reason for your application process to need root inside the container.
RUN addgroup -S app && adduser -S app -G app
USER appMulti-stage builds — ship the app, not the toolchain
A naive Dockerfile ships your compiler, package manager cache, and dev dependencies into production alongside your actual app — larger attack surface, larger image, slower pulls. Multi-stage builds compile in one stage and copy only the built artifact into a minimal final image.
FROM node:20 AS build
WORKDIR /app
COPY . .
RUN npm ci && npm run build
FROM node:20-alpine
WORKDIR /app
COPY --from=build /app/dist ./dist
COPY --from=build /app/node_modules ./node_modules
CMD ["node", "dist/index.js"]A real health check, wired to the orchestrator
A HEALTHCHECK directive (or your orchestrator's equivalent readiness/liveness probe) is what turns "the process is technically running" into "the process is actually serving requests correctly" — without one, a hung process behind a load balancer just silently eats requests.
Scan the image before it reaches a registry your deploy pipeline trusts
Base images ship with known CVEs, and dependencies accumulate more over time. Scanning (Trivy and Docker Scout are both reasonable, widely-used choices) as a pipeline stage catches this before the image is pushed, not after it's already running.
What this doesn't solve
A hardened image running on an unhardened host, with an over-permissioned Docker daemon or a docker.sock mounted somewhere it shouldn't be, is still exposed. Container security is layered — image hardening is one layer, not the whole picture.