Stage 4 · Production · Chapter 18 of 20 · 7 min read

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.

From working image to production imageFour changes turn an image that works into one you can run in production: a small base, a non-root user, a healthcheck, and a vulnerability scan, plus resource limits when you run it.Small baseslim or alpineUSER appnot rootHEALTHCHECK“am I working?”Scanknown CVEsRun with limitsmemory, CPUNone of these change what your app does. They change what happens when something goes wrong.

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)RoughlyTrade-off
node:221 GBfull Debian: every tool, most to patch
node:22-slim200 MBDebian minus extras; a safe default
node:22-alpine150 MBmusl libc: a few native modules behave differently
distroless130 MBno 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

Why the user inside mattersA process running as root in a container is root on the shared kernel. If it escapes the container it has full control of the host. A process running as an unprivileged user is limited even if it escapes.Default: rootWith USER appcontainernode server.jsuid 0 (root)containernode server.jsuid 1000 (app)escapesescapesroot on the host kernelcan read and change everythinguid 1000 on the hostan ordinary user, no special rights
Containers share the host kernel (chapter 1). The user ID inside is a real user ID on that kernel.

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 app

A 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.

Health statesA container with a HEALTHCHECK starts in the starting state, becomes healthy when the check passes, and becomes unhealthy after the check fails a set number of times in a row.startinggrace periodpasseshealthycheck exits 0failsunhealthy3 failures in a rowdocker ps shows the state. Compose (service_healthy) and orchestrators act on it;plain docker run does not restart an unhealthy container.

Resource limits

Limits are cgroupsdocker run --memory and --cpus put the container in a control group. If it tries to use more memory than allowed, the kernel kills its process with exit code 137. CPU use above the limit is slowed down, not killed.cgroup: --memory 256m --cpus 0.5Container apimay use 256 MB and half a CPUover memoryOOM-killed, exit 137over CPUthrottled, slowerWithout limits, one leaking container can starve every other process on the host.
FlagLimits
--memory 256mRAM; above it the process is OOM-killed (exit 137)
--cpus 0.5CPU time: half of one core
--pids-limit 200number of processes, which stops fork bombs
--read-onlythe container filesystem; add --tmpfs /tmp for scratch space
--cap-drop ALLLinux capabilities; add back only what the app needs
--security-opt no-new-privilegessetuid 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 CI

Secrets

Environment variables (chapter 6) are configuration, not a vault. Know where each method leaks:

MethodWho can read the value
ENV DB_PASSWORD=… in a Dockerfileanyone with the image: docker history, docker image inspect
--build-arg DB_PASSWORD=…can be recorded in the image history
-e or --env-filenot in the image, but shown by docker inspect on the container
a file mounted at run timeonly processes that can read that file

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.0

Try it yourself

  1. Step 1 · Who are you inside?

    Most official images run as root unless told otherwise. --user overrides 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
  2. Step 2 · Watch a healthcheck

    The check runs every 5 seconds. After the first pass, docker ps shows (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
  3. 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
  4. 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
Common mistakePassing a secret with --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

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.