Containers & Docker
Stage 3 · DevOps, Deployment & Operations · B.U.I.L.D. letter: D
You shipped your app to a server, and it exploded on arrival. Same code. Different universe. Containers are how you ship the universe too.
⚠️ The vibe trap
You built something beautiful on your laptop — Node 20, a specific version of Sharp, some native binaries that compiled just right — and it worked perfectly. Then it hit the server running Node 18, a different OS, and three missing system libraries, and it fell apart silently. This is the "works on my machine" problem, and it has ended careers and delayed launches for decades. The server is not your laptop. The CI runner is not the server. Your teammate's Mac is not any of these. Without containers, every environment is a snowflake you have to maintain by hand, and environment drift is silent, cumulative, and brutal.
📦 Images vs. Containers: The Blueprint and the Building
A Docker image is a frozen, layered snapshot of your application plus everything it needs to run: the OS base, the runtime (Node, Python, etc.), your dependencies, your compiled code, and your startup command. It is immutable — once built, it does not change.
A container is a running instance of an image. You can spin up ten containers from the same image and each one gets its own isolated process space, filesystem, and network interface. Stop the container and the image is still there, unchanged, ready to run again.
Mental model: An image is a recipe and all the pre-measured ingredients sealed in a box. A container is the dish you actually cook from it. The dish gets eaten (stopped, deleted). The box stays on the shelf.
Why this matters: Because "works on my machine" becomes "works in this image, period." You build the image once, push it to a registry, and every environment — staging, production, your teammate's laptop, CI — pulls and runs the exact same bytes.
Common mistake: Treating containers as VMs. They are not VMs. Containers share the host kernel; they are isolated processes, not isolated operating systems. They start in milliseconds, not minutes, and they are designed to be ephemeral — do not store important data inside a running container's filesystem.
📄 The Dockerfile, Line by Line
A Dockerfile is the recipe for building your image. Here is a real, production-worthy multi-stage Dockerfile for a Node.js application. Read every line — none of them are accidental.
# ── Stage 1: builder ──────────────────────────────────────────────────────────
# Start from the official Node 20 image pinned to Alpine Linux (tiny, ~5 MB base)
FROM node:20-alpine AS builder
# Set the working directory inside the image
WORKDIR /app
# Copy ONLY the package files first — this is the layer-caching trick (see below)
COPY package.json package-lock.json ./
# Install ALL dependencies (including devDependencies needed for build)
RUN npm ci
# Now copy the rest of your source code
COPY . .
# Run your build step (TypeScript compile, Next.js build, etc.)
RUN npm run build
# ── Stage 2: runner ───────────────────────────────────────────────────────────
# Start FRESH from the same small base — none of the builder's node_modules or
# source files come with us. This is what shrinks the final image.
FROM node:20-alpine AS runner
WORKDIR /app
# Set NODE_ENV so libraries optimise for production
ENV NODE_ENV=production
# Copy only what the production server actually needs
COPY --from=builder /app/package.json ./
COPY --from=builder /app/package-lock.json ./
# Install ONLY production dependencies in this clean stage
RUN npm ci --omit=dev
# Copy the compiled output from the builder stage
COPY --from=builder /app/dist ./dist
# Tell Docker which port your app listens on (documentation + firewall hint)
EXPOSE 3000
# The command that runs when a container starts from this image
CMD ["node", "dist/index.js"]
Mental model: Think of stages as assembly-line stations. The builder station has all the power tools and raw materials. The runner station receives only the finished product. You would not ship the factory with the furniture.
Why multi-stage? A single-stage build that includes devDependencies, TypeScript source, and build tooling can easily reach 1–2 GB. The multi-stage approach above typically produces a 150–300 MB image. Smaller images pull faster, start faster, and have fewer attack surfaces.
Common mistake: Putting COPY . . before COPY package.json package-lock.json ./ and RUN npm ci. Every time any source file changes, Docker invalidates the cache at that layer and re-runs everything below it, including the slow npm ci. Put dependency installation before code copying and Docker will only re-run npm ci when your package files actually change.
🔇 .dockerignore: What Stays Off the Truck
Just as .gitignore keeps secrets and junk out of git, .dockerignore keeps them out of your image. Without it, COPY . . will bundle your node_modules (overwriting the clean ones you just installed), your .env file with real secrets, your .git history, and your editor config into the image.
# .dockerignore
node_modules
.env
.env.*
.git
.gitignore
dist
.next
coverage
*.log
*.md
.DS_Store
Mental model: Your Dockerfile is the shipping manifest. .dockerignore is the customs officer who refuses to let contraband on the ship.
Why this matters: node_modules alone can be hundreds of megabytes. More critically, if .env contains a real database password or API key, it is now baked into your image — and if you ever push that image to a public registry, you have exposed your credentials to the world. This is a supply-chain security issue, not just a size issue.
Common mistake: Forgetting to ignore dist or .next. Your COPY . . copies them into the image, then your RUN npm run build overwrites them — wasting a layer and adding unnecessary size. Always ignore build output directories that you are re-generating inside the Dockerfile.
🏗️ Building and Running Locally
Here are the exact commands to build your image and run a container from it on your machine.
# Build an image and tag it with a name
# -t myapp:latest → name:tag (use a version tag in production, not "latest")
# . → build context: send this directory to the Docker daemon
docker build -t myapp:latest .
# Run a container from that image
# -p 3000:3000 → map host port 3000 → container port 3000
# --rm → delete the container when it stops (keeps things tidy locally)
# -e DATABASE_URL="..." → inject an environment variable (never bake secrets into images)
docker run --rm -p 3000:3000 -e DATABASE_URL="postgres://..." myapp:latest
# See running containers
docker ps
# See all containers (including stopped)
docker ps -a
# Open a shell inside a running container for debugging
docker exec -it <container-id> sh
# Remove an image you no longer need
docker rmi myapp:latest
Mental model: docker build takes a recipe (Dockerfile) and bakes an image. docker run takes an image and starts a live process from it. The -p flag punches a hole from your host machine's network into the container's isolated network.
Why -e for secrets? Environment variables are injected at runtime and never stored in the image layers. Secrets baked into images via ENV or COPY are readable by anyone who pulls the image. Inject secrets at runtime via -e, --env-file, or your orchestration platform's secret store.
Common mistake: Running docker run myapp and wondering why you cannot reach it in your browser. Containers have their own network. You must map a host port to a container port with -p host:container. If you skip -p, the container is running but completely unreachable from outside.
🐙 Docker Compose: Your Whole Stack, One Command
Your app does not live alone — it needs a database, maybe a cache, maybe a background worker. Docker Compose lets you declare your entire local stack in one YAML file and spin it all up with docker compose up.
# docker-compose.yml
version: "3.9"
services:
app:
build:
context: .
dockerfile: Dockerfile
ports:
- "3000:3000"
environment:
NODE_ENV: development
DATABASE_URL: postgres://hyve:secret@db:5432/hyvedb
depends_on:
db:
condition: service_healthy
volumes:
# Mount source code for hot-reload in development only
- ./src:/app/src
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: hyve
POSTGRES_PASSWORD: secret
POSTGRES_DB: hyvedb
ports:
- "5432:5432"
volumes:
# Named volume persists your data across container restarts
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U hyve -d hyvedb"]
interval: 5s
timeout: 5s
retries: 5
volumes:
postgres_data:
# Start the entire stack (builds images if needed, then starts all services)
docker compose up
# Start in the background (detached mode)
docker compose up -d
# View logs from all services
docker compose logs -f
# View logs from just one service
docker compose logs -f app
# Stop and remove containers (volumes are preserved)
docker compose down
# Stop and remove containers AND volumes (wipes your local DB data)
docker compose down -v
Mental model: docker-compose.yml is a blueprint for your entire local development environment. New teammate joins? They clone the repo, run docker compose up, and have a running app with a seeded database in under two minutes — no "install Postgres and set up the user" instructions required.
Why depends_on with service_healthy? Without it, your app container starts, tries to connect to Postgres, and fails because Postgres is still initializing. The healthcheck makes Compose wait until Postgres is actually ready to accept connections before starting your app.
Common mistake: Using volumes: - ./src:/app/src in production. Mounting host directories is great for hot-reload in development, but in production your image should be self-contained — no host mounts. Use separate Compose files (docker-compose.yml for dev, docker-compose.prod.yml for production overrides) or just use the image directly in production.
🚫 When You Do NOT Need Docker
Docker is a tool, not a religion. Several modern platforms remove the need to write or maintain a Dockerfile entirely:
- Vercel / Netlify — detect your framework, build in their cloud, and deploy to their edge network. Zero Dockerfile required, and for most Next.js / Vite / SvelteKit projects, these platforms build for you faster and more reliably than a hand-rolled Dockerfile.
- Railway / Render — can auto-detect and build common stacks. You can still provide a Dockerfile if you need control.
- Heroku (and Heroku-style platforms) — buildpacks do what a Dockerfile does, automatically.
Use Docker when: you have a non-standard runtime, multiple services (app + worker + cron), need exact OS-level control, are targeting Kubernetes or ECS, or are on a platform that runs your image directly.
Skip Docker when: your platform builds for you, your app is a simple single-service web app that fits a standard framework, and the added complexity of maintaining a Dockerfile does not buy you anything.
The vibe coder's instinct — ship fast, reduce friction — is correct here. If Vercel already handles your build flawlessly, adding a Dockerfile is complexity for its own sake. Add it when you need it.
🛠️ Your Mission
Write a Dockerfile for your own project and run it as a container on your local machine.
- Create a
Dockerfilein the root of a project you built (use the multi-stage template above as your starting point). - Create a
.dockerignorethat excludesnode_modules,.env, and build output. - Run
docker build -t myproject:v1 .and fix any errors until the build succeeds. - Run
docker run --rm -p 3000:3000 myproject:v1and confirm your app responds in the browser. - If your project uses a database, add a
docker-compose.ymlwith both anappanddbservice and bring the full stack up withdocker compose up. - Check your final image size with
docker images myproject. If it is over 500 MB, investigate — you likely have devDependencies or build artifacts in the final stage.
✅ You're done when…
- Your
docker buildcompletes without errors and produces an image under 500 MB - Your container starts and your app is reachable at
http://localhost:3000 - Your
.dockerignorepasses the Production-Readiness Checklist: nonode_modules, no.envfiles, no.gitdirectory in the image (verify withdocker run --rm myproject:v1 ls -la) - You can explain the difference between an image and a container to a teammate in one sentence
- Your
docker-compose.ymlstarts bothappanddbwith a singledocker compose up, and the app is healthy before it attempts to connect to the database
➡️ Next: Infrastructure as Code. Build It Right, Or Don't Build It At All. 🏛️