devopsdiary
Next chapter

diary / chapters / 06

chapter 06 · ~55 min · docker lab

Containers and Docker

A container is your application plus everything it needs to run, sealed into one package that behaves identically on your laptop, in CI and in production. This is the chapter where "works on my machine" stops being a phrase anyone has to say.

TOPIC 01The problem containers solve

Your app needs Node 20, a specific image library, three environment variables and a certain locale. Your laptop has all of that. The server has Node 18 and a different libc. The new joiner has a Mac with an ARM chip. Each environment is subtly different, and the differences surface as bugs that only appear somewhere you cannot debug.

The old fixes were a wiki page called "setting up your dev environment" (immediately out of date), or a configuration management tool trying to converge every machine to the same state (better, still fragile). Containers take a different approach: stop trying to make environments match, and ship the environment with the app.

real life

A tiffin box. Instead of hoping the office kitchen has the right rice, the right spices and a working stove, you pack the finished meal and carry it. Wherever you open it, it is the same meal. A container is a tiffin box for software: the app, its runtime, its libraries and its default settings, sealed together.

TOPIC 02Containers vs virtual machines

This is the most common interview opener in the field, so be precise. A virtual machine virtualises hardware: each VM boots its own kernel and a full operating system. A container virtualises the operating system: it shares the host's kernel and is just isolated processes — isolated using Linux features called namespaces (what a process can see) and cgroups (how much it can use).

the same three apps, two ways
   VIRTUAL MACHINES                    CONTAINERS
 ┌─────┬─────┬─────┐               ┌─────┬─────┬─────┐
 │ app │ app │ app │               │ app │ app │ app │
 ├─────┼─────┼─────┤               ├─────┴─────┴─────┤
 │ libs│ libs│ libs│               │      libs       │  (per image, small)
 ├─────┼─────┼─────┤               ├─────────────────┤
 │ OS  │ OS  │ OS  │  ← GBs each   │ container engine│
 ├─────┴─────┴─────┤               ├─────────────────┤
 │    hypervisor   │               │   host kernel   │  ← ONE kernel, shared
 ├─────────────────┤               ├─────────────────┤
 │    hardware     │               │    hardware     │
 └─────────────────┘               └─────────────────┘
   boot: 30s–2min                    start: < 1 second
   image: gigabytes                  image: tens of MB
   isolation: strongest              isolation: strong, but shared kernel

The trade-off in one line: containers are dramatically lighter and faster, VMs give a harder security boundary. Which is why you run many containers per VM in the cloud — and why untrusted, arbitrary third-party code often still gets a VM of its own.

practise this one

The arcade has a game just for this distinction — Container or VM?. Sixteen statements, two bins. Do it once now and the answer will be automatic in an interview.

TOPIC 03Images, layers and tags

An image is a read-only template. A container is a running instance of it. One image, many containers.

real life

The image is a recipe card; the container is tonight's dish. Every line you add to the card is a layer, stacked in order. Change the last line and only the last step is re-cooked. Change the first line and everything after it must be made again. That single fact is the whole of build-cache optimisation.

Layers are cached and shared: ten images built on node:20-alpine store that base once on disk. When a container runs, Docker adds a thin writable layer on top — and that layer is deleted with the container, which is why anything you want to keep must live in a volume.

Tags are names for an image: notes-api:v2, notes-api:a91f3c, postgres:16-alpine. A tag is a movable label, not an identity — :latest in particular means "whatever was pushed most recently", which is why it has no place in a production deployment. Tag with the commit SHA and you always know exactly which line of code is running.

TOPIC 04Running containers

terminal · the commands you'll use daily
docker run hello-world                  # pull + run, prove it works
docker run -it --rm ubuntu bash         # interactive shell, deleted on exit
docker run -d -p 8080:80 --name web nginx
#          │   │            └ a name you can type instead of a hash
#          │   └ HOST port : CONTAINER port  (host side comes first)
#          └ detached: run in the background

docker ps                               # running containers
docker ps -a                            # including stopped ones
docker logs -f web                      # follow its output
docker exec -it web sh                  # get a shell INSIDE a running container
docker stop web && docker rm web        # SIGTERM, then remove
docker images                           # what is on disk
docker stats                            # live CPU/memory per container
docker system df                        # where has my disk gone?
docker system prune -a                  # reclaim it (careful: removes unused images)
the port-mapping trap

-p 8080:80 is host:container, in that order. And inside the container your app must listen on 0.0.0.0, not 127.0.0.1 — a container's localhost is its own, so binding to loopback makes it unreachable from outside no matter how you map ports (chapter 03, topic 02).

TOPIC 05Writing a Dockerfile

Dockerfile — a Node app, done properly
FROM node:20-alpine                 # small, pinned base. never :latest

# a non-root user: if the app is compromised, it is not root
RUN addgroup -S app && adduser -S app -G app

WORKDIR /app

# dependencies FIRST — this is the cache trick, see next topic
COPY package*.json ./
RUN npm ci --omit=dev

# then the source, which changes on every commit
COPY --chown=app:app . .

ENV NODE_ENV=production
ENV PORT=3000
EXPOSE 3000                         # documentation; does not publish anything

USER app                            # drop privileges before running

HEALTHCHECK --interval=30s --timeout=3s --start-period=10s \
  CMD wget -qO- http://localhost:3000/health || exit 1

CMD ["node", "server.js"]           # exec form: your app becomes PID 1 and
                                    # receives SIGTERM directly (chapter 02)
InstructionWhat it doesWatch out for
FROMThe base imagePin the version; prefer -alpine or -slim
RUNRuns a command at build time, creating a layerChain related commands with && to avoid layer sprawl
COPYCopies files in from the build contextOrder matters for caching; use .dockerignore
WORKDIRSets the directory for later instructionsUse it instead of RUN cd, which does not persist
ENVEnvironment variable baked into the imageNever put secrets here — they are visible in the image
CMDDefault command, overridable at run timeUse the JSON/exec form so signals reach your process
ENTRYPOINTThe fixed program; CMD supplies its argumentsUse for wrapper scripts and CLI-style images
ARGBuild-time variableVisible in build history — also not for secrets
.dockerignore — as important as the Dockerfile
node_modules
.git
.env
*.log
dist
coverage
Dockerfile
README.md

Without this file, COPY . . ships your local node_modules (built for the wrong platform), your entire Git history, and possibly a .env full of credentials. It also makes the build context huge, so every build starts by uploading hundreds of megabytes to the daemon.

TOPIC 06The build cache

Docker caches each layer. On rebuild, it reuses a layer if that instruction and its inputs are unchanged — and once one layer misses, every layer after it is rebuilt. So order your Dockerfile from least-changing to most-changing.

the same app, two orderings
# ✗ SLOW — source copied before install
COPY . .                 # any code change invalidates this layer…
RUN npm ci               # …so dependencies reinstall every single time (~90s)

# ✓ FAST — manifest first
COPY package*.json ./    # changes only when dependencies change
RUN npm ci               # CACHED on almost every build
COPY . .                 # cheap layer, changes constantly
terminal · watch the cache work
docker build -t notes-api:v1 .
=> [2/5] COPY package*.json ./     CACHED
=> [3/5] RUN npm ci                CACHED   ← 90s saved
=> [4/5] COPY . .                  0.2s

docker build --no-cache -t notes-api:v1 .   # force a cold build to compare
docker history notes-api:v1                 # size of every layer — find the fat

This is the highest-value five minutes of work in most pipelines: a team with an inverted Dockerfile is paying ninety seconds on every commit, several times a day, for nothing.

TOPIC 07Multi-stage builds

Building often needs a compiler, dev dependencies and build tools. Running needs none of them. A multi-stage build uses one stage to build and a clean stage to ship, copying only the finished artifact across.

Dockerfile — 1.1 GB becomes 90 MB
# ---------- stage 1: build ----------
FROM node:20 AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci                      # includes devDependencies
COPY . .
RUN npm run build               # produces /app/dist

# ---------- stage 2: run ----------
FROM node:20-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY package*.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --from=build /app/dist ./dist    # ONLY the built output crosses over
USER node
EXPOSE 3000
CMD ["node", "dist/server.js"]

Three wins at once: smaller images (faster pulls, faster deploys, cheaper storage), a smaller attack surface (no compilers or package managers to exploit), and no accidental leakage of build-time secrets into the final image. For compiled languages the effect is even more dramatic — a Go binary can ship in a scratch or distroless image of a few megabytes.

TOPIC 08Volumes: keeping data

Containers are ephemeral: delete one and its writable layer goes with it. That is a feature — it is what makes them reliably reproducible — but it means anything that must survive has to live outside.

terminal · volumes and bind mounts
# named volume: Docker manages it, ideal for databases
docker volume create pgdata
docker run -d --name db -v pgdata:/var/lib/postgresql/data \
  -e POSTGRES_PASSWORD=secret postgres:16

# bind mount: a host folder, ideal for local development
docker run -d -p 3000:3000 -v "$PWD:/app" -w /app node:20 npm run dev
# ↑ edit files on your laptop, the container sees them instantly

docker volume ls
docker volume inspect pgdata
docker volume rm pgdata          # this deletes the data. no undo.
real life

A container is a hotel room: you can rearrange it freely, and housekeeping resets everything when you check out. A volume is the locker in the lobby — outside the room, still there for the next guest. Anyone who has lost a development database to a docker rm learns the distinction permanently in one afternoon.

TOPIC 09Networks: containers talking

On a user-defined bridge network, containers reach each other by container name — Docker runs a small DNS server for you. That is the piece that makes multi-container apps pleasant.

terminal · name-based networking
docker network create appnet
docker run -d --name db --network appnet -e POSTGRES_PASSWORD=secret postgres:16
docker run -d --name api --network appnet -p 3000:3000 \
  -e DATABASE_URL=postgres://postgres:secret@db:5432/postgres notes-api:v1
#                                                    ↑ "db" is the container name

docker exec -it api sh -c 'nc -zv db 5432'     # prove they can talk
docker network inspect appnet

Note what did not happen: the database has no -p flag, so it is not published to your host at all. It is reachable only by containers on appnet. That is the containerised version of the firewall tiering from chapter 03 — and the habit to keep.

TOPIC 10Compose: the whole stack in one file

Typing five docker run commands in the right order does not scale. Docker Compose declares the whole stack in one file, and docker compose up creates the network, the volumes and the containers.

compose.yaml
services:
  api:
    build: .                       # build from the Dockerfile here
    ports: ["3000:3000"]
    environment:
      DATABASE_URL: postgres://postgres:secret@db:5432/app
      REDIS_URL: redis://cache:6379
    depends_on:
      db:
        condition: service_healthy  # wait for READY, not just "started"
    restart: unless-stopped

  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_PASSWORD: secret
      POSTGRES_DB: app
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 5s
      retries: 10

  cache:
    image: redis:7-alpine

volumes:
  pgdata:
terminal · compose
docker compose up -d            # build if needed, start everything
docker compose ps
docker compose logs -f api      # logs of one service
docker compose exec api sh
docker compose down             # stop and remove containers + network
docker compose down -v          # …and delete the volumes (data gone)
docker compose up -d --build    # rebuild after changing the Dockerfile

Compose is the best onboarding tool in existence: a new joiner clones the repo, runs one command, and has the full stack — app, database, cache — running in two minutes. It is also excellent for integration tests in CI. What it is not is a production orchestrator across many machines; that is chapter 07.

TOPIC 11Registries

A registry stores images. Docker Hub is the public default; GitHub Container Registry (ghcr.io), GitLab's registry, AWS ECR, Google Artifact Registry and Azure ACR are the common private ones. Your pipeline builds an image, pushes it to a registry, and your deploy target pulls it from there.

terminal · push and pull
docker login ghcr.io -u YOUR_USER --password-stdin < token.txt
docker build -t ghcr.io/your-user/notes-api:a91f3c .
docker push ghcr.io/your-user/notes-api:a91f3c
docker pull ghcr.io/your-user/notes-api:a91f3c

# tag the same image twice: an immutable SHA tag plus a moving one
docker tag ghcr.io/your-user/notes-api:a91f3c ghcr.io/your-user/notes-api:v2
tagging convention that saves incidents

Always push an immutable tag (the commit SHA, or a digest) and deploy that. Human-friendly tags like v2 or staging are for people; machines should reference something that can never point at different bytes tomorrow. This is what makes "which code is running in production?" a one-command question.

TOPIC 12Debugging containers

terminal · the routine, in order
docker ps -a                    # status + exit code. 0 = clean exit,
                                # 137 = SIGKILL/OOM, 1 = app error
docker logs --tail 100 api      # what did it say before dying?
docker inspect api | less       # the full truth: env, mounts, network, cmd
docker exec -it api sh          # poke around inside (if it is still running)

# it exits instantly and you cannot exec in: override the entrypoint
docker run -it --rm --entrypoint sh notes-api:v1
# then run your CMD by hand and read the real error

docker stats api                # is it being throttled or starved?
docker diff api                 # what has it written to its writable layer?
SymptomUsual cause
Exits immediately, code 0The main process finished — a foreground command was needed, not a background one
Exit code 137Killed: out of memory, or a docker stop that timed out into SIGKILL
Port mapped but nothing answersApp bound to 127.0.0.1 inside the container instead of 0.0.0.0
"connection refused" to another serviceNot on the same user-defined network, or the wrong container name
Works locally, fails in CIFiles present on your laptop but excluded by .dockerignore — or a platform difference (ARM vs x86)
Data disappeared after redeployWritten into the container instead of a volume

TOPIC 13Production practices

  • One concern per container. App in one, database in another. It makes scaling, restarting and reasoning independent.
  • Run as a non-root user. Two lines in the Dockerfile; removes an entire escalation path.
  • Pin base images and rebuild regularly to pick up security patches. Pinning without rebuilding is just old software with extra steps.
  • Log to stdout/stderr. Do not write log files inside a container — let the platform collect the stream (chapter 10).
  • Configure by environment variables, never by baking config into the image. One image, many environments.
  • Handle SIGTERM. Finish in-flight requests, then exit. Otherwise every deploy drops user requests.
  • Set memory and CPU limits so one container cannot starve its neighbours.
  • Scan images in CI: trivy image notes-api:v1 (chapter 11).
remember this much
  • Container = isolated process sharing the host kernel. VM = its own kernel. Container start < 1s; VM boot 30s+.
  • Image = template (recipe), container = running instance (the dish). Layers cache in order.
  • Copy dependency manifests before source code, or you rebuild dependencies on every commit.
  • Multi-stage builds ship the artifact without the toolchain: smaller and safer.
  • Nothing inside a container survives it — data belongs in volumes.
  • On a user-defined network, containers find each other by name. Don't publish databases to the host.
  • Deploy immutable tags (commit SHA), never :latest.

LABContainerise a real app, then break it

55 minutes · Docker Desktop or Docker Engine

From Dockerfile to a two-service stack

  1. Take any small web app (or generate one — a five-line Express or Flask app with a /health route is plenty). Add a .dockerignore first.
  2. Write the Dockerfile from topic 05: pinned base, dependencies before source, non-root user, exec-form CMD. Build it: docker build -t notes-api:v1 .
  3. Run it: docker run -d -p 8080:3000 --name api notes-api:v1, then curl localhost:8080/health.
  4. Measure the cache: change one line of source and rebuild. Time it. Now move COPY . . above RUN npm ci and rebuild again. Note both times — the gap is the lesson.
  5. Break the bind: make the app listen on 127.0.0.1, rebuild, run with the same port mapping, and watch curl fail even though docker ps looks perfectly healthy. Fix it back to 0.0.0.0.
  6. Shrink it: convert to a multi-stage build. Compare with docker images and docker history. Report the before/after size to yourself out loud — this is a portfolio line.
  7. Add a database: write the compose.yaml from topic 10 with Postgres and a healthcheck. docker compose up -d. Confirm the API talks to db by name.
  8. Prove the volume works: write a row, docker compose down, up -d again, and read it back. Then run down -v and watch it vanish. Now you will never confuse the two flags.
  9. Stretch: add a step to the chapter 05 pipeline that builds this image and pushes it to ghcr.io tagged with ${{ github.sha }}.

CHECKCheck yourself

A Dockerfile has COPY . . immediately before RUN npm ci. What is the consequence?

Layer caching is sequential: a miss on COPY . . forces every later layer to rebuild, including the expensive install. Copy package*.json first, install, then copy the source — that ordering alone often cuts a build from two minutes to fifteen seconds.

docker ps shows your container up and the port mapped as 0.0.0.0:8080->3000/tcp, but curl localhost:8080 is refused. Most likely cause?

A container has its own loopback interface. Binding to 127.0.0.1 means "only reachable from inside this container", so the port mapping delivers traffic to an address nothing is listening on. Confirm from inside with docker exec -it api sh -c 'netstat -ltn' and bind to 0.0.0.0.

Why is deploying myapp:latest to production a bad idea?

latest is just a label that anyone can move. Two nodes pulling "the same" tag hours apart can run different code, and a rollback has nothing specific to roll back to. Tag with the commit SHA or deploy by digest, so a running container is always traceable to an exact line of code.

saved in this browser only — no account needed