A production-grade Docker image is built by a multi-stage Dockerfile, starts from a minimal base image pinned by digest, runs as a non-root user on a read-only filesystem, shuts down cleanly on SIGTERM, and is scanned, signed and deployed by digest rather than by tag. Those properties remove most container incidents: bloated images, build secrets leaking into layers, root processes, requests dropped during deploys, and a latest tag that changes under you. This guide gives working Dockerfiles for Node.js and Go, the build and supply chain steps around them, and the production failures that appear when any of it is missing.
Key takeaways
- Order Dockerfile instructions by how often they change, and use BuildKit cache mounts so dependency installs do not rerun on every code change.
- The final stage should contain your artifact, its runtime and nothing else: no compilers, no package managers, ideally no shell.
- Run as a non-root user, keep code owned by root and read-only, drop all capabilities, and set memory limits your runtime actually respects.
- Pin base images by digest and deploy your own images by digest. Tags are mutable; digests are not.
- Build with SBOM and provenance attestations, scan with a pinned scanner, and sign the digest you will deploy.
- Use exec-form
CMDand handle SIGTERM, or every deploy drops in-flight requests.
The production checklist
| Property | Why it matters | How |
|---|---|---|
| Multi-stage build | Smaller image, fewer packages to patch, no build tools in production | Separate deps, build and runtime stages |
| Minimal base, pinned by digest | Reproducible builds; fewer vulnerabilities | FROM image:tag@sha256:..., updated by Renovate or Dependabot |
| Non-root user | Limits the damage of a compromised process | USER with a numeric UID, runAsNonRoot in Kubernetes |
| Read-only root filesystem | Attackers cannot drop binaries or modify code | --read-only plus a tmpfs for scratch space |
| No secrets in layers | Layers and history are readable by anyone who can pull | BuildKit --mount=type=secret, runtime injection |
| Graceful shutdown | Deploys do not drop requests | Exec-form CMD, SIGTERM handler, drain timeout |
| Logs to stdout and stderr | The platform collects and rotates them | No log files inside the container |
| Scanned, signed, deployed by digest | You know what runs and that you built it | Trivy, cosign, digest references in manifests |
A production Dockerfile for Node.js
FROM node:24-trixie-slim@sha256:REPLACE_WITH_DIGEST AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm \
npm ci --ignore-scripts
FROM deps AS build
COPY . .
RUN npm run build && npm prune --omit=dev
FROM gcr.io/distroless/nodejs24-debian13:nonroot@sha256:REPLACE_WITH_DIGEST
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build /app/package.json ./
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
USER nonroot
EXPOSE 8080
CMD ["dist/server.js"]What each choice buys you:
- Lockfile first, source second. Changing a source file does not invalidate the
npm cilayer, so most rebuilds skip the dependency install entirely. The cache mount keeps npm's download cache between builds without putting it in the image. npm ci --ignore-scripts. Install scripts are how several recent npm supply chain attacks executed on build machines. If a dependency genuinely needs one (native modules such as image libraries), runnpm rebuild <package>for that package explicitly.npm prune --omit=dev. Test frameworks and bundlers stay in the build stage.- Distroless runtime. The
nodejs24-debian13image contains Node and its runtime libraries, no shell and no package manager. Its entrypoint is alreadynode, which is whyCMDlists only the script. - Files owned by root. The
nonrootuser can read the application but cannot change it. Do not--chowncode to the runtime user unless it must write there.
The Node.js process is PID 1 in this container. A process running as PID 1 gets no default signal handling from the kernel, so SIGTERM does nothing unless your code handles it:
const server = app.listen(8080);
process.on("SIGTERM", () => {
server.close(() => process.exit(0));
setTimeout(() => process.exit(1), 25_000).unref();
});server.close stops accepting connections and lets in-flight requests finish. The timer forces an exit before the orchestrator's grace period runs out (30 seconds by default in Kubernetes).
A production Dockerfile for Go
FROM --platform=$BUILDPLATFORM golang:1.27-trixie@sha256:REPLACE_WITH_DIGEST AS build
ARG TARGETOS TARGETARCH
WORKDIR /src
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod go mod download
COPY . .
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH \
go build -trimpath -ldflags="-s -w" -o /out/server ./cmd/server
FROM gcr.io/distroless/static-debian13:nonroot@sha256:REPLACE_WITH_DIGEST
COPY --from=build /out/server /server
USER nonroot:nonroot
EXPOSE 8080
ENTRYPOINT ["/server"]--platform=$BUILDPLATFORM runs the compiler natively on the build machine and cross-compiles for each target, which is much faster than emulating ARM on an x86 runner. CGO_ENABLED=0 produces a static binary, so the runtime image needs no libc. static-debian13 still provides CA certificates, timezone data and a nonroot user, which is why it is a better default than scratch: a scratch image without CA certificates fails every outbound TLS call with an "unknown authority" error.
Python follows the same shape: install dependencies into a virtual environment in a build stage (uv or pip with a lockfile), copy the environment into a slim runtime stage, and run as a numeric non-root UID.
Choosing a base image
| Base | Contents | Strengths | Watch out for |
|---|---|---|---|
| Distroless (Google) | Runtime and its libraries only | Tiny attack surface, nonroot variants | No shell; debug with ephemeral containers or debug tags |
| Docker Hardened Images | Minimal, non-root, with SBOM and provenance | Free and Apache 2.0 licensed since December 2025, maintained by Docker | Another catalog to track for updates |
| Chainguard images | Minimal Wolfi-based images | Rebuilt frequently, low CVE counts | Free public images carry only latest and latest-dev tags; version tags come from the paid catalog or the five-image Catalog Starter |
| Debian slim | Small Debian with a shell and apt | Familiar, easy to debug, glibc | More packages to patch than distroless |
| Alpine | musl libc and BusyBox | Very small | musl differs from glibc in DNS and performance behavior; glibc binaries fail to start |
| scratch | Nothing | Smallest possible | You must add CA certificates, timezone data and a user yourself |
Our default is distroless or a hardened image for the runtime stage, and the language's official Debian image for build stages. Pin both by digest and let Renovate or Dependabot open pull requests when the digests move, so base image patches arrive as reviewable changes instead of surprises.
Keep the build context clean
Everything in the build context can end up in a layer. A .dockerignore keeps credentials and bulk out:
.git
.github
node_modules
dist
coverage
.env
.env.*
*.pem
*.key
Dockerfile*
docker-compose*.ymlFor credentials needed during the build, such as a private registry token, use a secret mount. It is available to one RUN step and never written to a layer:
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \
npm ci --ignore-scriptsdocker buildx build --secret id=npmrc,src="$HOME/.npmrc" -t api:dev .Never use ARG or ENV for secrets. Build arguments are recorded in image history, and a file deleted in a later step still exists in the layer that added it.
Run it locked down
The same restrictions apply whether the container runs under Docker, Compose or Kubernetes:
docker run --rm \
--read-only --tmpfs /tmp \
--cap-drop ALL --security-opt no-new-privileges \
--user 65532:65532 \
--memory 512m --cpus 1 --pids-limit 256 \
-p 8080:8080 \
ghcr.io/acme/api@sha256:REPLACE_WITH_DIGESTIn Kubernetes these become securityContext fields (runAsNonRoot, readOnlyRootFilesystem, allowPrivilegeEscalation: false, capabilities.drop: ["ALL"]) plus resource requests and limits. Our Kubernetes security guide covers enforcing them cluster-wide with Pod Security admission and policy engines.
Two runtime details trip teams up:
- Kubernetes ignores the Dockerfile
HEALTHCHECK. It is used by Docker and Compose only. In Kubernetes, define readiness and liveness probes instead. - Memory limits and runtimes. A container limit of 512 MB does not tell every runtime to stay under it. For Node.js, set
--max-old-space-sizebelow the limit (leaving room for buffers and native memory). For the JVM, use-XX:MaxRAMPercentage. Otherwise the kernel's OOM killer ends the process with no application-level error at all.
Logs belong on stdout
Write logs to stdout and stderr as structured JSON and let the platform collect them. On plain Docker hosts, the default json-file logging driver does not rotate logs unless you configure it, which is how a chatty container fills a disk. Set rotation in /etc/docker/daemon.json:
{
"log-driver": "json-file",
"log-opts": {
"max-size": "50m",
"max-file": "3"
}
}Build, scan, sign and push by digest
A release build produces multi-architecture images with an SBOM and build provenance attached, records the digest, scans that exact digest, and signs it:
docker buildx build \
--platform linux/amd64,linux/arm64 \
--sbom=true --provenance=mode=max \
--tag "ghcr.io/acme/api:${GIT_SHA}" \
--metadata-file build-meta.json \
--push .
DIGEST=$(jq -r '."containerimage.digest"' build-meta.json)
IMAGE="ghcr.io/acme/api@${DIGEST}"
trivy image --exit-code 1 --severity CRITICAL,HIGH --ignore-unfixed "$IMAGE"
cosign sign --yes "$IMAGE"In CI, cosign sign uses keyless signing: the workflow's OIDC identity is recorded in a short-lived certificate, so there is no signing key to leak. At deploy time, verify the signature against the identity you expect:
cosign verify "$IMAGE" \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
--certificate-identity-regexp '^https://github.com/acme/api/\.github/workflows/'Pin the scanner too. In March 2026, attackers force-pushed 76 of 77 version tags of the aquasecurity/trivy-action GitHub Action and published a malicious Trivy release (GHSA-69fq-xp46-6x23). Aqua's advisory notes that Trivy images referenced by digest were not affected. Tags are pointers someone else can move. Digests are content. Our GitHub Actions guide shows the pinned workflow version of this pipeline, and the supply chain security guide covers SBOMs and dependency policy.
Tag images with the Git commit SHA for humans, and deploy the digest. A Kubernetes manifest that says image: ghcr.io/acme/api@sha256:... runs the same bytes on every node, today and during next month's rollback.
Compose for local development
Compose is for development and small single-host deployments. Keep the production image and the development setup in the same Dockerfile, using a target for a dev stage if you need extra tools:
services:
api:
build:
context: .
target: build
command: ["npm", "run", "dev"]
ports:
- "8080:8080"
env_file: .env
depends_on:
db:
condition: service_healthy
db:
image: postgres:17
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: app
POSTGRES_DB: app
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app"]
interval: 5s
timeout: 3s
retries: 10
volumes:
pgdata:condition: service_healthy waits for Postgres to accept connections, not just for its container to start. Where these containers should run in production (a serverless container platform, ECS, or Kubernetes) is a separate decision; our Kubernetes vs serverless guide walks through it.
What goes wrong in production
Deploys drop requests. Shell-form CMD npm start runs your app under /bin/sh -c and npm, neither of which forwards SIGTERM to your process. The orchestrator waits out the grace period and then kills it mid-request. Use exec form, run the runtime directly, and handle the signal. In Kubernetes, also give the load balancer time to stop sending traffic before shutdown begins, with a short preStop delay.
OOMKilled with no stack trace. The runtime's heap was sized for the node, not the container. Align heap settings with the limit and alert on OOMKilled events, not just on restarts.
Different nodes run different code. A deployment referenced api:latest or api:v2, the tag moved between pulls, and half the fleet runs a different build. Deploy digests. Use imagePullPolicy: IfNotPresent with digests and nothing ever drifts.
TLS fails only in production. The final stage was scratch or a hand-built image without CA certificates or timezone data. The error appears on the first outbound HTTPS call, often after the deploy looked healthy.
A secret ships in a layer. Someone copied .env into the image during a debugging session, or passed a token as a build argument. Anyone who can pull the image can read it with docker history or by extracting the layer. Rotate the secret, then fix the build; deleting the image tag does not remove copies already pulled.
Builds get slow, then the CI disk fills. COPY . . before the dependency install invalidates the cache on every commit. Separately, self-hosted runners accumulate build cache until the disk is full. Schedule docker builder prune with a retention filter on long-lived build hosts.
The image is clean today and vulnerable next month. Nothing changed in your code, but new CVEs were published against packages in your base image. Rebuild on a schedule, not only on commits, and let scanning run against what is deployed, not just what is being built.
Frequently Asked Questions
Should Docker containers run as root?
No. Unless user namespaces are enabled, root in a container is UID 0 on the host, held back only by dropped capabilities and seccomp, so any escape or misconfigured mount gives an attacker far more to work with. Set a non-root USER in the Dockerfile, enforce runAsNonRoot in Kubernetes, and keep application files owned by root so the app can read its code but not modify it.
What is a multi-stage Docker build?
A multi-stage build uses several FROM stages in one Dockerfile: early stages carry compilers, package managers and dev dependencies, and the final stage copies in only the built artifacts. The result is a smaller image with far fewer packages that can carry vulnerabilities. It also keeps build tools out of production, where an attacker could use them.
Which base image should I use for production?
Use the smallest image your application runs on reliably. Distroless, Docker Hardened Images and Chainguard images remove shells and package managers; Debian slim images are a pragmatic choice when you need a shell for debugging. Alpine is small, but its musl libc behaves differently from glibc, so test before you switch.
Does Kubernetes still use Docker?
Kubernetes removed its built-in Docker Engine integration (dockershim) in version 1.24 and runs containers through containerd or CRI-O. Images built with Docker are standard OCI images and run unchanged. Docker remains the usual tool for building images and for local development.
How do I keep secrets out of Docker images?
Never pass secrets through ARG, ENV or COPY, because they persist in image layers and history even if a later step deletes them. Use BuildKit secret mounts for build-time credentials and inject runtime secrets from your orchestrator or a secrets manager. Add .env files and private keys to .dockerignore so they never enter the build context.
How do I make Docker images smaller?
Use a multi-stage build, start the final stage from a minimal base such as distroless, install only production dependencies, and exclude everything else with a .dockerignore file. Combine package installation and cleanup in the same RUN step, because files deleted in a later layer still ship in the earlier one.
Need help hardening your containers?
We build and run containerized systems where the image pipeline is part of the product: reproducible builds, minimal images, signing, and runtime policy that holds up in an audit. If we built it, we are on the pager for it, and you talk to the engineer who would do the work from the first call.