0 / 91
Week 11 · Day 76 of 91

Docker and repeatable environments

Testing, Security, Docker, and Deployment

Objective

Package services so another machine can run consistent versions.

Production readiness resembles validation before field deployment: repeatable setup, protective limits, test evidence, monitoring, and rollback plans.

  • image versus container
  • Dockerfile
  • Compose services and volumes

Why this matters

Right now, running your app on a new machine means installing the right Node version, installing PostgreSQL, creating a database, running migrations, and setting environment variables — and discovering, some hours in, that the other machine has PostgreSQL 15 and one of your queries behaves differently. Today you package the whole stack so that a clean clone plus one command gives a running API and database at the exact versions you tested. That is the difference between "works on my machine" and software someone else can run.

The problem being solved

"Works on my machine" is not a joke about carelessness. It is a real statement: your app depends on far more than the files in your repository. It depends on a Node version, a PostgreSQL version, system libraries, environment variables, and a database that already has your schema in it. None of that is in Git, so none of it travels with your code.

There are two ways to fix this: write a long setup document and hope people follow it exactly, or ship the environment along with the code. Containers are the second option.

What a container actually is

A container is an ordinary process on your machine, started from a packaged filesystem and run with isolation so that it sees only that filesystem, its own process list, and its own network interface.

Read that again, because the common misconception matters: a container is not a virtual machine. There is no second operating system booting inside it. A VM emulates hardware and runs a full guest OS with its own kernel — gigabytes of image, tens of seconds to boot. A container shares your machine's kernel and simply runs a process whose view of the world has been fenced off. That is why containers start in well under a second and why a small one is tens of megabytes.

The consequence: the packaged filesystem contains everything above the kernel — the Node binary, system libraries, your code — so it is identical everywhere. What it cannot change is the kernel, which is why containers built for Linux run on macOS and Windows through a small Linux VM that Docker Desktop manages for you.

A potted module versus a whole instrument

A virtual machine is shipping the entire instrument — chassis, supply, and all — so the customer can be sure the board is powered correctly. A container is a potted module: the circuit and every passive it depends on, encapsulated, with defined pins. It draws from the host's supply rail instead of carrying its own, so it is far smaller and starts instantly — but it must be compatible with that rail. The shared kernel is the rail.

Image versus container

This is the distinction everything else rests on, and today's quiz asks it directly.

An image is a packaged filesystem and configuration, used to create containers. It is read-only, built once, and stored as a stack of layers. Its configuration includes the default command, the working directory, environment variables, and the port it expects to serve on. An image does nothing on its own; it is inert, like a .zip that also remembers how to be launched.

A container is a running (or stopped) instance created from an image: the image's filesystem plus a thin writable layer on top, with a process running inside. You can start ten containers from one image; they all see the same starting files and cannot see each other's writes.

docker images   # the inert packages you have built or pulled
docker ps       # the containers currently running
docker ps -a    # containers including stopped ones

Recipe and meal

The image is the recipe together with all the ingredients sealed in a box. The container is the meal you cooked from it. Cooking twice gives two meals from one box, and eating one does not alter the recipe.

Two minutes, right now

Run docker run hello-world. Docker downloads a tiny image, creates a container, runs it, and prints Hello from Docker! followed by an explanation of the steps it just took. Then run docker ps -a and find the stopped container — the process ended, but the container record is still there. That is image and container in ninety seconds.

The Dockerfile

A Dockerfile is the recipe for building an image: a list of instructions, each producing a layer.

FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
EXPOSE 3000
CMD ["node", "src/server.js"]

Line by line: start from the official Node 22 image on Alpine Linux (small); work inside /app; copy only the dependency manifests; install exactly the versions in package-lock.json, skipping dev dependencies; copy the rest of the source; declare the port as documentation; and record the command that runs when a container starts.

The odd-looking part is copying package*.json before the source. Docker caches each layer and reuses it while its inputs are unchanged. Because your source changes far more often than your dependencies, this order means editing a route rebuilds in seconds instead of reinstalling every package.

Next to it, a .dockerignore — the same idea as .gitignore, deciding what is not copied in:

node_modules
.env
.git
test-results

Never bake secrets into an image

Anything copied in, or set with ENV, is readable by anyone who can pull the image — including in earlier layers, so deleting a file in a later step does not remove it. .env in .dockerignore is the first defence. Pass secrets in at run time instead, as this lesson does with env_file. If a key does get built in, rotate the key; rebuilding the image is not enough.

Compose: services, networks, and volumes

Your app is two programs, so you need two containers that can find each other. Docker Compose describes a multi-container stack in one file, compose.yaml:

services:
  db:
    image: postgres:17-alpine
    environment:
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: postgres
      POSTGRES_DB: maintenance
    volumes:
      - db-data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 5s
      timeout: 5s
      retries: 10

  api:
    build: .
    env_file: .env
    ports:
      - "3000:3000"
    depends_on:
      db:
        condition: service_healthy

volumes:
  db-data:

Three ideas in there:

  • Services are the containers. Compose puts them on a private network and gives each a hostname equal to its service name. So inside the api container, the database is at db, not localhost: DATABASE_URL=postgresql://postgres:postgres@db:5432/maintenance. localhost inside a container means that container, which is the single most common Compose mistake.
  • Ports map host to container. "3000:3000" means port 3000 on your machine reaches port 3000 inside. The database has no ports entry, so it is reachable by the API but not from outside — a good default.
  • Volumes solve the fact that a container's writable layer is deleted with the container. A named volume (db-data) is storage managed by Docker that outlives any container, so your rows survive a restart. The health check plus condition: service_healthy makes the API wait until PostgreSQL is genuinely accepting connections, instead of crash-looping on startup.

Walkthrough: bring the stack up

docker compose up --build
[+] Running 3/3
 ✔ Network maintenance-tracker_default  Created
 ✔ Container maintenance-tracker-db-1   Healthy
 ✔ Container maintenance-tracker-api-1  Started

In another terminal, check state and logs, then prove the API answers:

docker compose ps
docker compose logs -f api
curl -i http://localhost:3000/api/equipment

Run your migrations inside the running API container — exec runs a command in a container that already exists:

docker compose exec api npm run migrate

Stop the stack:

docker compose down

down removes the containers and the network but keeps the named volume, so docker compose up again finds your data intact. Confirm that: create a record, down, up, and read it back.

`docker compose down -v` and `docker system prune -a`

down -v also deletes the named volumes — every row in your database, permanently, with no prompt. That is exactly what you want for a clean reset and exactly what you must never run against anything real. docker system prune -a goes further: it deletes all stopped containers, unused networks, and all images not used by a running container, so the next build re-downloads everything. Safe alternatives: docker compose down to stop without data loss, and docker image ls to see what you actually have before removing anything by name.

Checkpoint

You can say which command lists images, which lists containers, and why the API reaches the database at db rather than localhost.

Your turn

  1. Write the Dockerfile and .dockerignore for your API. Build it: docker build -t maint-api . Confirm with docker images that maint-api is listed, and note its size.
  2. Write compose.yaml with the db and api services, the named volume, and the health check.
  3. Create .env with DATABASE_URL pointing at the db host, and confirm .env is in both .gitignore and .dockerignore. Commit a .env.example with the keys and dummy values instead.
  4. Run docker compose up --build, then curl a route to prove the API answers.
  5. Run migrations with docker compose exec, create one record through your API, then down and up again and read the record back. That round trip is your proof the volume works.
  6. Write docs/runbook.md with four sections you have personally run: start, stop, logs, reset data (docker compose down -v, with the warning written next to it).
  7. Final test: git clone your repo into a fresh folder, copy .env.example to .env, and run docker compose up --build. If that is not enough, your runbook is incomplete — fix it now.

Pair mode — with your files open

"Review my Docker setup for secret leakage, oversized images, missing health checks, and unclear volumes."

Inspect every suggested diff, apply one change at a time, and re-run docker compose up before accepting it. A Compose file you cannot explain line by line is a stack you cannot debug at 7am.

You are done when

One command starts the whole stack from a clean clone, and data survives a down and up.

Common pitfalls

  • Using localhost for the database inside a container. It means the container itself. Use the service name, db.
  • Copying the whole project before npm ci. Every source edit then reinstalls all dependencies. Copy package*.json first.
  • Forgetting the volume. Without it, docker compose down silently discards the entire database, and you conclude the app is broken.
  • Committing .env. Commit .env.example instead. Secrets in Git history outlive the commit that removed them.

Verify it yourself

Open today's reference, Docker's Get started guide.

  1. Find where it explains the difference between an image and a container, and compare its wording to this lesson's. Write the definition of an image in your own words in docs/runbook.md.
  2. Find what docker compose down does to volumes by default, and confirm the -v behaviour this lesson warned about. Quote the documented sentence next to that command in your runbook.

The hour

  1. 0–5 min Recall

    Without notes, state yesterday’s main idea and one unresolved question.

  2. 5–20 min Learn

    Read only the listed concept notes and official reference sections needed today.

  3. 20–48 min Build

    Containerize API and PostgreSQL with Docker Compose. Document startup, shutdown, logs, and data reset.

  4. 48–55 min Explain and verify

    Run the result, inspect evidence, and explain the data/control flow in your own words.

  5. 55–60 min Quiz and commit

    Complete the quiz, record one lesson, and commit the verified change when applicable.

What to hand in

Deliverable

One command starts the development stack from a clean clone.

Working with AI today

AI as pair programmer

Define one small task, review the plan, inspect the diff, run checks, and explain every changed section.

Review my Docker setup for secret leakage, oversized images, missing health checks, and unclear volumes.

References

End-of-day quiz

Q1 What is a container image?
Q2 Which result best proves today’s work is complete?
Q3 Before accepting an AI-generated code change, what should you do?

Explain-back gate

Pass the quiz above to unlock completion.

Quiz + explain-back checks required.