Skip to content
Software Atlas

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.

⏱ ~45 min read15 sections10 interactive widgetsShell · Dockerfile · YAML · Go · JS
FoundationsPractitionerStaff-level
Foundations§01

The 30-second version

In one sentenceA container image packs your program with everything it needs to run. A container is one running copy of that image: an ordinary process that the operating system fences off from everything else on the machine.

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:

terminal
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 containers

One image can start any number of containers, and each one gets its own private scratch space. Try it:

One image, many containers
An image is the frozen template. Each docker run makes a new, separate running copy.
Image · hello:1.0read-only
layer 1 · Alpine Linux files
layer 2 · Node.js runtime
layer 3 · your node_modules
layer 4 · your code (server.js)
Running containers0 running
None yet. Press “docker run” a few times.
Start two containers, save a file in one, then delete it. Watch what the other container and the image see.

What this topic covers

  1. What a container really is (§02–§03): a Linux process with walls and a budget, and how that differs from a virtual machine (VM).
  2. 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.
  3. Running them well (§08–§10): ports, volumes, configuration, shutting down cleanly, and Docker Compose for local development.
  4. Production (§11–§13): security, scanning, and the pitfalls that bite at scale.
Foundations§02

A container is just a process

In one sentenceThere is no “container” object inside Linux. A container is an ordinary process started with three extras: walls (what it can see), a budget (what it can use) and its own files.

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

1

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.

2

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.

3

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:

Build a container out of walls
Same process — node server.js — every time. Switch each wall on or off and look at what the process can see and use.
All walls up: this is what Docker gives you. It's still one ordinary process on the host.
$ 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)
memory limit: 64 MB
press the button…

See it for yourself

Start a container that just sleeps, then look at it from outside and from inside:

terminal (measured on this machine)
$ 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 ps

Same 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:

terminal (measured on this machine)
$ 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=137

tail /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.

Foundations§03

Containers vs virtual machines

In one sentenceA virtual machine pretends to be a whole computer and boots its own operating system. A container is a process on an operating system that's already running. Every container on a machine shares one kernel.
What sits under your app
Red boxes are kernels. Count them.
3 virtual machines
app A
libraries
guest OS + kernel
boots in ~10–60 s
app B
libraries
guest OS + kernel
boots in ~10–60 s
app C
libraries
guest OS + kernel
boots in ~10–60 s
hypervisor (KVM, Hyper-V…)
host OS + kernel
hardware

Each VM carries a whole operating system: GBs of disk, hundreds of MB of RAM before your app starts.

3 containers
app A
libraries
from the image
app B
libraries
from the image
app C
libraries
from the image
container runtime (Docker / containerd)
host OS + kernel
ONE kernel, shared by every container
hardware

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 machineContainer
Start timeTens 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)
SizeGBs: a whole OS installMBs: often 5–250 MB (§07)
Memory overheadHundreds of MB for the guest OS before your appLittle beyond your process itself
KernelIts own, any OS (run Windows on Linux)The host's. A Linux container needs a Linux kernel
IsolationStrong: separate kernels, enforced by the CPU's virtualization hardwareWeaker: 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).

terminal (measured on this machine)
$ 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 one
Foundations§04

Your first Dockerfile

In one sentenceA Dockerfile is a recipe: start from an existing image, copy your code in, run the build commands, and say which program to start.

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

main.go
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

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

terminal (measured on this machine)
$ 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 hello

It works. It's also 508 MB.

docker image ls (measured on this machine)
IMAGE          DISK USAGE   CONTENT SIZE
hello:1.0         508MB        102MB       # Go version
hello-node:1.0    244MB       62.5MB       # Node version

Disk 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?

Practitioner§05

Images and layers

In one sentenceAn image is a stack of layers: each one a tar archive (a bundle of files, like a .zip) of the files one build step changed. A running container gets one extra, writable layer on top, and that layer is thrown away when the container is removed.

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:

From Dockerfile to running container
Watch the layers pile up, then see what happens when the container writes, edits and deletes.
Code
1FROM node:24-alpine
2WORKDIR /app
3COPY package.json package-lock.json ./
4RUN npm ci --omit=dev
5COPY . .
6CMD ["node", "server.js"]
image layers (read-only)
① Alpine files /bin /etc /lib
② Node.js /usr/local/bin/node
container layer (writable)
no container yet
what the process sees
/bin, /etc, /lib…
node
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.
1 / 11

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:

  1. Reading a path returns the copy from the top-most layer that has it.
  2. 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.
  3. 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:

terminal (measured on this machine; ENV lines trimmed)
$ 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:

layer IDs (measured on this machine)
$ 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, forever

The 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”:

Dockerfile (don't do this)
FROM alpine:3.22
COPY .npmrc /root/.npmrc
RUN echo "pretend we npm install here" && rm /root/.npmrc

The running container can't see the file. But the image still has it:

terminal (measured on this machine)
$ 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_SuperSecret123

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

Practitioner§06

The build cache and COPY order

In one sentenceDocker reuses a saved step only if that step and every step before it are unchanged. So put the things that change rarely (dependencies) first and the things that change constantly (your code) last.

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:

Build cache simulator
Build once to fill the cache, then make a change and build again. Timings are illustrative for a mid-size Node app.
Dockerfilenot built yet
FROM node:24-alpine·
WORKDIR /app·
COPY . .·
RUN npm ci·
RUN npm run build·
CMD ["node", "dist/server.js"]·
What changed since the last build?
Press “docker build”. The first build always runs every step: the cache is empty.

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:

docker build --progress=plain (measured on this machine, trimmed)
#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.4s

Copy only the dependency manifests first, install, then copy the code:

Dockerfile
FROM node:24-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
same edit, rebuilt (measured on this machine, trimmed)
#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.4s

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

Dockerfile
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.goTime (measured on this machine)
Without a cache mount10.7 s
With --mount=type=cache1.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.

.dockerignore
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).

Practitioner§07

Multi-stage builds and small images

In one sentenceA multi-stage build uses one image to build your program and a different, much smaller image to run it. Only the finished output is copied across; compilers and dev tools never ship.

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.

What a multi-stage build keeps
Two stages, one COPY between them. Watch what ends up in the image.
Code
1FROM golang:1.27-alpine AS build
2WORKDIR /src
3COPY . .
4RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /out/hello .
5
6FROM gcr.io/distroless/static-debian12:nonroot
7COPY --from=build /out/hello /hello
8ENTRYPOINT ["/hello"]
stage 1: build (workshop)
Alpine Linux 9 MB
Go toolchain 296 MB
stage 2: the image you ship
not started yet
size
build stage so far: ~305 MB
Stage 1, named build, starts from the full Go image: Alpine plus the whole Go toolchain. This is the workshop.
1 / 5

Go: from 508 MB to 15 MB

Here is the full version, with the cache-friendly ordering from §06 and a few extra flags:

Dockerfile
# 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.

Dockerfile
# 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

Same app, different images
Measured on this machine with docker image ls (Docker 29). Go: the small HTTP service from §04. Node: an Express + TypeScript service.
Go · golang:1.27-alpine, single stage
508 MB
Go · multi-stage → distroless/static
14.6 MB
Go · multi-stage → scratch
8.41 MB
Node · node:24-alpine, single stage (dev deps + build)
299 MB
Node · multi-stage, production deps only
249 MB
Node · …of which the node:24-alpine base
244 MB

“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 imageWhat's insideGood for
scratchNothing at allStatic Go/Rust binaries that need no files
distroless/staticCertificates, time zones, users. No shellStatic binaries. The usual choice for Go
distroless/nodejs24 and similarOne language runtime. No shellNode, Python or Java when you don't need a shell
alpine (~4 MB download)A tiny Linux with a shell and the apk package managerWhen you need a shell or extra packages. Uses musl, not glibc
debian:*-slim, node:24-slimA trimmed Debian with glibcNative modules that misbehave on musl
node:24, golang:1.27 (no suffix)A full Debian with compilers and toolsBuild stages, not final images
Practitioner§08

Ports, volumes and configuration

In one sentenceA container is sealed by default: its ports are private and its files vanish when it's removed. -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:

  1. The app listens on the right port inside the container (3000 here).
  2. The port is published: -p 8080:3000 (port mapping) makes Docker forward host port 8080 to container port 3000.
  3. The app listens on every interface, not just its own loopback. This is the bind address: 0.0.0.0, not 127.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:

Where does the request go?
Follow curl's request from your laptop into the container. Change the two settings and send it again.
your laptop
curl localhost:8080
host port 8080
Docker's forwarding rule → container port 3000
container eth0
172.17.0.2:3000 — the container's own network interface
the app
listening on lo (127.0.0.1) only
lo 127.0.0.1 ← app is here
Press curl to send a request.

Now break each rule in turn, and try keeping data too (the second half of this section):

Ports and volumes playground
A small notes app listens on port 3000 inside its container. Change the settings, then try to reach it and keep its data.
app listens on
publish a port
/data storage
Try: run → curl. Then switch to 127.0.0.1 or “no -p” and curl again. Then write a note, docker rm, run, read.

Error messages are the real ones from curl 8.5 and BusyBox, captured on this machine.

the 127.0.0.1 trap (measured 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
hi

In 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:

terminal (measured on this machine)
$ 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
SyntaxLives inUse for
Volume-v notes:/dataA folder Docker managesDatabases, uploads, anything to keep. Same speed on every OS
Bind mount-v "$PWD":/appA folder on your machineLive-editing code during development; reading config files
tmpfs--tmpfs /tmpMemory onlyScratch 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 lines

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

Practitioner§09

Signals, PID 1 and graceful shutdown

In one sentencedocker 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:

  1. 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.
  2. 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:

Who gets the SIGTERM?
Pick how the container starts its app and whether the app handles SIGTERM, then press docker stop. Every result is measured on this machine (Docker's default grace period: 10 s).
inside the container
ENTRYPOINT ["./start.sh"]
start.sh runs migrations, then `node server.js`; server.js handles SIGTERM
PID 1 · /bin/sh ./start.sh
└─
PID 8 · node server.js
Press docker stop.
Start methodWith handlerNo handler
CMD ["node", "server.js"] (exec form)0.17 s · 010.4 s · 137
ENTRYPOINT ["./start.sh"], script runs node server.js10.2 s · 13710.3 s · 137
Same script, last line exec node server.js0.15 s · 010.2 s · 137
CMD ["npm", "start"] (npm 11)0.23 s · 00.71 s · 1
docker run --init, exec form0.19 s · 00.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.

main.go
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")
}
terminal (measured on this machine)
$ 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
0

Keep 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:

start.sh
#!/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 1

Watch 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 child

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

Practitioner§10

Docker Compose for local development

In one sentenceDocker Compose describes a whole local stack (your app, its database, a cache) in one YAML file, and starts it with 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.

compose.yaml
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

terminal (measured on this machine; trimmed, project renamed demo)
$ 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
----
  1

The start order, as a timeline:

docker compose up with a healthy-condition dependency
network
create
db
starting · healthcheck failing
healthy
app
running (DB is ready)
The app waits for the database's healthcheck, not just for its container to exist.

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:

name lookup inside the Compose network (measured on this machine)
$ docker compose run --rm --entrypoint sh db -c 'getent hosts db app'
172.18.0.2        db
172.18.0.3        app

Everyday commands

CommandDoes
docker compose up -d --buildBuild what changed, start everything in the background
docker compose logs -f appFollow one service's output
docker compose exec db psql -U appOpen a shell or tool inside a running service
docker compose downStop and remove containers and the network. Volumes are kept
docker compose down -vAlso delete the volumes: a fresh, empty database next time
docker compose watchRebuild or sync files into the container whenever you save (needs a develop.watch section)
Staff-level§11

Image security and scanning

In one sentenceA container wall is thinner than a VM's (§03), so assume an attacker will get code running inside one. Give them as little as possible: no root, no extra powers, no shell, no secrets in the image, no known-vulnerable packages.

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
who runs the process? (measured on this machine)
$ 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-only makes the container's / read-only, so an attacker can't drop files into it. --tmpfs /tmp adds a scratch /tmp in memory for apps that need one.
  • --cap-drop ALL removes every capability.
  • --security-opt no-new-privileges stops setuid programs (like sudo) 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:8a32c8cf649a49915a66e81e7a609223c708d0adabf288ee3f4077d765542f85

When 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=dev
docker 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)
illustrative output (shape of a Trivy report, not a real scan)
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.

Staff-level§12

Containers in production

In one sentenceIn production the image is the unit of deployment: built once, named so it can never change, pulled by many machines of different kinds, and run under limits it must know about.

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/myapp

Then 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:

terminal (real, while writing this topic)
$ docker manifest inspect golang:1.27
toomanyrequests: You have reached your unauthenticated pull rate limit. https://www.docker.com/increase-rate-limit

Log 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 GOMEMLIMIT for Go or --max-old-space-size for 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 automatic GOMAXPROCS.
  • Expose a health endpoint. /healthz lets the platform check that the process is not just alive but able to serve. Dockerfile HEALTHCHECK exists, 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.
Practitioner§13

Pitfalls gallery

#1

COPY . . before installing dependencies

Every code change re-installs every dependency, turning a 2-second rebuild into minutes (§06).

✕ Don't
COPY . .
RUN npm ci
✓ Do
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
#2

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

#3

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

#4

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

#5

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.

✕ Don't
#!/bin/sh
node migrate.js
node server.js
✓ Do
#!/bin/sh
node migrate.js
exec node server.js
#6

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

#7

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

#8

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

#9

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

#10

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

#11

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.

Practitioner§14

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.

QUESTION 1 / 6
Which steps re-run?

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"]
QUESTION 2 / 6
Where did my data go?

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?

QUESTION 3 / 6
Ten seconds, every deploy

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"]
QUESTION 4 / 6
Reachable or not?

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");
QUESTION 5 / 6
The deleted secret

Is the token in .npmrc inside the pushed image?

FROM alpine:3.22
COPY .npmrc /root/.npmrc
RUN npm-install-stand-in && rm /root/.npmrc
QUESTION 6 / 6
Exit code detective

Your container keeps restarting. docker inspect shows OOMKilled: true, exit code 137. What happened?

Answered 0 / 6
Foundations§15

Cheat sheet

🧱
A container is a processNamespaces (what it sees) + cgroups (what it uses) + the image's files. One shared kernel.
🥞
Images are layersOne read-only layer per file-changing step, plus a writable layer per container. Deleting a file only hides it.
⚡
Order for the cacheRarely changing first: base, dependency manifests, install. Code last. Add .dockerignore.
🏗️
Multi-stageBuild with the toolchain, ship only the output. Go → distroless/static; Node → production deps only.
🚪
Ports, volumes, config-p host:container, listen on 0.0.0.0, named volumes for data, settings from env vars.
🛑
PID 1 and SIGTERMExec form, exec in scripts, handle SIGTERM, --init for child processes. 137 = SIGKILL, 143 = SIGTERM.
🔒
Least privilegeUSER non-root, --cap-drop ALL, read-only root, no secrets in layers, scan in CI.
🏷️
Immutable namesTag by commit SHA, pin bases by digest, build once and promote. Never deploy :latest.
Dockerfile template (Go service)
# 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 imagedocker build -t app:3f9c2ab .
Run in the background with a portdocker run -d --name app -p 8080:8080 app:3f9c2ab
See logs / get a shell insidedocker logs -f app · docker exec -it app sh
See layers and sizesdocker 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 dieddocker inspect -f '{{.State.ExitCode}} {{.State.OOMKilled}}' app
Start the whole local stackdocker compose up -d --build · docker compose down (-v also deletes volumes)
Build for Intel and ARMdocker buildx build --platform linux/amd64,linux/arm64 --push -t … .
Clean updocker container prune · docker image prune · docker system df
Finished reading?Mark it complete to track your progress on a roadmap. Saved in this browser only.
Up next in DevOps & CloudCloud Basics
Read →