04 · Containerization Basics (Docker) as a Deployment Pattern¶
Everything up to this point deployed an app directly onto a server's OS — install a runtime, copy code, run it under systemd. Containers package the app and its runtime/dependencies into one portable image, so "works on my machine" becomes "works in this exact environment, everywhere." This module treats Docker as a deployment pattern, not a general Docker course.
Why containers, from an ops angle¶
- Reproducible environment — the container image pins the OS packages, language runtime version, and dependencies. No more "prod has a different libssl than staging."
- Immutable artifact — you build an image once, tag it, and run the same bytes in staging and production (this is the "environment-based deployment" idea from Level 2, taken further — the artifact itself is now identical, not just the config pointed at it).
- Density and isolation — multiple containers share one kernel but get their own filesystem/process namespace, so you can pack more workloads per host than full VMs, with lighter-weight isolation than "just run everything directly on the host."
- Portability — the same image runs on a laptop, a bare-metal server, or any cloud, as long as a container runtime is present.
Trade-off to be honest about: containers add a layer (the runtime, image registry, orchestration) — for a single app on a single server, plain systemd (Level 1, module 4) is often simpler and has less to operate. Reach for containers when you need portability across environments or multiple apps/services per host with clean isolation.
Anatomy of a Dockerfile¶
FROM node:20-slim
WORKDIR /app
# copy dependency manifests first so this layer caches independently of source changes
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY . .
# run as non-root — never run production containers as root
RUN useradd --uid 1000 --create-home appuser
USER appuser
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=3s CMD wget -qO- http://localhost:3000/healthz || exit 1
CMD ["node", "server.js"]
Key ops-relevant choices baked into this file:
- Layer ordering — dependency install before source copy means
changing application code doesn't invalidate (and re-run) the
npm cilayer, keeping rebuilds fast. - Slim base image — smaller attack surface and faster pulls than a full
node:20image with a whole Debian userland. - Non-root user — if the app is compromised, the attacker doesn't automatically have root inside the container (and, depending on configuration, on the host).
HEALTHCHECK— the same liveness/readiness idea from module 01, expressed at the container level sodocker psand orchestrators can see container health directly.
Building, tagging, and running¶
docker build -t registry.example.com/app:1.4.2 .
docker push registry.example.com/app:1.4.2
docker run -d \
--name app \
--restart unless-stopped \
-p 3000:3000 \
-e DATABASE_URL="postgres://app:secret@db.internal:5432/app" \
--memory=512m --cpus=1 \
registry.example.com/app:1.4.2
Notes that matter operationally:
- Tag by immutable version, never just
latest—latestmakes rollbacks and "what's actually running in prod" impossible to answer reliably. Use a semantic version or the git commit SHA as the tag. --restart unless-stoppedgives you the systemd-equivalent of auto-restart on crash/reboot, without systemd itself.--memory/--cpusset resource limits — without them, one runaway container can starve every other container on the host, the container equivalent of one process filling up a whole VM.
docker-compose for multi-container apps¶
# docker-compose.yml
services:
app:
image: registry.example.com/app:1.4.2
restart: unless-stopped
ports:
- "3000:3000"
environment:
DATABASE_URL: postgres://app:secret@db:5432/app
depends_on:
db:
condition: service_healthy
deploy:
resources:
limits:
memory: 512M
cpus: "1.0"
db:
image: postgres:16
restart: unless-stopped
environment:
POSTGRES_PASSWORD: secret
POSTGRES_DB: app
volumes:
- db_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 3s
retries: 5
volumes:
db_data:
depends_on: condition: service_healthy ties back directly to the
HEALTHCHECK mechanism — app won't start until db's healthcheck passes,
avoiding the classic "app crash-loops because the database wasn't ready
yet" race.
Persisting data and avoiding the classic mistake¶
Containers are meant to be disposable — anything written inside the
container's own filesystem is lost when the container is removed. Data that
must survive belongs in a named volume (as db_data above) or a
bind-mounted host path, never in the container's writable layer.
# WRONG: data lives only inside the container, gone on `docker rm`
docker run -d postgres:16
# RIGHT: data lives in a named volume, survives container replacement
docker run -d -v db_data:/var/lib/postgresql/data postgres:16
This is the container-world version of "don't put your database on the same disk you'll wipe during a redeploy" — the container itself is the disposable unit; durable state has to be explicitly placed outside it.
Logs and systemd-style operational habits¶
docker logs -f --tail 100 app # equivalent to journalctl -u app -f
docker inspect app --format '{{.State.Health.Status}}' # healthy / unhealthy / starting
docker stats --no-stream # equivalent to a quick top for containers
In production, container logs are typically shipped off-host (Level 2's centralized logging module) rather than read locally — a container that's rescheduled onto a different host takes its logs with it unless they're already centralized.
How It Actually Works¶
Why layer ordering actually changes build time, mechanically. Docker
builds an image as a stack of read-only layers, one per instruction, and
caches each layer keyed by a hash of the instruction plus its build
context (for COPY, that includes a hash of the copied files' contents).
On a rebuild, Docker walks the instruction list and reuses every cached
layer up to the first one whose inputs changed — every layer after that
point is rebuilt regardless of whether it would have produced identical
output. COPY package.json package-lock.json ./ followed by RUN npm ci
before COPY . . means an application source change only invalidates the
COPY . . layer onward — npm ci reruns only when the manifests
themselves change, not on every source edit.
Why containers isolate without a hypervisor. A container is not a
lightweight VM — it's an ordinary Linux process that the kernel makes
appear isolated using two independent mechanisms: namespaces (PID, net,
mount, UTS, IPC — each gives the process its own view of that resource
category, so docker run's process sees itself as PID 1 in its own PID
namespace even though it's just another PID on the host) and cgroups
(the same systemd mechanism from Level 1 module 4, used here to enforce
--memory/--cpus limits rather than just track process trees). Because
there's one shared kernel underneath all of this — unlike a VM's separate
kernel per guest — a container escape or kernel exploit has a much larger
blast radius than a VM escape, which is the concrete reason USER appuser
and avoiding --privileged matter operationally, not just as a checklist
item.
Why the writable layer is disposable but a bind mount/volume isn't.
Every running container gets one additional writable layer on top of its
read-only image layers (a union filesystem, historically overlay2 on
Linux), and docker rm discards that writable layer along with the
container. A named volume is mounted through that layer at a specific
path, backed by storage Docker manages outside any image layer — so
docker rm on a container with the volume attached leaves the volume's
data untouched on the host, and a new container mounting the same volume
picks up exactly where the old one left off. This is the mechanical reason
"never store durable data in the container's own filesystem" isn't a
style preference — that data is architecturally scoped to the container's
lifetime, full stop.
Exercise¶
- Write a
Dockerfilefor a small app (any language) that follows the pattern above: cached dependency layer, non-root user,HEALTHCHECK. - Write a
docker-compose.ymlrunning the app plus a database it depends on, usingdepends_on: condition: service_healthyand a named volume for the database's data directory. docker compose up -d, then kill the database container (docker kill <db-container>) and observe: does the app container crash-loop, retry, or hang? Decide whether that's the behavior you'd want in production and what you'd change (e.g. retry logic in the app, orrestart: unless-stoppedon the db) if not.- Tag and rebuild the image with a code change, and demonstrate a rollback
by re-running the previous version's tag — confirm the previous tag
still exists and works, which is why "never overwrite
latest" matters.