Files
emba-course-solver/openspec/changes/docker-compose-deployment/design.md
Bill Ballou 663d70e44b Add Docker Compose deployment with multi-stage build and nginx
Multi-stage Dockerfile builds the Vite app in Node 22 and serves static
assets from nginx:alpine. Includes gzip compression, SPA fallback routing,
immutable cache headers for hashed assets, and configurable port mapping
(default 8080). Deploy with `docker compose up -d`.
2026-02-28 23:01:50 -05:00

4.2 KiB

Context

The project is a pure client-side React/Vite app (app/ directory) with no backend or database. The build output is static HTML/JS/CSS in app/dist/. Currently there is no Dockerfile, docker-compose.yml, or any deployment configuration. The Vite config allows host soos, suggesting a specific deploy target.

Goals / Non-Goals

Goals:

  • Single-command production deployment via docker compose up -d
  • Small, efficient Docker image (static files served by nginx, no Node.js at runtime)
  • Reproducible builds — the Docker image builds from source with no host dependencies beyond Docker

Non-Goals:

  • HTTPS/TLS termination (handled by a reverse proxy or load balancer in front)
  • CI/CD pipeline configuration
  • Multi-environment configs (dev/staging/prod) — one compose file for production
  • Health check endpoints (static file server doesn't need custom health checks)

Decisions

1. Multi-stage Dockerfile: Node build → nginx serve

Choice: Two-stage Dockerfile. Stage 1 (node:22-alpine) installs deps and runs vite build. Stage 2 (nginx:alpine) copies the built dist/ into nginx's serve directory.

Rationale: The final image contains only nginx + static files (~30 MB) instead of Node.js + node_modules (~400 MB). Alpine base keeps it minimal.

Alternative considered: Single-stage Node image running vite preview. Rejected — vite preview is not production-grade and carries unnecessary runtime weight.

2. nginx for static file serving

Choice: nginx:alpine as the production web server.

Rationale: nginx is the standard for serving static SPAs — fast, lightweight, well-understood. It handles gzip compression, cache headers, and SPA routing fallback natively.

Alternative considered: Caddy (automatic HTTPS, simpler config). Rejected — HTTPS termination is a non-goal and nginx is more widely deployed.

3. SPA fallback routing in nginx

Choice: Configure nginx with try_files $uri $uri/ /index.html so all non-file routes fall back to index.html.

Rationale: This is a single-page React app. If the user refreshes on any route, nginx needs to serve index.html and let React Router handle it. Even though this app currently has no client-side routing, this is the correct default for any SPA.

4. Gzip compression enabled in nginx

Choice: Enable gzip for text/html, CSS, JS, and JSON in the nginx config.

Rationale: The app's JS bundle benefits significantly from compression. nginx handles this with zero application changes.

5. Cache headers for hashed assets

Choice: Set Cache-Control: public, max-age=31536000, immutable for files in /assets/ (Vite's hashed output), and no-cache for index.html.

Rationale: Vite produces content-hashed filenames for JS/CSS (e.g., index-DaSE1W8K.css), so they're safe to cache forever. index.html must always be revalidated to pick up new deploys.

6. Port 80 inside container, mapped via compose

Choice: nginx listens on port 80 inside the container. docker-compose.yml maps a host port (default 8080) to container port 80.

Rationale: Standard convention. The host port is easily changeable in compose without touching the Dockerfile or nginx config.

7. File layout — all Docker files at project root

Choice: Place Dockerfile, docker-compose.yml, nginx.conf, and .dockerignore at the project root.

Rationale: The Dockerfile needs access to app/ for the build context. Placing it at root keeps the build context simple (docker build .). Compose convention is to put docker-compose.yml at root.

Risks / Trade-offs

  • No HTTPS: The container serves plain HTTP on port 80. → Mitigation: Expected to sit behind a reverse proxy (Caddy, Traefik, cloud LB) that terminates TLS.

  • Large build context if .dockerignore is missing: Without .dockerignore, Docker copies node_modules/, .git/, etc. into the build context. → Mitigation: .dockerignore excludes node_modules, .git, dist, and openspec.

  • nginx config is static: No templating for environment-specific settings. → Mitigation: For this app there's nothing environment-specific. If needed later, envsubst can be added to the entrypoint.