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.
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 startThe 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 Dockerfile uses named stages so Compose can pick one per environment with build.target:
# 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/; }.
compose.override.yaml is loaded automatically and turns the stack into a live-reload environment:
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"]cp .env.example .env
docker compose up --build --watchThe 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.
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 }docker compose -f compose.yaml -f compose.prod.yaml config # review the merge
docker compose -f compose.yaml -f compose.prod.yaml up -dIn 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.
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 resetThen 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.
migrate service with condition: service_completed_successfully so schema changes run before the API starts.tests target and a compose.test.yaml, then wire them into the GitHub Actions workflow.web for automatic TLS on a real domain.Why is the `backend` network declared with `internal: true`?
compose.yaml declares services, healthchecks, networks and volumes; overrides adapt it per environment.internal backend for data services and a frontend for the proxy.build.target give one image definition for dev, test and production.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.