Without notes, state yesterday’s main idea and one unresolved question.
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
apicontainer, the database is atdb, notlocalhost:DATABASE_URL=postgresql://postgres:postgres@db:5432/maintenance.localhostinside 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 noportsentry, 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 pluscondition: service_healthymakes 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
- Write the
Dockerfileand.dockerignorefor your API. Build it:docker build -t maint-api .Confirm withdocker imagesthatmaint-apiis listed, and note its size. - Write
compose.yamlwith thedbandapiservices, the named volume, and the health check. - Create
.envwithDATABASE_URLpointing at thedbhost, and confirm.envis in both.gitignoreand.dockerignore. Commit a.env.examplewith the keys and dummy values instead. - Run
docker compose up --build, thencurla route to prove the API answers. - Run migrations with
docker compose exec, create one record through your API, thendownandupagain and read the record back. That round trip is your proof the volume works. - Write
docs/runbook.mdwith four sections you have personally run: start, stop, logs, reset data (docker compose down -v, with the warning written next to it). - Final test:
git cloneyour repo into a fresh folder, copy.env.exampleto.env, and rundocker 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
localhostfor 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. Copypackage*.jsonfirst. - Forgetting the volume. Without it,
docker compose downsilently discards the entire database, and you conclude the app is broken. - Committing
.env. Commit.env.exampleinstead. Secrets in Git history outlive the commit that removed them.
Verify it yourself
Open today's reference, Docker's Get started guide.
- 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. - Find what
docker compose downdoes to volumes by default, and confirm the-vbehaviour this lesson warned about. Quote the documented sentence next to that command in your runbook.
The hour
- 0–5 min Recall
- 5–20 min Learn
Read only the listed concept notes and official reference sections needed today.
- 20–48 min Build
Containerize API and PostgreSQL with Docker Compose. Document startup, shutdown, logs, and data reset.
- 48–55 min Explain and verify
Run the result, inspect evidence, and explain the data/control flow in your own words.
- 55–60 min Quiz and commit
Complete the quiz, record one lesson, and commit the verified change when applicable.
What to hand in
One command starts the development stack from a clean clone.
Working with AI today
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
Explain-back gate
Pass the quiz above to unlock completion.
Quiz + explain-back checks required.