Skip to main content

๐Ÿณ Weekend Project: Containerize a Full-Stack App with Docker Compose

All week you learned what images, containers, layers, volumes, and networks are. This weekend you put every piece together at once. You'll take a real full-stack app โ€” a React client, an Express API, a Postgres database, and a Redis cache โ€” and wrap the whole thing in Docker so a teammate can clone the repo, type one command, and watch it come alive. No "works on my machine," no fifteen-step README, no hunting for the right Node version. You'll write multi-stage production Dockerfiles that ship tiny, non-root images, wire four services together in a single compose.yaml, gate startup on health checks, persist the database in a named volume, and add a dev override that hot-reloads your code as you type. This is the lesson where "I can build an app" becomes "I can ship an app."

Week 11 · Weekend Project · Docker & Containers Capstone

๐ŸŽฏ Learning Objectives

By completing this project, you will be able to:

  • Write a multi-stage production Dockerfile that builds a React app and serves the static bundle from Nginx as a non-root user
  • Write a multi-stage Dockerfile for an Express API that installs only production dependencies and runs as the unprivileged node user
  • Author a modern compose.yaml (no version: key) that wires client, server, database, and Redis with service-name networking
  • Persist database data across restarts with a named volume, and gate depends_on on health checks so nothing starts before its dependencies are ready
  • Add a compose.override.yaml that bind-mounts source for hot reload in development
  • Shrink your images with a .dockerignore, alpine/slim bases, and layer-cache-friendly ordering, and manage secrets through env files

Estimated Time: 6โ€“9 hours across the weekend

Project: A fully containerized full-stack app that boots with one docker compose up โ€” client on localhost:8080, API, Postgres, and Redis all networked internally, data surviving restarts, plus a hot-reloading dev mode.

In This Project

The Goal

Build a repository that a stranger can run without knowing a single thing about your stack. They clone it, copy one example env file, type docker compose up, and thirty seconds later a working app answers at http://localhost:8080. Behind that one port sit four cooperating containers: an Nginx container serving your compiled React bundle and proxying /api calls, an Express container running your API, a Postgres container holding the data, and a Redis container for caching and sessions. They find each other by name, start in the right order, and the database keeps its data even when every container is destroyed and rebuilt.

The magic word is reproducibility. A container bundles your code together with the exact operating system libraries, language runtime, and dependencies it needs, frozen into an image. That image runs identically on your laptop, your teammate's, the CI runner, and the production server โ€” because it is identical. Docker Compose is the conductor: one declarative file describes every service, every connection, and every volume, and Compose makes it so.

๐Ÿ“– Why "one command" is a big deal

Think of the classic onboarding day: install this Node version, that database, this Redis, set these ten environment variables, run three terminals in the right order. Every step is a chance to drift from the person next to you โ€” and drift is where "works on my machine" bugs are born. Containerizing collapses all of it into a file you commit to Git. The setup instructions are the code now. New teammates, CI pipelines, and production all read the same source of truth, so they all behave the same way.

Prerequisites

This is the Week 11 capstone, so it assumes the Docker fundamentals you built up all week โ€” images versus containers, the Dockerfile instruction set, volumes, and networks โ€” plus a working full-stack app to wrap. Before you start, make sure you're comfortable with:

  • Images & containers โ€” an image is a frozen blueprint; a container is a running instance of it (this week's opening lessons)
  • The Dockerfile basics โ€” FROM, WORKDIR, COPY, RUN, EXPOSE, CMD, and how each instruction becomes a cached layer
  • Volumes & networks โ€” named volumes for durable data, and how containers on one network reach each other by service name
  • A full-stack app โ€” any React (Vite) client + Express API you've built earlier in the course; this project containerizes it rather than writing it from scratch
  • Environment variables โ€” why connection strings and secrets belong in .env, never in committed code (Week 7)
  • The terminal โ€” running commands, reading logs, and killing a process with Ctrl+C

You'll need Docker Desktop (macOS/Windows) or Docker Engine (Linux) installed, with the Compose v2 plugin โ€” check both at once:

# Docker Engine version
docker --version
# => Docker version 27.x.x

# Compose v2 is a SUBCOMMAND now: "docker compose" (a space), not "docker-compose"
docker compose version
# => Docker Compose version v2.x.x

โš ๏ธ It's docker compose, not docker-compose

The old standalone docker-compose (with a hyphen) was a separate Python program โ€” Compose v1, now end-of-life. Modern Docker ships Compose v2 as a built-in subcommand: docker compose with a space. Everything in this project uses v2. If docker compose version errors, update Docker Desktop or install the docker-compose-plugin package.

Required Features Checklist

These are the non-negotiables. Every one is achievable with plain Docker and Compose โ€” no orchestration platform required. Tick each off as you go.

โœ… Must-have features

  • โ˜ Multi-stage client Dockerfile โ€” a Node stage builds the React bundle; an Nginx stage serves it; the final image contains no Node or source code
  • โ˜ Multi-stage server Dockerfile โ€” production dependencies only, running as the non-root node user
  • โ˜ Database + Redis as services โ€” Postgres and Redis pulled from official images, with health checks
  • โ˜ A single compose.yaml โ€” no version: key, four services wired together, communicating by service name
  • โ˜ A named volume for the database so data survives docker compose down and rebuilds
  • โ˜ Health-check-gated depends_on โ€” the server waits for db and redis to be healthy, not merely started
  • โ˜ A compose.override.yaml for development with bind-mount hot reload
  • โ˜ A .dockerignore in each build context and env files for configuration
  • โ˜ Optimized image size โ€” alpine/slim bases, cache-friendly layer order, no dev dependencies in production
  • โ˜ One-command boot โ€” docker compose up brings the whole stack online

The Architecture

Four services, one internal network, one door to the outside world. Only the client publishes a port to your host (8080). The browser talks to Nginx; Nginx serves the React files and proxies any /api request across the internal network to the Express server. The server talks to Postgres and Redis. Crucially, the database and cache are never exposed to the host โ€” they're only reachable from inside the Compose network, which is exactly how you want it in production.

flowchart LR Browser["Browser"] -->|"host port 8080"| Client["client
Nginx serves React"] subgraph net["Compose network app-net"] Client -->|"proxy /api"| Server["server
Express API"] Server --> Db[("db
Postgres 16")] Server --> Redis[("redis
Redis 7")] end Db --> Vol[["db-data
named volume"]]

Notice how each container is addressed by its service name. The server's database URL is postgres://...@db:5432/... โ€” literally the word db, the service's name in compose.yaml. Compose runs a tiny DNS server on the internal network so db, redis, and server resolve to the right container automatically. You never hardcode an IP address.

The other big idea is the multi-stage build. Your client image is built in two phases: a heavyweight Node stage compiles the app, then a featherweight Nginx stage receives only the finished files. The Node toolchain โ€” hundreds of megabytes โ€” is thrown away. The image you ship is just Nginx plus a folder of static assets.

A multi-stage build: a large Node build stage produces static files that are copied into a tiny Nginx runtime image, discarding the toolchain Stage 1: build node:20-alpine npm ci + source code npm run build โ‰ˆ 400 MB ยท discarded ๐Ÿ—‘๏ธ Stage 2: runtime nginx-unprivileged just the /dist folder non-root, port 8080 โ‰ˆ 25 MB ยท shipped ๐Ÿš€ COPY --from=build only /dist crosses over
Multi-stage builds let you compile in a fat image and ship a thin one โ€” the build toolchain never reaches production.

Project Structure

Two folders โ€” one per service you build โ€” plus the Compose files and env at the repo root. Postgres and Redis need no folders of their own; you pull them ready-made from official images. Each build context gets its own Dockerfile and .dockerignore.

fullstack-app/
โ”œโ”€โ”€ compose.yaml              <-- the production topology (committed)
โ”œโ”€โ”€ compose.override.yaml     <-- dev-only tweaks, auto-merged (committed)
โ”œโ”€โ”€ .env                      <-- real secrets (NEVER committed)
โ”œโ”€โ”€ .env.example              <-- a safe template to copy (committed)
โ”œโ”€โ”€ .gitignore                <-- ignores .env, node_modules
โ”œโ”€โ”€ client/                   <-- React (Vite) single-page app
โ”‚   โ”œโ”€โ”€ Dockerfile            <-- multi-stage: build โ†’ Nginx
โ”‚   โ”œโ”€โ”€ nginx.conf            <-- serve SPA + proxy /api to the server
โ”‚   โ”œโ”€โ”€ .dockerignore
โ”‚   โ”œโ”€โ”€ package.json
โ”‚   โ””โ”€โ”€ src/
โ””โ”€โ”€ server/                   <-- Express API
    โ”œโ”€โ”€ Dockerfile            <-- multi-stage: deps โ†’ non-root runtime
    โ”œโ”€โ”€ .dockerignore
    โ”œโ”€โ”€ package.json
    โ””โ”€โ”€ src/
        โ””โ”€โ”€ server.js         <-- includes a GET /health route

๐Ÿ’ก Why compose.yaml and not docker-compose.yml?

Compose v2 looks for compose.yaml first โ€” it's the modern, preferred filename in the official spec. The old docker-compose.yml still works for backward compatibility, but new projects should use compose.yaml. And the companion compose.override.yaml is special: Compose reads it automatically and merges it on top of the base file, so a bare docker compose up picks up your dev settings with zero extra flags.

Stage 1 โ€” The Client Image (React โ†’ Nginx)

Your React app is source code the browser can't run directly โ€” it has to be compiled into plain HTML, CSS, and JavaScript first. So the client image has two jobs that call for two very different tools: a Node environment to build, and a web server to serve. A multi-stage Dockerfile does both in one file and ships only the second.

Create client/Dockerfile:

# syntax=docker/dockerfile:1

# --- Stage "base": install dependencies (shared by dev + build) ---
FROM node:20-alpine AS base
WORKDIR /app
# Copy ONLY the manifests first so this layer is cached until deps change.
COPY package*.json ./
RUN npm ci

# --- Stage "build": compile the React app into static files ---
FROM base AS build
COPY . .
RUN npm run build          # Vite outputs to /app/dist

# --- Stage "runtime": a tiny, non-root Nginx that serves /dist ---
FROM nginxinc/nginx-unprivileged:1.27-alpine AS runtime
# nginx-unprivileged already runs as a non-root user and listens on 8080.
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 8080
# The base image's default CMD starts nginx in the foreground โ€” nothing to add.

The COPY --from=build is the hinge of the whole file: it reaches into the finished build stage and lifts out only the dist folder. Everything else from that stage โ€” Node, npm, node_modules, your source โ€” is left behind and never ships.

Now the Nginx config. A single-page app needs two behaviors: serve index.html for any route the React router owns (the try_files ... /index.html fallback), and forward API calls to the Express service so the browser only ever talks to one origin.

# client/nginx.conf
server {
    listen 8080;
    server_name _;

    # Serve the compiled SPA; fall back to index.html for client-side routes.
    location / {
        root /usr/share/nginx/html;
        index index.html;
        try_files $uri $uri/ /index.html;
    }

    # Proxy API calls to the "server" service by name over the Compose network.
    location /api/ {
        proxy_pass http://server:3000;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
    }
}

๐Ÿ“– Why non-root matters

By default many base images run your process as root. If an attacker ever escapes your app, being root inside the container is a much stronger foothold for escaping the container itself. Running as an unprivileged user is defense in depth โ€” a cheap layer of safety with no downside. That's why we reach for nginxinc/nginx-unprivileged (already non-root, listening on the unprivileged port 8080) instead of the stock nginx image, and why the server image below switches to the built-in node user.

Stage 2 โ€” The Server Image (Express)

The API image is simpler โ€” Node is the runtime, so there's nothing to compile. But we still use multiple stages for two wins: install all dependencies for a dev stage with hot reload, and install only production dependencies for a lean runtime that runs as a non-root user.

Create server/Dockerfile:

# syntax=docker/dockerfile:1

# --- Stage "base": shared workdir + manifests ---
FROM node:20-alpine AS base
WORKDIR /app
COPY package*.json ./

# --- Stage "dev": ALL deps (incl. nodemon) for hot reload ---
FROM base AS dev
RUN npm ci
COPY . .
USER node                  # drop root even in development
EXPOSE 3000
CMD ["npm", "run", "dev"]  # e.g. "nodemon src/server.js"

# --- Stage "prod-deps": production dependencies ONLY ---
FROM base AS prod-deps
RUN npm ci --omit=dev      # skips devDependencies โ†’ smaller image

# --- Stage "runtime": the lean, non-root production image ---
FROM node:20-alpine AS runtime
ENV NODE_ENV=production
WORKDIR /app
COPY --from=prod-deps /app/node_modules ./node_modules
COPY . .
USER node                  # run as the built-in unprivileged "node" user
EXPOSE 3000
CMD ["node", "src/server.js"]

The order of instructions is deliberate. Copying package*.json and running npm ci before copying the rest of the source means Docker caches the expensive dependency install as its own layer. Change a line in server.js and the next build reuses the cached node_modules layer, rebuilding in seconds instead of reinstalling everything.

Your API needs a health route so Compose can tell when it's truly ready to serve traffic. Add this to server/src/server.js:

// A trivial liveness probe the container's healthcheck will hit.
app.get('/health', (req, res) => {
  res.status(200).json({ status: 'ok', uptime: process.uptime() });
});

โš ๏ธ npm ci, not npm install

Inside images, prefer npm ci. It installs the exact versions pinned in package-lock.json โ€” reproducible, faster, and it fails loudly if the lockfile and package.json disagree. Plain npm install can silently update the lockfile, which is exactly the kind of drift containers exist to prevent. Add --omit=dev for the production stage to leave test frameworks and build tools out of the shipped image.

Stage 3 โ€” Wire It Up with compose.yaml

This is the keystone. One declarative file names every service, tells Compose how to build or pull it, connects them, and describes health and persistence. Modern Compose needs no version: key โ€” that field is obsolete and Compose warns if you include it.

Create compose.yaml at the repo root:

# compose.yaml โ€” the production topology. No "version:" key needed.
name: fullstack-app

services:
  client:
    build:
      context: ./client        # uses the multi-stage Dockerfile's final stage
    image: fullstack-client:latest
    ports:
      - "8080:8080"            # the ONLY service exposed to your host
    depends_on:
      server:
        condition: service_healthy
    restart: unless-stopped

  server:
    build:
      context: ./server
      target: runtime          # build the lean production stage
    image: fullstack-server:latest
    environment:
      NODE_ENV: production
      PORT: 3000
      # Reach the DB and cache by SERVICE NAME โ€” Compose DNS resolves them.
      DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
      REDIS_URL: redis://redis:6379
    depends_on:
      db:
        condition: service_healthy   # wait until Postgres is READY, not just up
      redis:
        condition: service_healthy
    healthcheck:
      test: ["CMD", "wget", "--quiet", "--spider", "http://localhost:3000/health"]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 20s        # grace time while the app boots
    restart: unless-stopped

  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
      POSTGRES_DB: ${POSTGRES_DB}
    volumes:
      - db-data:/var/lib/postgresql/data   # named volume โ†’ durable data
    healthcheck:
      # Double $$ escapes Compose interpolation so the SHELL expands the vars.
      test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 10s
    restart: unless-stopped

  redis:
    image: redis:7-alpine
    command: ["redis-server", "--save", "60", "1", "--loglevel", "warning"]
    volumes:
      - redis-data:/data
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      timeout: 5s
      retries: 5
    restart: unless-stopped

# Named volumes live independently of any container's lifecycle.
volumes:
  db-data:
  redis-data:

Three ideas in this file carry the whole project โ€” worth reading slowly.

1. Service-name networking

Compose puts every service on one private network and gives each a DNS name equal to its service key. That's why the server's DATABASE_URL points at @db:5432 and not an IP: db resolves to the Postgres container. Change the service name and the hostname changes with it. Because db and redis publish no host ports, nothing outside the network can reach them โ€” only the server can, exactly as it should be.

2. Health-check-gated depends_on

A plain depends_on: [db] only waits for the database container to start โ€” not for Postgres to finish its own boot and accept connections. Your server would race ahead and crash on "connection refused." The fix is condition: service_healthy: Compose runs each service's healthcheck and holds dependents in the gate until the check passes. The server waits for db and redis to be genuinely ready; the client waits for the server. Startup becomes an orderly chain instead of a race.

3. A named volume for persistence

Containers are disposable โ€” delete one and everything written inside it vanishes. That's fine for stateless app code, fatal for a database. Mounting the named volume db-data at Postgres's data directory stores the files outside the container, managed by Docker. Run docker compose down and back up, rebuild the image, swap Postgres versions โ€” the data is still there. Only an explicit docker compose down -v removes it.

๐Ÿ’ก Named volume vs bind mount

A named volume (db-data:/var/lib/postgresql/data) is storage Docker owns and manages โ€” perfect for databases, where you want durability without caring where the bytes physically live. A bind mount (./server:/app) maps a folder from your machine into the container โ€” perfect for development, where you want your live source files reflected instantly. You'll use exactly that bind mount in the next stage.

Stage 4 โ€” Dev Mode & Hot Reload

The production images are sealed on purpose: source is baked in at build time, so editing a file changes nothing until you rebuild. Great for shipping, miserable for coding. Development wants the opposite โ€” your edits reflected the instant you save. The answer is a bind mount plus a dev server, layered on with an override file that Compose merges automatically.

Create compose.override.yaml:

# compose.override.yaml โ€” dev-only tweaks, auto-merged over compose.yaml.
services:
  client:
    build:
      target: base            # stop at the Node stage; run Vite's dev server
    command: ["npm", "run", "dev", "--", "--host", "0.0.0.0"]
    ports:
      - "5173:5173"           # Vite's dev port, with HMR
    volumes:
      - ./client:/app         # bind-mount live source into the container
      - /app/node_modules     # keep the image's node_modules, don't shadow it
    environment:
      NODE_ENV: development

  server:
    build:
      target: dev             # the "dev" stage: all deps + nodemon
    command: ["npm", "run", "dev"]
    ports:
      - "3000:3000"           # expose the API directly for curl/Postman
    volumes:
      - ./server:/app
      - /app/node_modules
    environment:
      NODE_ENV: development

Two mounts, working together. ./server:/app maps your project folder over the container's /app, so a saved change appears inside instantly and nodemon restarts the server. The second, /app/node_modules, is an anonymous volume that shields the container's own installed dependencies from being hidden by your host folder โ€” without it, your (possibly empty or platform-mismatched) local node_modules would shadow the good one baked into the image.

โœ… How the override merges

Run a bare docker compose up and Compose reads compose.yaml and then compose.override.yaml, deep-merging the second onto the first โ€” so you get dev builds, bind mounts, and hot reload with no flags. For production you opt out of the override by naming files explicitly:

# Development (override merged automatically):
docker compose up

# Production (base file ONLY โ€” override ignored):
docker compose -f compose.yaml up -d
sequenceDiagram participant You as You (editor) participant Host as Host folder participant Cont as Container participant Nodemon as nodemon You->>Host: Save src/server.js Host->>Cont: Bind mount reflects the change instantly Cont->>Nodemon: File watcher fires Nodemon->>Nodemon: Restart the Node process Nodemon-->>You: Updated API ready in about one second

Stage 5 โ€” Slim It Down & Secure It

A working stack isn't a finished one. Two things separate a hobby setup from a professional one: small images (faster builds, faster deploys, smaller attack surface) and clean secret handling (nothing sensitive in Git, nothing sensitive baked into a layer).

A .dockerignore in every context

When you run docker build, Docker copies the whole build context to the daemon before running the Dockerfile. Ship your entire local node_modules and .git and that copy is slow, the cache busts constantly, and secrets can leak into the image. A .dockerignore โ€” same syntax as .gitignore โ€” keeps the context lean. Put one in client/ and server/:

# client/.dockerignore  and  server/.dockerignore
node_modules
npm-debug.log
dist
build
coverage
.git
.gitignore
.env
.env.*
Dockerfile
.dockerignore
.DS_Store

Ignoring node_modules is the big one: you want the image to run its own npm ci, not inherit your host's possibly-mismatched binaries. Ignoring .env guarantees a stray secrets file can never be copied into a layer.

Env files: a template you commit, secrets you don't

Compose reads a .env file in the project root automatically and substitutes ${VAR} references in compose.yaml. Commit a template so teammates know which variables exist; keep the real .env out of Git.

# .env.example  (committed โ€” safe placeholder values)
POSTGRES_USER=appuser
POSTGRES_PASSWORD=change-me-in-a-real-env
POSTGRES_DB=appdb
# .gitignore
node_modules/
.env

New teammates run one line to get started:

cp .env.example .env      # then edit .env with real secrets, never commit it

Squeezing the images

You've already done most of the work; here's the checklist that makes it deliberate:

  • โœ… Multi-stage builds โ€” the build toolchain never reaches the runtime image (Stage 1)
  • โœ… alpine/slim bases โ€” node:20-alpine is a fraction of the size of node:20
  • โœ… npm ci --omit=dev โ€” no test or build tooling in production (Stage 2)
  • โœ… Cache-friendly order โ€” copy manifests and install before copying source, so code edits don't reinstall deps
  • โœ… A tight .dockerignore โ€” a small build context is a fast, safe build

Measure your results โ€” the numbers are motivating:

# List image sizes; the client should be tens of MB, not hundreds
docker images fullstack-client fullstack-server

# See the layers and where the bytes went
docker history fullstack-server:latest

Output (illustrative)

REPOSITORY          TAG      SIZE
fullstack-client    latest   26.4MB
fullstack-server    latest   198MB

โš ๏ธ A secret in a layer is a secret forever

Never COPY a .env into an image or bake a password into a RUN. Image layers are immutable and cached โ€” a secret written into one is recoverable by anyone who pulls the image, even if a later layer deletes the file. Pass secrets at run time through environment variables (as compose.yaml does) or Docker secrets, and keep the values in an un-committed .env.

Stage 6 โ€” Run & Verify

Time to bring it online. From the repo root, with your .env in place:

# Build images and start the whole stack (override merges โ†’ dev mode)
docker compose up --build

# ...or run detached (in the background) and follow logs separately
docker compose up --build -d
docker compose logs -f

Watch the startup order in the logs: db and redis report healthy, then the server boots, then the client comes up. That ordering is your health-check gates doing their job. Check the running services and their health at a glance:

docker compose ps
# STATE should read "running (healthy)" for db, redis, and server

Now exercise it. In dev mode the client is on Vite's port and the API is exposed directly:

# The app in the browser (dev):
#   http://localhost:5173

# Hit the API's health route straight through:
curl http://localhost:3000/health
# => {"status":"ok","uptime":12.3}

For the production shape โ€” Nginx serving the bundle and proxying /api behind a single port โ€” run the base file only:

docker compose -f compose.yaml up --build -d

# Everything behind ONE door now:
#   App:            http://localhost:8080
#   API (proxied):  http://localhost:8080/api/health

โœ… Prove persistence for real

The whole point of the named volume is that data survives. Insert a row (through your app or docker compose exec db psql ...), then tear the stack all the way down and bring it back:

docker compose down          # stops + removes containers, KEEPS volumes
docker compose up -d          # fresh containers, same db-data volume
# โ†’ your row is still there

Then contrast: docker compose down -v (note the -v) also deletes the volumes โ€” do that and the data is gone. That one flag is the line between "restart" and "wipe."

A few commands you'll lean on constantly while iterating:

docker compose logs -f server        # tail one service's logs
docker compose exec server sh        # open a shell inside a running container
docker compose exec db psql -U appuser -d appdb   # a psql prompt in the DB
docker compose restart server        # bounce one service
docker compose down                  # stop everything (data volumes survive)

Stretch Goals

Required build done with time left? Push it toward production. Pick whatever excites you โ€” none are needed to pass the rubric.

  • ๐Ÿค– CI build โ€” add a GitHub Actions workflow that runs docker compose build (and docker compose config to validate the file) on every push, so a broken Dockerfile fails the pull request
  • ๐Ÿ“ฆ Push to a registry โ€” tag your images (docker tag fullstack-server ghcr.io/you/server:1.0) and docker push them to GitHub Container Registry or Docker Hub, then deploy by pulling instead of building
  • ๐Ÿ“Š Healthcheck dashboard โ€” add a small status page (or a script over docker compose ps --format json) that shows each service's health, or wire a lightweight tool like Dozzle for live log viewing
  • ๐Ÿฉบ A client healthcheck โ€” give the Nginx service its own healthcheck (wget --spider http://localhost:8080) so Compose reports it healthy too
  • ๐Ÿ”’ Docker secrets โ€” move the Postgres password out of the env var and into a Compose secret file mounted at /run/secrets
  • ๐Ÿท๏ธ Pin digests โ€” lock base images to a SHA digest (postgres:16-alpine@sha256:...) for byte-for-byte reproducible builds
  • โš™๏ธ Resource limits โ€” add deploy.resources.limits (CPU/memory) so a runaway service can't starve the others

CI starter โ€” validate and build on every push

# .github/workflows/docker.yml
name: Docker Build

on:
  push:
    branches: [ main ]
  pull_request:
    branches: [ main ]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Validate the Compose file
        run: docker compose config

      - name: Build all images
        run: docker compose build

Self-Check Rubric

Before you call this done, grade yourself. Aim to answer "yes" to everything in the first two columns โ€” the stretch column is bonus.

AreaMeets expectations (required)Exceeds (stretch)
Client image Multi-stage: Node builds, Nginx serves; final image has no Node or source; runs non-root on 8080 Own healthcheck; base image pinned to a digest
Server image Multi-stage; npm ci --omit=dev in production; runs as the node user; /health route Distroless or further-slimmed runtime; resource limits set
Services Postgres & Redis from official images, each with a working healthcheck Secrets via Docker secret; DB/cache locked to internal network only
compose.yaml No version: key; four services wired by service name; only the client publishes a host port docker compose config validated in CI
Persistence Named volume for the DB; data survives down then up Documented backup/restore of the volume
Startup order depends_on uses condition: service_healthy; nothing starts before its deps are ready start_period tuned so no false "unhealthy" during boot
Dev experience compose.override.yaml bind-mounts source; edits hot-reload without a rebuild Debugger port exposed; live log dashboard
Size & secrets .dockerignore per context; alpine bases; .env git-ignored with an .env.example template Images pushed to a registry; sizes measured & documented

๐Ÿงช Final testing checklist

  • โ˜ docker compose up --build brings the whole stack online with one command
  • โ˜ docker compose ps shows db, redis, and server as healthy
  • โ˜ The app loads at localhost:8080 (prod) and /api calls reach the server through Nginx
  • โ˜ The server logs show it started after the database was healthy โ€” no connection-refused crash
  • โ˜ Editing a source file in dev mode hot-reloads without a manual rebuild
  • โ˜ Data survives docker compose down then up; only down -v wipes it
  • โ˜ docker images shows the client is tens of MB, not hundreds
  • โ˜ .env is git-ignored; a fresh clone works after cp .env.example .env

Summary

๐ŸŽ‰ What You Built

  • A fully containerized full-stack app โ€” React client, Express API, Postgres, and Redis โ€” that boots with a single docker compose up
  • Multi-stage Dockerfiles that build in a fat image and ship a thin, non-root one: Nginx serving a static React bundle, and an Express runtime with production-only dependencies
  • A modern compose.yaml (no version: key) with service-name networking, where only the client is exposed to the host and the data stores stay private
  • Health-check-gated depends_on so services start in a reliable order, and a named volume that keeps the database durable across rebuilds
  • A compose.override.yaml that bind-mounts source for hot reload, merged automatically in development and skipped in production
  • Lean, secure images via .dockerignore, alpine bases, and cache-friendly layers, with secrets kept in an un-committed .env beside a committed .env.example

This project is proof that Week 11 stuck. You crossed from "here's a fifteen-step setup guide, good luck" to "clone it and type one command." The moves you practiced โ€” multi-stage builds, non-root users, service-name DNS, health-gated startup, named volumes, an auto-merged dev override, and a tight .dockerignore โ€” are the everyday grammar of shipping software the way professional teams do. Your app now runs the same on every machine because it is the same, everywhere.

๐Ÿ“š Additional Resources

๐Ÿš€ What's Next?

Your app is packaged and portable โ€” but is it correct? So far you've verified it by hand, clicking and curling. That doesn't scale, and it doesn't catch the bug you introduce next Tuesday. Week 12 opens exactly there: the next lesson, Testing Principles, lays the foundation for automated tests โ€” why they matter, what to test, and how a good test suite lets you change code fearlessly. The containerized app you just built is the perfect thing to point that new safety net at.

๐ŸŽ‰ You finished Week 11!

You containerized a real full-stack app end to end. Push it to GitHub, add it to your portfolio โ€” anyone can run your project now with a single command.