Home/Blog

Docker in Production: Dockerfile and Image Best Practices

Production Docker done right: multi-stage Dockerfiles for Node.js and Go, non-root images, digest pinning, scanning, signing, and the failures to expect.

Docker in Production: Dockerfile and Image Best Practices

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 CMD and handle SIGTERM, or every deploy drops in-flight requests.

The production checklist

PropertyWhy it mattersHow
Multi-stage buildSmaller image, fewer packages to patch, no build tools in productionSeparate deps, build and runtime stages
Minimal base, pinned by digestReproducible builds; fewer vulnerabilitiesFROM image:tag@sha256:..., updated by Renovate or Dependabot
Non-root userLimits the damage of a compromised processUSER with a numeric UID, runAsNonRoot in Kubernetes
Read-only root filesystemAttackers cannot drop binaries or modify code--read-only plus a tmpfs for scratch space
No secrets in layersLayers and history are readable by anyone who can pullBuildKit --mount=type=secret, runtime injection
Graceful shutdownDeploys do not drop requestsExec-form CMD, SIGTERM handler, drain timeout
Logs to stdout and stderrThe platform collects and rotates themNo log files inside the container
Scanned, signed, deployed by digestYou know what runs and that you built itTrivy, 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 ci layer, 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), run npm rebuild <package> for that package explicitly.
  • npm prune --omit=dev. Test frameworks and bundlers stay in the build stage.
  • Distroless runtime. The nodejs24-debian13 image contains Node and its runtime libraries, no shell and no package manager. Its entrypoint is already node, which is why CMD lists only the script.
  • Files owned by root. The nonroot user can read the application but cannot change it. Do not --chown code 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

BaseContentsStrengthsWatch out for
Distroless (Google)Runtime and its libraries onlyTiny attack surface, nonroot variantsNo shell; debug with ephemeral containers or debug tags
Docker Hardened ImagesMinimal, non-root, with SBOM and provenanceFree and Apache 2.0 licensed since December 2025, maintained by DockerAnother catalog to track for updates
Chainguard imagesMinimal Wolfi-based imagesRebuilt frequently, low CVE countsFree public images carry only latest and latest-dev tags; version tags come from the paid catalog or the five-image Catalog Starter
Debian slimSmall Debian with a shell and aptFamiliar, easy to debug, glibcMore packages to patch than distroless
Alpinemusl libc and BusyBoxVery smallmusl differs from glibc in DNS and performance behavior; glibc binaries fail to start
scratchNothingSmallest possibleYou 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*.yml

For 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-scripts
docker 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_DIGEST

In 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-size below 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.