Capstone: Full-Stack Application with Docker Compose

Advanced
16 min

Capstone: Full-Stack Application with Docker Compose

This project assembles everything from the course into one repository: a React front end served by Nginx, a Node.js API, PostgreSQL and Redis, wired together by Compose with healthchecks, isolated networks, a development override with live reload, and a production override with limits. By the finish you will have a template you can adapt to any stack.

Project Layout

text
shop/ ├── compose.yaml # base: services, networks, volumes, healthchecks ├── compose.override.yaml # development: bind mounts, watch, published ports ├── compose.prod.yaml # production: restart policies, limits, logging ├── .env.example ├── web/ # Vite + React, served by Nginx (Dockerfile, nginx.conf) ├── api/ # Express API (Dockerfile with dev/test/production targets) └── db/init/01-schema.sql # seed schema for first start

The base file in the sample code is the contract: it declares four services, two networks and one volume. backend is internal, so only api can reach the database and cache; web sits on frontend and proxies /api/ to the API by name.

The API Image

The API Dockerfile uses named stages so Compose can pick one per environment with build.target:

dockerfile
# syntax=docker/dockerfile:1 FROM node:22-alpine AS base WORKDIR /app COPY package.json package-lock.json ./ FROM base AS dev RUN --mount=type=cache,target=/root/.npm npm ci COPY . . CMD ["npx", "nodemon", "src/server.js"] FROM base AS production ENV NODE_ENV=production RUN --mount=type=cache,target=/root/.npm npm ci --omit=dev COPY src ./src USER node EXPOSE 3000 HEALTHCHECK --interval=15s --start-period=10s \ CMD wget -qO- http://localhost:3000/health || exit 1 CMD ["node", "src/server.js"]

The server reads DATABASE_URL and REDIS_URL, exposes /health and /products, and handles SIGTERM. The front end's Dockerfile is the Node-to-Nginx build from the static sites lesson, with try_files for routing and location /api/ { proxy_pass http://api:3000/; }.

Development Override

compose.override.yaml is loaded automatically and turns the stack into a live-reload environment:

yaml
services: web: build: { context: ./web, target: dev } # vite dev server stage ports: ["5173:5173"] develop: watch: - { action: sync, path: ./web/src, target: /app/src } api: build: { context: ./api, target: dev } ports: ["3000:3000"] volumes: ["./api:/app", "/app/node_modules"] db: ports: ["127.0.0.1:5432:5432"]
bash
cp .env.example .env docker compose up --build --watch

The API uses a bind mount with the node_modules shadow volume; the front end uses Compose watch. The database port is published on loopback only for desktop clients.

Production Override

yaml
services: web: restart: unless-stopped ports: ["80:80"] api: restart: unless-stopped deploy: { resources: { limits: { cpus: "1", memory: 512m } } } logging: { options: { max-size: "10m", max-file: "3" } } environment: DATABASE_URL: postgres://app@db:5432/shop PGPASSWORD_FILE: /run/secrets/db_password secrets: [db_password] db: restart: unless-stopped environment: { POSTGRES_PASSWORD_FILE: /run/secrets/db_password } secrets: [db_password] secrets: db_password: { file: /srv/shop/secrets/db_password }
bash
docker compose -f compose.yaml -f compose.prod.yaml config # review the merge docker compose -f compose.yaml -f compose.prod.yaml up -d

In production, images come from the registry rather than being built on the server: replace build: with image: ghcr.io/you/shop-api:${TAG} in the prod file, and let the CI pipeline from the previous lesson publish them.

Verifying the Stack

bash
docker compose ps # all four services, db and cache (healthy) curl -s localhost:8080/api/products # JSON from the API through the Nginx proxy docker compose exec api getent hosts db # resolves on backend network docker compose exec web getent hosts db # fails: web is not on backend docker compose logs -f api docker compose down # keeps db-data; add -v to reset

Then try the failure modes you have learned to diagnose: stop the database and watch the API's healthcheck fail, or run docker scout quickview on both images.

Extending the Project

  • Add a migrate service with condition: service_completed_successfully so schema changes run before the API starts.
  • Add a tests target and a compose.test.yaml, then wire them into the GitHub Actions workflow.
  • Put Caddy or Traefik in front of web for automatic TLS on a real domain.
Quick Quiz
Question 1 of 3

Why is the `backend` network declared with `internal: true`?

Key Takeaways

  • A base compose.yaml declares services, healthchecks, networks and volumes; overrides adapt it per environment.
  • Isolate tiers with networks: an internal backend for data services and a frontend for the proxy.
  • Named Dockerfile stages plus build.target give one image definition for dev, test and production.
  • Development uses bind mounts and Compose watch; production uses registry images, restart policies, limits and secrets.
  • Verify with compose ps, a request through the proxy, and DNS checks that prove the isolation works.

Next lesson: Docker in Production: Swarm, Kubernetes and Beyond — take the stack to real servers and see when orchestration is worth it.

Capstone: Full-Stack Application with Docker Compose - Docker | CodeYourCraft | CodeYourCraft