Production-ready images: users, healthchecks and limits
The same container that works on your laptop can still be too big, too privileged and too trusting for a server. Five habits fix most of it.
? What do the diagram colours mean
key idea / Docker itselfcontainer / good pathhost / watch outproblem / blockedneutral
The checklist
An image that runs on your laptop is not yet an image you want on a server. Four habits close most of the gap.
Small base images
Every package in the base is something to download, store and patch. Start from the smallest base your app runs on, and use a multi-stage build (chapter 4) so compilers never reach it.
| Base (Node example) | Roughly | Trade-off |
|---|---|---|
node:22 | 1 GB | full Debian: every tool, most to patch |
node:22-slim | 200 MB | Debian minus extras; a safe default |
node:22-alpine | 150 MB | musl libc: a few native modules behave differently |
| distroless | 130 MB | no shell or package manager at all; harder to debug |
Pin the tag (chapter 2). For fully repeatable builds, pin the digest too: FROM node:22-slim@sha256:….
Run as a non-root user
Add a user in the Dockerfile and switch to it with USER. Everything after that line, and the running container, uses it. Files you COPY are owned by root unless you add --chown.
# Alpine
RUN addgroup -S app && adduser -S app -G app
USER app
# Debian / Ubuntu
RUN groupadd -r app && useradd -r -g app app
USER appA non-root process cannot bind ports below 1024, so listen on 3000 or 8080 inside and publish whatever you like outside.
Healthchecks
“Running” only means the process exists. A HEALTHCHECK asks the app itself whether it is working.
Resource limits
| Flag | Limits |
|---|---|
--memory 256m | RAM; above it the process is OOM-killed (exit 137) |
--cpus 0.5 | CPU time: half of one core |
--pids-limit 200 | number of processes, which stops fork bombs |
--read-only | the container filesystem; add --tmpfs /tmp for scratch space |
--cap-drop ALL | Linux capabilities; add back only what the app needs |
--security-opt no-new-privileges | setuid binaries cannot gain root |
In Compose these live under each service, for example mem_limit: 256m, cpus: 0.5 and read_only: true.
Scan for known vulnerabilities
Scanners compare every package in an image against public CVE databases. Run one locally and in CI (chapter 19), and rebuild on a schedule so base-image fixes reach you.
$ docker scout cves myapp:1.0 # built into Docker Desktop
$ trivy image myapp:1.0 # open source, common in CISecrets
Environment variables (chapter 6) are configuration, not a vault. Know where each method leaks:
| Method | Who can read the value |
|---|---|
ENV DB_PASSWORD=… in a Dockerfile | anyone with the image: docker history, docker image inspect |
--build-arg DB_PASSWORD=… | can be recorded in the image history |
-e or --env-file | not in the image, but shown by docker inspect on the container |
| a file mounted at run time | only processes that can read that file |
- Never put real secrets in
ENVorARG. - A secret needed while building (a private registry token): a BuildKit secret mount,
RUN --mount=type=secret,id=npmrc …withdocker build --secret id=npmrc,src=.npmrc. It exists for that one step and is not saved in a layer. - In production: the platform’s secret store (Kubernetes Secrets, Docker secrets, AWS Secrets Manager, Vault), usually mounted as files.
All together
FROM node:22 AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build && npm prune --omit=dev
FROM node:22-slim
WORKDIR /app
COPY --from=build --chown=node:node /app /app
# the official node images already contain a "node" user
USER node
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
CMD node -e "fetch('http://localhost:3000/health').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"
CMD ["node", "dist/server.js"]$ docker run -d --name api --restart unless-stopped \
--memory 256m --cpus 0.5 --pids-limit 200 \
--read-only --tmpfs /tmp --cap-drop ALL \
-p 8080:3000 myapp:1.0Try it yourself
-
Step 1 · Who are you inside?
Most official images run as root unless told otherwise.
--useroverrides it at run time.$ docker run --rm alpine:3.20 id uid=0(root) gid=0(root) $ docker run --rm --user 1000:1000 alpine:3.20 id uid=1000 gid=1000 -
Step 2 · Watch a healthcheck
The check runs every 5 seconds. After the first pass,
docker psshows(healthy).$ docker run -d --name hc --rm \ --health-cmd 'wget -q -O /dev/null http://localhost/ || exit 1' \ --health-interval 5s nginx:1.27-alpine $ sleep 6; docker ps --filter name=hc --format '{{.Names}} {{.Status}}' hc Up 6 seconds (healthy) $ docker stop hc -
Step 3 · Hit a memory limit
The shell tries to hold 100 MB in a 32 MB container. The kernel kills it.
$ docker run --name oom --memory 32m alpine:3.20 sh -c 'x=$(head -c 100000000 /dev/zero | tr "\0" a)' $ docker inspect -f '{{.State.ExitCode}} oom={{.State.OOMKilled}}' oom 137 oom=true $ docker rm oom -
Step 4 · A read-only filesystem
$ docker run --rm --read-only alpine:3.20 touch /hello touch: /hello: Read-only file system $ docker run --rm --read-only --tmpfs /tmp alpine:3.20 touch /tmp/hello && echo ok ok
--build-arg or ENV “just for the build”. It can end up in the image history, readable by anyone who pulls the image. Use a BuildKit secret mount.Recap
- Start from the smallest base that works, and build in a separate stage.
- Add a user and switch to it with
USER: root in a container is root on the kernel. HEALTHCHECKreports whether the app works, not just whether it runs.--memory,--cpusand--pids-limitare cgroup limits; exit 137 means the memory limit was hit.- Scan images for CVEs, and keep secrets out of
ENV,ARGand layers.
Quick check
A container keeps dying with exit code 137. What is the most likely cause?
Show answer
Answer: It hit its memory limit and was OOM-killed.
137 is 128 + 9 (SIGKILL), usually from the kernel OOM killer. docker inspect shows OOMKilled=true.
Why add USER app to a Dockerfile?
Show answer
Answer: If the process escapes the container, it is an ordinary user on the host, not root.
Root in a container is root on the shared kernel. A non-root user limits the damage of an escape.
Production-ready images: common questions
What does Docker exit code 137 mean?
The process was killed with SIGKILL (128 + 9), most often by the kernel OOM killer after the container hit its memory limit. docker inspect shows OOMKilled=true.
Why should a container not run as root?
Root inside a container is root on the shared host kernel. If the process escapes, an unprivileged user limits what it can do.