Containers
What a container really is (a Linux process with walls and a budget), how images and layers work, and how to write Dockerfiles that build fast, ship small, run safely and shut down cleanly.
The 30-second version
You've probably heard “it works on my machine”. Your laptop has Node 24, the server has Node 20. Your laptop has a system library the server lacks. Containers fix this by shipping the program together with its runtime, libraries and files, so every machine runs exactly the same bytes.
The tool most people use is Docker. Three commands cover the whole idea:
docker build -t hello:1.0 . # pack: turn the Dockerfile in this folder (.) into an image named hello:1.0
docker run -d -p 8080:8080 hello:1.0 # unpack: start a container (-d = in the background,
# -p = forward port 8080 on your machine into it)
docker ps # list running containersOne image can start any number of containers, and each one gets its own private scratch space. Try it:
What this topic covers
- What a container really is (§02–§03): a Linux process with walls and a budget, and how that differs from a virtual machine (VM).
- Building images (§04–§07): the Dockerfile, layers, the build cache, and multi-stage builds that turn a 500 MB image into a 15 MB one.
- Running them well (§08–§10): ports, volumes, configuration, shutting down cleanly, and Docker Compose for local development.
- Production (§11–§13): security, scanning, and the pitfalls that bite at scale.
A container is just a process
A process is a running program. The kernel, the core of the operating system, gives every process an ID number (its PID) and lets it see the whole machine: every file, every other process, every network card. A container is a process that the kernel shows a smaller world.
The three ingredients
Walls: namespaces
Namespaces give the process a private view: its own process list, hostname, network card and IP, and file tree. It can't see what's outside.
Budget: cgroups
cgroups cap how much CPU, memory and how many processes it may use. Go over the memory cap and the kernel kills it.
Its own files: the image
The image's files become the process's whole file system: its /. That's how a container “has” Alpine Linux and Node 24 even on an Ubuntu host.
Switch the walls on and off below. The program never changes; only its view of the machine does:
$ ps
PID USER COMMAND
1 node node server.js
19 node ps$ hostname 94f3083f2d98
$ ip addr 1: lo inet 127.0.0.1/8 2: eth0 inet 172.17.0.7/16
$ ls / app bin dev etc home lib proc sys tmp usr var (only the image's files)
press the button…See it for yourself
Start a container that just sleeps, then look at it from outside and from inside:
$ docker run -d --name sleepy alpine:3.22 sleep 300
$ ps -o pid,user,cmd -C sleep # on the host: an ordinary process
PID USER CMD
1676 root sleep 300
$ docker exec sleepy ps # inside the container
PID USER TIME COMMAND
1 root 0:00 sleep 300 # ← the same process, but it believes it's PID 1
8 root 0:00 psSame process, two numbers. The host calls it 1676. Inside its own PID namespace it's PID 1, the first process in its world, and it can't see the host's other processes at all. (PID 1 has special duties around signals; that matters a lot in §09.)
The budget in action
Give a container 64 MB of memory, then make it eat memory forever:
$ docker run --name hungry -m 64m alpine:3.22 sh -c 'tail /dev/zero'
$ docker inspect -f 'OOMKilled={{.State.OOMKilled}} ExitCode={{.State.ExitCode}}' hungry
OOMKilled=true ExitCode=137tail /dev/zero keeps reading an endless stream with no line breaks, so its memory use grows until the kernel's out-of-memory killer stops it (OOM-killed). The exit code 137 means “killed by SIGKILL”: SIGKILL is the kernel's “stop now, no cleanup” signal, which a program can't catch or refuse (§09 covers signals properly). You'll see that number again, in Docker and in Kubernetes, whenever something runs out of memory. A CPU limit (--cpus 0.5) works differently: nothing is killed, the process is just paused for part of every 100 ms.
Who actually creates the walls?
When you type docker run, the docker command only sends a request to a background program, the Docker daemon. It hands the job to a container runtime: containerd fetches the image and prepares its files, and runc makes the namespaces and cgroups, then starts your program inside them. Kubernetes uses the same containerd and runc underneath. That's why an image built with Docker runs unchanged there.
Containers vs virtual machines
Each VM carries a whole operating system: GBs of disk, hundreds of MB of RAM before your app starts.
No guest kernel to boot: starting one is starting a process (≈0.3 s measured on this machine).
A hypervisor slices one physical machine into several virtual machines. Each VM boots a full guest operating system, kernel and all, before your app can start. A container skips all of that: the kernel is already running, so starting a container means starting one process.
The numbers
| Virtual machine | Container | |
|---|---|---|
| Start time | Tens of seconds to boot an OS (approximate; tiny “microVMs” such as Firecracker get this down to well under a second) | ≈0.3 s for docker run --rm alpine:3.22 true, measured on this machine (the process itself starts in milliseconds; most of that is Docker's setup) |
| Size | GBs: a whole OS install | MBs: often 5–250 MB (§07) |
| Memory overhead | Hundreds of MB for the guest OS before your app | Little beyond your process itself |
| Kernel | Its own, any OS (run Windows on Linux) | The host's. A Linux container needs a Linux kernel |
| Isolation | Strong: separate kernels, enforced by the CPU's virtualization hardware | Weaker: one kernel bug can break every wall at once |
That last row is the trade-off. All containers on a host make system calls into the same kernel. If an attacker finds a kernel bug, they may escape the container and reach the host and its neighbours. A VM escape needs a hypervisor bug, which is much rarer.
“But I run Docker on my Mac”
Containers need a Linux kernel, and macOS and Windows don't have one. Docker Desktop quietly starts a small Linux VM and runs your containers inside it. That's why file sharing between your Mac and a container can feel slow, and why an Apple Silicon Mac builds arm64 images by default (§12).
$ uname -r # the host's kernel version
6.18.44-fc-v185
$ docker run --rm alpine:3.22 uname -r # the kernel version as seen from inside a container
6.18.44-fc-v185 # ← same kernel: there is only oneYour first Dockerfile
Here is the service we'll package for the rest of this topic. It answers every request with the machine's hostname, so you can tell containers apart. (New to Go? The Go primer takes five minutes.)
package main
import (
"fmt"
"log"
"net/http"
"os"
)
func main() {
port := os.Getenv("PORT") // read config from the environment (§08)
if port == "" {
port = "8080"
}
http.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
host, _ := os.Hostname() // inside a container: the container's ID
fmt.Fprintf(w, "hello from %s\n", host)
})
log.Printf("listening on :%s", port)
log.Fatal(http.ListenAndServe(":"+port, nil)) // ":8080" = every network interface
}The Dockerfile
# 1. start from an image that has Go installed
FROM golang:1.27-alpine
# 2. cd into /src (created if missing)
WORKDIR /src
# 3. copy my project folder into /src
COPY . .
# 4. run a command at BUILD time
RUN go build -o /bin/hello .
# 5. document the port (doesn't open anything)
EXPOSE 8080
# 6. what to start at RUN time
CMD ["/bin/hello"]Build it, run it
$ docker build -t hello:1.0 .
$ docker run -d --rm --name hello -p 8080:8080 hello:1.0
$ curl localhost:8080
hello from 5a2777c0f2ef # ← the container's ID is its hostname
$ docker logs hello
2026/10/30 22:34:39 listening on :8080
$ docker stop helloIt works. It's also 508 MB.
IMAGE DISK USAGE CONTENT SIZE
hello:1.0 508MB 102MB # Go version
hello-node:1.0 244MB 62.5MB # Node versionDisk usage is the unpacked size; content size is the compressed download. The Go program itself is about 8 MB. The rest is the whole Go compiler, which the running service never uses. The Node image is mostly Node itself, which it does need.
This first Dockerfile has three more problems you can't see yet: it rebuilds slowly (§06), it ships build tools (§07), and it runs as root (§11). The next sections fix each one. First, what is an image, physically?
Images and layers
Every Dockerfile step that changes files produces one layer. Each layer is stored as a tar archive. The image is those layers in order, plus a small settings file (which command to run, environment variables, which user). Step through a build and then a running container:
1FROM node:24-alpine2WORKDIR /app3COPY package.json package-lock.json ./4RUN npm ci --omit=dev5COPY . .6CMD ["node", "server.js"]
FROM starts from someone else's image. node:24-alpine is itself a stack of read-only layers (simplified to two here: the Alpine Linux files, then Node). You download them once and every image built on them shares them.How the layers become one folder
The kernel feature that merges them is OverlayFS. It takes several read-only folders (the image layers) plus one writable folder (the writable layer) and presents them as a single folder tree. Three rules cover everything you saw above:
- Reading a path returns the copy from the top-most layer that has it.
- Writing a file from a lower layer first copies the whole file up into the writable layer. This is called copy-up (a file-level form of copy-on-write). A container that appends one line to a 1 GB file from the image pays for a 1 GB copy first.
- Deleting a file adds a whiteout marker on top that hides it. The bytes stay in the lower layer.
Seeing the layers
docker history lists an image's layers, newest first. Here is the Go image from §04:
$ docker history hello:1.0
CREATED BY SIZE
CMD ["/bin/hello"] 0B # settings only
EXPOSE [8080/tcp] 0B # settings only
RUN /bin/sh -c go build -o /bin/hello . # bu… 101MB # ← binary + Go's build cache!
COPY . . # buildkit 20.5kB
WORKDIR /src 8.19kB
WORKDIR /go 4.1kB # ┐
COPY /target/ / # buildkit 296MB # │ these came from golang:1.27-alpine:
RUN /bin/sh -c apk add --no-cache ca-certifi… 1.22MB # │ the Go toolchain
ADD alpine-minirootfs-3.24.3-x86_64.tar.gz /… 9.08MB # ┘ and Alpine itself(The bottom row says Alpine 3.24 because golang:1.27-alpine is built on Alpine 3.24; elsewhere we use alpine:3.22 directly. Both are fine.) Two surprises worth noticing. The RUN go build layer is 101 MB, not 8 MB, because Go's compile cache (in /root/.cache) was saved into the layer too. And 296 MB is the Go toolchain, which the service never needs at run time. §07 removes both.
Layers are shared
Two images built on node:24-alpine store and download its layers once. Docker identifies each layer by the SHA-256 hash of its contents, so identical layers are recognised everywhere:
$ docker image inspect -f '{{range .RootFS.Layers}}{{println .}}{{end}}' node:24-alpine
sha256:75c137aedfb2…
sha256:60bd9d2f4730…
sha256:593de0610faa…
sha256:c5462dd18906…
$ docker image inspect -f '…same…' my-node-app # built FROM node:24-alpine
sha256:75c137aedfb2… # ┐
sha256:60bd9d2f4730… # │ identical: stored once,
sha256:593de0610faa… # │ pulled once
sha256:c5462dd18906… # ┘
sha256:42860ef2f7b1… # ← my app's own layers start here
…That's why pushing a new version of your app usually uploads only a few MB: the server already has the base layers.
Tags move, digests don't
Every image has a digest: a SHA-256 hash of its contents. A tag is just a name pointing at a digest, and the owner can move it at any time. node:24-alpine points at a new build every time Node or Alpine ships a patch.
docker pull node:24-alpine # whatever the tag points to today
docker pull node@sha256:8a32c8cf649a49915a66e81e7a609223c708d0adabf288ee3f4077d765542f85
# └ exactly these bytes, foreverThe format of all this (layers, settings, digests) is a published standard, the OCI (Open Container Initiative) image spec. That's why an image built by Docker runs on Podman, containerd and Kubernetes, and can be stored in any container registry: Docker Hub, GitHub Container Registry, Amazon ECR.
Deleting a file doesn't delete it
Rule 3 has a nasty consequence. Here someone copies an npm token in, uses it, and deletes it “to be safe”:
FROM alpine:3.22
COPY .npmrc /root/.npmrc
RUN echo "pretend we npm install here" && rm /root/.npmrcThe running container can't see the file. But the image still has it:
$ docker save leaky -o leaky.tar && mkdir x && tar -xf leaky.tar -C x
$ for layer in x/blobs/sha256/*; do tar -tzf "$layer" 2>/dev/null | grep npmrc; done
root/.wh..npmrc # the RUN layer: just a whiteout marker
root/.npmrc # the COPY layer: the real file
$ tar -xzOf x/blobs/sha256/b6a85bcbc76f… root/.npmrc
NPM_TOKEN=npm_SuperSecret123Anyone who can pull the image can do this. Never COPY a secret into an image, even briefly. §11 shows the safe way (a BuildKit secret mount), and Secrets covers what to do once one has leaked.
The build cache and COPY order
The build cache works step by step, from the top. For each step Docker asks: “have I run exactly this before, on top of exactly the same layers?”
- For
COPY, “the same” means the contents of the copied files are identical (Docker hashes them; timestamps don't matter). - For
RUN, it means the command text is identical. Docker does not look at what the command would download.
The first step that misses re-runs, and so does every step after it, because their starting layers have changed. Try both Dockerfile orders:
The fix, in real output
From here on, assume a TypeScript version of the Node service: it has a dependency (Express) and a build step (npm run build compiles src/server.ts into dist/server.js). The first Dockerfile copies everything and then installs. Edit one line of code and npm install runs again:
#8 [2/5] WORKDIR /app CACHED
#9 [3/5] COPY . . DONE 0.0s ← server.ts changed, so this misses…
#10 [4/5] RUN npm install DONE 1.6s ← …and every dependency is reinstalled
#11 [5/5] RUN npm run build DONE 1.4sCopy only the dependency manifests first, install, then copy the code:
FROM node:24-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build#9 [3/6] COPY package.json package-lock.json ./ CACHED
#10 [4/6] RUN npm ci CACHED ← dependencies untouched
#11 [5/6] COPY . . DONE 0.0s
#12 [6/6] RUN npm run build DONE 1.4sIn BuildKit's output, #9 is just an ID for that line of output, and [3/5] means “step 3 of 5”. This demo app has one dependency, so npm install took only 1.6 s here. A real app with hundreds of packages commonly takes 30 s to several minutes, and you pay that on every code change with the first order.
The same idea in Go: copy go.mod and go.sum and download modules before copying the code.
FROM golang:1.27-alpine
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go build -o /bin/hello .(The tiny service from §04 has no dependencies and therefore no go.sum. Until it has one, copy just go.mod.)
Cache mounts: keep the compiler's cache between builds
Go and npm keep their own caches of compiled packages and downloads. Normally each build starts with an empty one. A BuildKit cache mount keeps that folder on the builder between builds, without saving it into the image:
RUN --mount=type=cache,target=/root/.cache/go-build \
go build -o /bin/hello .Rebuild of the Go service after editing main.go | Time (measured on this machine) |
|---|---|
| Without a cache mount | 10.7 s |
With --mount=type=cache | 1.5 s |
Nothing changed at all (every step CACHED) | 0.7 s |
For npm, the equivalent is RUN --mount=type=cache,target=/root/.npm npm ci.
.dockerignore: keep junk out of the build context
COPY . . copies the whole build context. Without a .dockerignore, that includes your local node_modules (built for your OS, possibly wrong for Linux), .git, test reports and .env files with secrets. Any change to them breaks the cache, and some of them leak into the image.
node_modules
dist
.git
.env
*.log
*.md
Dockerfile*It uses the same pattern style as .gitignore. Excluding *.md means a README edit no longer invalidates COPY . . (try the checkbox in the simulator above).
Multi-stage builds and small images
A Dockerfile can contain several FROM lines. Each starts a new stage with a fresh file system. COPY --from=<stage> copies files out of an earlier stage. Only the last stage becomes your image.
1FROM golang:1.27-alpine AS build2WORKDIR /src3COPY . .4RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /out/hello .56FROM gcr.io/distroless/static-debian12:nonroot7COPY --from=build /out/hello /hello8ENTRYPOINT ["/hello"]
build, starts from the full Go image: Alpine plus the whole Go toolchain. This is the workshop.Go: from 508 MB to 15 MB
Here is the full version, with the cache-friendly ordering from §06 and a few extra flags:
# syntax=docker/dockerfile:1
# ---- stage 1: build ----
FROM golang:1.27-alpine AS build
WORKDIR /src
COPY go.mod ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/hello .
# ---- stage 2: run ----
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /out/hello /hello
USER nonroot:nonroot
EXPOSE 8080
ENTRYPOINT ["/hello"]Going even smaller, FROM scratch starts from a completely empty image. It works for a static Go binary, but you then have to copy CA certificates and time-zone data yourself if the program needs them. Distroless is the usual sweet spot.
Node: ship production dependencies only
Node can't be compiled into a single file the same way, so the runtime has to ship. What you can drop is everything only needed to build: TypeScript, type definitions, test tools, npm's download cache.
# syntax=docker/dockerfile:1
# ---- stage 1: production dependencies only ----
FROM node:24-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
# ---- stage 2: all dependencies + compile TypeScript ----
FROM node:24-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
# ---- stage 3: what actually ships ----
FROM node:24-alpine
WORKDIR /app
ENV NODE_ENV=production
COPY --from=deps /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
COPY package.json ./
USER node
EXPOSE 3000
CMD ["node", "dist/server.js"]The deps and build stages don't depend on each other, so BuildKit runs them in parallel. npm ci --omit=dev skips devDependencies. ENV NODE_ENV=production sets an environment variable for the running app; Express and many libraries switch to faster, less chatty behaviour when they see it. USER node uses the unprivileged user the official Node images already include.
The results
“Size on disk” is unpacked; “download” is what a server pulls from the registry. The Node service can't shrink below its base image, because the Node runtime itself has to ship.
The Go image is 35× smaller on disk and downloads in a fraction of the time. That matters more than it seems: every new server, every autoscaled copy and every CI job pulls the image before it can start.
| Base image | What's inside | Good for |
|---|---|---|
scratch | Nothing at all | Static Go/Rust binaries that need no files |
distroless/static | Certificates, time zones, users. No shell | Static binaries. The usual choice for Go |
distroless/nodejs24 and similar | One language runtime. No shell | Node, Python or Java when you don't need a shell |
alpine (~4 MB download) | A tiny Linux with a shell and the apk package manager | When you need a shell or extra packages. Uses musl, not glibc |
debian:*-slim, node:24-slim | A trimmed Debian with glibc | Native modules that misbehave on musl |
node:24, golang:1.27 (no suffix) | A full Debian with compilers and tools | Build stages, not final images |
Ports, volumes and configuration
-p opens a door for network traffic, -v attaches storage that outlives it, and -e passes settings in.Ports: three things must line up
A network interface is one connection point the OS gives programs for sending and receiving traffic, each with its own IP address: eth0 is a (real or virtual) network card, and lo is the loopback interface, 127.0.0.1 alias localhost, which only reaches programs on the same machine.
The container has its own network namespace (§02), so it gets its own eth0 and its own lo, separate from the host's.
For a request from your laptop to reach the app, three things must be true:
- The app listens on the right port inside the container (3000 here).
- The port is published:
-p 8080:3000(port mapping) makes Docker forward host port 8080 to container port 3000. - The app listens on every interface, not just its own loopback. This is the bind address:
0.0.0.0, not127.0.0.1.
The third one catches everyone once. Inside a container, 127.0.0.1 means the container's own loopback. Docker's forwarded traffic arrives on the container's eth0, so a server listening only on lo never sees it. Follow a request:
Now break each rule in turn, and try keeping data too (the second half of this section):
Error messages are the real ones from curl 8.5 and BusyBox, captured on this machine.
$ docker run -d -p 3001:3000 node:24-alpine \
node -e 'require("http").createServer((q, r) => r.end("hi\n")).listen(3000, "127.0.0.1")'
$ curl localhost:3001
curl: (56) Recv failure: Connection reset by peer
# same, but .listen(3000) — no host means every interface
$ curl localhost:3001
hiIn Go, http.ListenAndServe(":8080", …) listens on every interface; "127.0.0.1:8080" would hit the same trap. Dev servers such as Vite default to localhost, so inside a container you need --host 0.0.0.0.
Containers talking to each other don't need -p at all. On a user-defined bridge network they reach each other directly by name: postgres://db:5432. Compose sets this up for you (§10).
Volumes: data that outlives the container
Everything a container writes goes into its writable layer, and docker rm deletes that layer (§05). For anything you want to keep, such as a database's files or uploads, attach storage from outside:
$ docker run --name scratchpad alpine:3.22 sh -c 'echo hello > /data.txt'
$ docker diff scratchpad # what this container changed vs its image
A /data.txt # A = added, in the writable layer
$ docker rm scratchpad
$ docker run --rm alpine:3.22 cat /data.txt
cat: can't open '/data.txt': No such file or directory
$ docker volume create notes
$ docker run --rm -v notes:/data alpine:3.22 sh -c 'echo hello > /data/notes.txt'
$ docker run --rm -v notes:/data alpine:3.22 cat /data/notes.txt
hello # a different container, same volume| Syntax | Lives in | Use for | |
|---|---|---|---|
| Volume | -v notes:/data | A folder Docker manages | Databases, uploads, anything to keep. Same speed on every OS |
| Bind mount | -v "$PWD":/app | A folder on your machine | Live-editing code during development; reading config files |
| tmpfs | --tmpfs /tmp | Memory only | Scratch files that must never touch disk |
Configuration: the same image everywhere
Build the image once and run that exact image in development, staging and production. What differs between them (database address, log level, feature switches) comes in from outside, usually as environment variables. That's why the service in §04 reads PORT with os.Getenv / process.env.
docker run -e PORT=9000 -e LOG_LEVEL=debug -p 9000:9000 hello:1.0 # one at a time
docker run --env-file ./staging.env hello:1.0 # many, from a file of NAME=value linesNever bake environment-specific settings or secrets into the image with ENV or COPY. Anyone who can pull the image can read them with docker image inspect (§05 showed that even deleted files survive). Inject secrets at run time from a secret manager or orchestrator; Secrets: delivery compares the options, including why files beat environment variables for the most sensitive ones.
Signals, PID 1 and graceful shutdown
docker stop sends SIGTERM to the container's PID 1, and only PID 1, waits 10 seconds, then sends SIGKILL. If the signal doesn't reach your app, or your app doesn't handle it, every stop cuts requests off and takes the full 10 seconds.A signal is a tiny message from the operating system to a process. Two matter here:
- SIGTERM means “please shut down”. A program can catch it and clean up first.
- SIGKILL means “die now”. It can't be caught or ignored.
Deploys stop containers all the time: every new version replaces the old containers. A graceful shutdown on SIGTERM (stop accepting, finish in-flight requests, exit) is what makes deploys invisible to users.
Why PID 1 is special
Inside the container, your app is usually PID 1 (§02). The kernel protects PID 1: signals it hasn't installed a handler for are ignored, instead of applying the default action (which for SIGTERM is “exit”). On a normal machine, that stops anyone from accidentally killing the init system. In a container, it means an app with no SIGTERM handler simply ignores docker stop.
Two things must both be true
A container stops quickly and cleanly only when:
- SIGTERM reaches your app. Docker signals PID 1 and nobody else. If PID 1 is a shell script or a wrapper, it has to pass the signal on, and a plain shell doesn't.
- Your app handles SIGTERM. Without a handler, an app that is PID 1 ignores the signal entirely (the rule above), and an app that isn't PID 1 dies instantly, dropping its in-flight requests.
Pick a start method and whether the app has a handler, then press docker stop. All ten combinations were measured:
| Start method | With handler | No handler |
|---|---|---|
CMD ["node", "server.js"] (exec form) | 0.17 s · 0 | 10.4 s · 137 |
ENTRYPOINT ["./start.sh"], script runs node server.js | 10.2 s · 137 | 10.3 s · 137 |
Same script, last line exec node server.js | 0.15 s · 0 | 10.2 s · 137 |
CMD ["npm", "start"] (npm 11) | 0.23 s · 0 | 0.71 s · 1 |
docker run --init, exec form | 0.19 s · 0 | 0.20 s · 143 |
Each cell is docker stop time · exit code, measured on this machine with Docker 29 and node:24-alpine. Exit codes: 0 means the app exited by itself after draining; 143 = 128 + 15 (killed by SIGTERM's default action); 137 = 128 + 9 (SIGKILL after the 10 s grace period); npm reports its child's death as 1. Only the 0 cells are graceful: anything else dropped whatever requests were in flight.
Go behaves differently with no handler: its runtime catches SIGTERM itself and exits right away even as PID 1, so you get a fast but ungraceful stop. With Go 1.27 that's exit code 143; the same program built with Go 1.24 exits with 2 (both measured here). Either way, in-flight requests are lost.
Rule 1: handle SIGTERM
Here is the service from §04 with a shutdown handler added. In Go that means replacing the one-line http.ListenAndServe (which can't be stopped) with an http.Server value you can call Shutdown on. Threads vs Processes §11 shows the same idea as a timeline.
package main
import (
"context"
"errors"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
)
func main() {
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
mux := http.NewServeMux() // our own router instead of the global one
mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
host, _ := os.Hostname()
fmt.Fprintf(w, "hello from %s\n", host)
})
// NEW: a server value we can shut down later (§04 used http.ListenAndServe, which can't be stopped)
srv := &http.Server{Addr: ":" + port, Handler: mux}
go func() { // serve in the background so main can wait for SIGTERM
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatal(err)
}
}()
log.Printf("listening on :%s", port)
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, os.Interrupt)
defer stop()
<-ctx.Done() // ← wait here until docker stop (SIGTERM) or Ctrl-C
log.Print("SIGTERM received, draining")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil { // stop accepting, finish in-flight requests
log.Printf("forced shutdown: %v", err)
}
log.Print("bye")
}$ docker run -d --name hello -p 8080:8080 hello:graceful
$ curl localhost:8080
hello from 4064ce6b2136
$ time docker stop hello # 0.16 s
$ docker logs hello
2026/10/30 23:03:21 listening on :8080
2026/10/30 23:03:22 SIGTERM received, draining
2026/10/30 23:03:22 bye
$ docker inspect -f '{{.State.ExitCode}}' hello
0Keep your deadline below the grace period: Docker's is 10 s (change it with docker stop -t 30 or --stop-timeout); Kubernetes' is 30 s by default.
Rule 2: make sure the signal reaches the app
The shell form CMD node server.js really runs /bin/sh -c "node server.js", which starts a shell as PID 1. Alpine's and Debian's shells usually replace themselves with the last command of a -c string, which is why the shell form often works by luck (it did in our tests). You're depending on a shell detail, though. The exec form removes the guesswork: the program itself is PID 1.
Entrypoint scripts are the classic trap. A shell running a script waits for its child and doesn't forward signals to it, so even an app with a perfect handler never hears SIGTERM. End the script with exec:
#!/bin/sh
set -e # stop the script if any command fails
node dist/migrate.js # one-off setup: runs, finishes, returns
exec node dist/server.js # ← exec: node REPLACES the shell and becomes PID 1Watch out for wrappers too. CMD ["npm", "start"] makes npm PID 1 with node as its child. npm 11 (measured here) does pass SIGTERM on, but it's an extra process that adds nothing, and older npm versions and other wrappers (yarn, nodemon) haven't always done so. Start the runtime directly: CMD ["node", "dist/server.js"].
Rule 3: if you start child processes, add an init
If your container runs several processes (a script that starts workers, an app that runs ffmpeg), PID 1 also has to forward signals to them and clean up their exit records (otherwise they linger as zombie processes). Ordinary apps don't do that. An init process does:
docker run --init my-image # Docker inserts tini as PID 1; your CMD becomes its childWithout --init, install tini in the image and use ENTRYPOINT ["/sbin/tini", "--", "node", "server.js"]. In Kubernetes there's no --init flag, so baking tini into the image is the portable choice.
Docker Compose for local development
docker compose up: images, network, volumes and start order included.A real service rarely runs alone. Typing three long docker run commands in the right order, with the right network and volumes, every morning, gets old fast. Docker Compose writes them down once in a YAML file.
services:
app:
build: . # build the Dockerfile in this folder
ports:
- "8080:8080" # host:container
environment:
PORT: "8080"
DATABASE_URL: postgres://app:devpass@db:5432/app # "db" = the service name below
depends_on:
db:
condition: service_healthy # wait until db's healthcheck passes
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: devpass # local dev only — never a real secret
POSTGRES_DB: app
volumes:
- dbdata:/var/lib/postgresql/data # named volume: survives "down" (Postgres 18+ images use /var/lib/postgresql)
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app"]
interval: 2s
retries: 15
volumes:
dbdata:Running it
$ docker compose up -d --build
Network demo_default Created
Container demo-db-1 Started
Container demo-db-1 Waiting
Container demo-db-1 Healthy # ← ~2 s later: pg_isready succeeded
Container demo-app-1 Started # ← only now does the app start
$ docker compose ps
SERVICE STATUS PORTS
app Up Less than a second 0.0.0.0:8080->8080/tcp
db Up 2 seconds (healthy) 5432/tcp # no host port: only reachable from the network
$ docker compose exec db psql -U app -c 'select 1 as ok'
ok
----
1The start order, as a timeline:
Compose put both containers on a private bridge network called demo_default and registered each service name there. From inside any container on it, the name db resolves to the database's address:
$ docker compose run --rm --entrypoint sh db -c 'getent hosts db app'
172.18.0.2 db
172.18.0.3 appEveryday commands
| Command | Does |
|---|---|
docker compose up -d --build | Build what changed, start everything in the background |
docker compose logs -f app | Follow one service's output |
docker compose exec db psql -U app | Open a shell or tool inside a running service |
docker compose down | Stop and remove containers and the network. Volumes are kept |
docker compose down -v | Also delete the volumes: a fresh, empty database next time |
docker compose watch | Rebuild or sync files into the container whenever you save (needs a develop.watch section) |
Image security and scanning
1. Don't run as root
By default the process in a container runs as root (user ID 0). It's a restricted root (namespaces, fewer powers), but if a kernel or runtime bug lets it escape, it's root on the host. A non-root user costs one line:
# official Node images ship a "node" user (uid 1000)
USER node# distroless :nonroot images ship a "nonroot" user (uid 65532)
USER nonroot:nonroot# scratch has no user list at all, so give numbers: user ID:group ID
USER 65532:65532$ docker run --rm hello:1.0 id # §04's first image
uid=0(root) gid=0(root) …
$ docker run --rm hello-node:multi id # §07's image with USER node
uid=1000(node) gid=1000(node)On a normal Linux host, only root may listen on ports below 1024 (like 80 and 443). Docker relaxes that rule inside containers (it sets ip_unprivileged_port_start to 0; measured here: node as uid 1000 bound port 80). Containers still conventionally listen on 8080 or 3000, because other runtimes and locked-down Kubernetes clusters may not relax it, and -p or a load balancer maps port 80/443 to them anyway.
2. Take away powers it doesn't need
Linux splits root's powers into separate switches called capabilities. Docker already turns off the dangerous ones and applies a seccomp filter that blocks rarely needed system calls. You can go further for a typical web service:
docker run --read-only --tmpfs /tmp \
--cap-drop ALL --security-opt no-new-privileges \
-p 8080:8080 hello:distroless--read-onlymakes the container's/read-only, so an attacker can't drop files into it.--tmpfs /tmpadds a scratch/tmpin memory for apps that need one.--cap-drop ALLremoves every capability.--security-opt no-new-privilegesstops setuid programs (likesudo) from regaining root.
The distroless Go service from §07 runs unchanged with all of these flags (measured). Kubernetes has the same switches in a pod's securityContext (runAsNonRoot, readOnlyRootFilesystem, capabilities.drop: ["ALL"], allowPrivilegeEscalation: false).
3. Pin what you build on
FROM node:24-alpine can mean different bytes tomorrow (§05). For builds you can reproduce, pin the digest and keep the tag as a readable comment:
FROM node:24-alpine@sha256:8a32c8cf649a49915a66e81e7a609223c708d0adabf288ee3f4077d765542f85When both are given, Docker uses the digest. Pinning means you no longer get security fixes automatically, so pair it with a bot (Dependabot or Renovate) that opens a pull request whenever the tag moves to a new digest. You get fixes on purpose, reviewed and tested, instead of by surprise.
4. Keep secrets out of every layer
§05 showed that a deleted file stays in its layer. Build-time secrets (a private registry token, an SSH key for private Git modules) go in through a BuildKit secret mount: the file exists only while that one RUN executes and is never written to a layer.
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc npm ci --omit=devdocker build --secret id=npmrc,src=$HOME/.npmrc -t app .ARG and ENV are not safe for secrets either: their values are recorded in the image's settings and history. Run-time secrets come from the platform when the container starts (Secrets: CI and Kubernetes).
5. Scan for known vulnerabilities
Every package in an image (Alpine's musl, OpenSSL, your express) might have a published CVE. Image scanning lists them all and checks each version against vulnerability databases. Run it in CI on every build, and again on a schedule, because new CVEs are published for images that haven't changed.
trivy image --severity HIGH,CRITICAL --exit-code 1 myapp:1.4.2 # Trivy (open source)
grype myapp:1.4.2 # Grype (open source)
docker scout cves myapp:1.4.2 # Docker Scout (Docker Desktop)myapp:1.4.2 (alpine 3.22)
Total: 1 (HIGH: 1, CRITICAL: 0)
┌────────────┬────────────────┬──────────┬───────────────────┬───────────────┐
│ Library │ Vulnerability │ Severity │ Installed Version │ Fixed Version │
├────────────┼────────────────┼──────────┼───────────────────┼───────────────┤
│ libcrypto3 │ CVE-XXXX-NNNNN │ HIGH │ 3.5.1-r0 │ 3.5.2-r0 │
└────────────┴────────────────┴──────────┴───────────────────┴───────────────┘--exit-code 1 makes the command fail when it finds something at those severities, which fails the CI job. Most findings are fixed by rebuilding on a newer base image, which is why small bases pay off: a distroless image has a handful of packages to keep patched; a full node:24 (Debian) image has hundreds.
Containers in production
Tag by commit, never deploy :latest
latest is just a tag Docker uses when you don't give one. It doesn't mean “newest”, and it moves. If production runs myapp:latest, you can't tell which code is running or roll back to “the previous one”. Tag every build with something that never moves, usually the Git commit:
docker build -t ghcr.io/acme/myapp:3f9c2ab -t ghcr.io/acme/myapp:1.4.2 .
docker push --all-tags ghcr.io/acme/myappThen build once and promote: the exact image (same digest) that passed tests in staging is the one deployed to production. Rebuilding for production would mean shipping something you never tested. The CI/CD topic builds this into a pipeline.
Build for every CPU your servers use
Images contain machine code for one CPU type. Apple Silicon laptops and AWS Graviton servers are arm64; most other servers are amd64. Run an image built for the wrong one and the container dies at once with an error like exec /hello: exec format error. Build a multi-platform image with Buildx:
docker buildx build --platform linux/amd64,linux/arm64 -t ghcr.io/acme/myapp:3f9c2ab --push .Go cross-compiles natively, so you can make the build stage run on the builder's own CPU and only tell Go which CPU to target. That's much faster than emulating the other one:
FROM --platform=$BUILDPLATFORM golang:1.27-alpine AS build
ARG TARGETOS TARGETARCH
# …
RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -o /out/hello .Don't depend on Docker Hub being nice to you
Docker Hub limits how many images anonymous users can pull per IP address. CI runners and Kubernetes nodes behind one shared address hit that rate limit quickly. While writing this page we hit it ourselves:
$ docker manifest inspect golang:1.27
toomanyrequests: You have reached your unauthenticated pull rate limit. https://www.docker.com/increase-rate-limitLog in for higher limits, use a pull-through cache or mirror (e.g. mirror.gcr.io, or your cloud's registry cache), or copy the base images you use into your own registry.
Make CI builds fast
A fresh CI machine starts with an empty build cache, so the layer ordering from §06 helps only if the cache is stored somewhere between runs:
docker buildx build \
--cache-from type=registry,ref=ghcr.io/acme/myapp:buildcache \
--cache-to type=registry,ref=ghcr.io/acme/myapp:buildcache,mode=max \
-t ghcr.io/acme/myapp:3f9c2ab --push .On GitHub Actions, type=gha stores the cache in Actions' own cache instead.
Run like a good citizen
- Log to stdout and stderr. Docker and Kubernetes collect them (
docker logs,kubectl logs) and ship them to your log system. Log files inside the container fill the writable layer and vanish with it. See Observability. - One main process per container. Scale, restart and limit things separately. A helper that must live next to the app (a proxy, a log shipper) goes in its own container in the same pod: a sidecar.
- Tell the runtime its budget. A memory limit is enforced by killing the process (§02). Set
GOMEMLIMITfor Go or--max-old-space-sizefor Node below the container's limit, and size worker pools to the CPU limit, not the host's core count. Threads vs Processes §11 has the details, including Go 1.25+'s automaticGOMAXPROCS. - Expose a health endpoint.
/healthzlets the platform check that the process is not just alive but able to serve. DockerfileHEALTHCHECKexists, but Kubernetes ignores it and uses its own probes; see Load balancing for health checks. - Start fast. Small images pull faster, and an app that's ready in one second can be scaled up or rescheduled in one second. Slow startup turns every node failure into a long outage.
Pitfalls gallery
COPY . . before installing dependencies
Every code change re-installs every dependency, turning a 2-second rebuild into minutes (§06).
COPY . .
RUN npm ciCOPY package.json package-lock.json ./
RUN npm ci
COPY . .No .dockerignore
Your local node_modules (built for macOS, perhaps) gets copied over the freshly installed Linux one, .git makes the build context hundreds of MB, and .env files with real secrets end up in the image. Add a .dockerignore before your first build (§06).
Listening on localhost inside the container
app.listen(3000, "127.0.0.1") or a dev server's default localhost accepts only connections from inside the container's own network namespace. With -p published, curl gets Connection reset by peer. Listen on 0.0.0.0 (§08).
A database with no volume
The data lives in the writable layer and disappears with docker rm, docker compose down followed by a rebuild, or the next deploy. Always mount a named volume at the database's data folder (§08, §10).
Every stop takes exactly 10 seconds
That's Docker's grace period running out: only SIGKILL ends the container (exit code 137). Either the signal never reaches your app (PID 1 is a shell script or a wrapper that doesn't forward it), or the app is PID 1 with no SIGTERM handler. You need both: get the signal to the app (exec form, exec at the end of scripts, or --init) and handle it in the app (§09). The fix below assumes server.js already has a handler; without one, exec alone still ends in 137.
#!/bin/sh
node migrate.js
node server.js#!/bin/sh
node migrate.js
exec node server.jsDeleting a secret in a later step
COPY .npmrc followed by RUN rm .npmrc leaves the token in the COPY layer for anyone who pulls the image (§05). Use RUN --mount=type=secret (§11), and rotate any secret that was ever pushed this way.
Deploying :latest
You can't tell what's running, two servers may pull different images under the same name, and “roll back to the previous version” has no meaning. Tag with the commit SHA and deploy that tag, or better, its digest (§12).
It works on my Mac, crashes on the server
exec format error at start-up means the image was built for a different CPU (arm64 on Apple Silicon vs amd64 servers). Build with --platform, or build multi-platform images in CI (§12).
Shipping the compiler
A single-stage Go image is ~500 MB of toolchain around an 8 MB binary; a single-stage Node image ships TypeScript and test tools. Bigger means slower pulls, slower scale-up and more packages for scanners to flag. Use a multi-stage build (§07).
Native modules on Alpine
Alpine uses musl instead of glibc as its C library. Most pure-JS and pure-Go code doesn't care, but npm packages with prebuilt native binaries (image processing, some database drivers) may fail to install or crash on Alpine. If that happens, switch the base to a Debian -slim variant rather than fighting it (§07).
Treating the container like a server
docker exec in, apt-get install a fix, edit a config file… and it's all gone on the next deploy, or never made it to the other replicas. Containers are disposable: every change goes into the Dockerfile or the configuration, then a new image.
Check yourself
Predict the answer before you click. Every one is covered above: Q1 in §06, Q2 in §08, Q3 in §09, Q4 in §08, Q5 in §05 and §11, Q6 in §02.
You build this image, then edit src/app.js (and nothing else) and build again. Which steps actually run on the second build?
FROM node:24-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
CMD ["node", "src/app.js"]You run docker run --name db postgres:16-alpine (no -v), create some tables, then docker rm -f db and start it again with the same command. What do you find?
Every docker stop of this container takes exactly 10 seconds and ends with exit code 137. server.js has a correct SIGTERM handler. Why?
FROM node:24-alpine
WORKDIR /app
COPY . .
# start.sh runs migrations, then: node server.js
ENTRYPOINT ["./start.sh"]The container runs this server and was started with -p 8080:3000. What does curl localhost:8080 on your laptop get?
import { createServer } from "node:http";
createServer((req, res) => res.end("hi"))
.listen(3000, "127.0.0.1");Is the token in .npmrc inside the pushed image?
FROM alpine:3.22
COPY .npmrc /root/.npmrc
RUN npm-install-stand-in && rm /root/.npmrcYour container keeps restarting. docker inspect shows OOMKilled: true, exit code 137. What happened?
Cheat sheet
# syntax=docker/dockerfile:1
FROM golang:1.27-alpine AS build
WORKDIR /src
# no dependencies yet (so no go.sum)? copy just go.mod
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN --mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/app .
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /out/app /app
USER nonroot:nonroot
ENTRYPOINT ["/app"]| I want to… | Command |
|---|---|
| Build and name an image | docker build -t app:3f9c2ab . |
| Run in the background with a port | docker run -d --name app -p 8080:8080 app:3f9c2ab |
| See logs / get a shell inside | docker logs -f app · docker exec -it app sh |
| See layers and sizes | docker history app:3f9c2ab · docker image ls |
| Keep data | -v data:/var/lib/postgresql/data |
| Pass config | -e KEY=value · --env-file prod.env |
| Limit resources | -m 256m --cpus 1 |
| Check why it died | docker inspect -f '{{.State.ExitCode}} {{.State.OOMKilled}}' app |
| Start the whole local stack | docker compose up -d --build · docker compose down (-v also deletes volumes) |
| Build for Intel and ARM | docker buildx build --platform linux/amd64,linux/arm64 --push -t … . |
| Clean up | docker container prune · docker image prune · docker system df |