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`.
This commit is contained in:
2026-02-28 23:01:50 -05:00
parent 7a8330e205
commit 663d70e44b
9 changed files with 249 additions and 0 deletions

6
.dockerignore Normal file
View File

@@ -0,0 +1,6 @@
node_modules
.git
dist
openspec
*.md
.claude

12
Dockerfile Normal file
View File

@@ -0,0 +1,12 @@
# Stage 1: Build
FROM node:22-alpine AS build
WORKDIR /build
COPY app/ ./
RUN npm ci
RUN npx vite build
# Stage 2: Serve
FROM nginx:alpine
COPY --from=build /build/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80

6
docker-compose.yml Normal file
View File

@@ -0,0 +1,6 @@
services:
app:
build: .
ports:
- "${PORT:-8080}:80"
restart: unless-stopped

21
nginx.conf Normal file
View File

@@ -0,0 +1,21 @@
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
# Gzip compression
gzip on;
gzip_types text/html text/css application/javascript application/json;
gzip_min_length 256;
# Hashed assets — cache forever
location /assets/ {
add_header Cache-Control "public, max-age=31536000, immutable";
}
# SPA fallback — no-cache on index.html
location / {
add_header Cache-Control "no-cache";
try_files $uri $uri/ /index.html;
}
}

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-03-01

View File

@@ -0,0 +1,72 @@
## 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.

View File

@@ -0,0 +1,25 @@
## Why
The app currently has no deployment configuration — it can only be run via `npm run dev` or `npm run preview`. Adding Docker Compose enables reproducible, single-command deployment to any host with Docker installed.
## What Changes
- Add a multi-stage Dockerfile that builds the Vite app and serves the static output with nginx
- Add a docker-compose.yml for single-command deployment (`docker compose up`)
- Add an nginx configuration for serving the SPA (with client-side routing fallback)
- Add a .dockerignore to keep the image lean
## Capabilities
### New Capabilities
- `docker-deployment`: Dockerfile, docker-compose.yml, nginx config, and .dockerignore for building and serving the static app
### Modified Capabilities
<!-- No existing specs to modify -->
## Impact
- **New files**: `Dockerfile`, `docker-compose.yml`, `nginx.conf`, `.dockerignore` at project root
- **Dependencies**: Requires Docker and Docker Compose on the deployment host
- **Existing workflow**: No changes to dev workflow (`npm run dev` still works as-is)
- **Build**: Vite build runs inside Docker — no host-side Node.js required for deployment

View File

@@ -0,0 +1,82 @@
## ADDED Requirements
### Requirement: Multi-stage Docker build
The Dockerfile SHALL use a multi-stage build where stage 1 installs dependencies and builds the Vite app, and stage 2 copies only the built static assets into an nginx:alpine image.
#### Scenario: Build produces a working image
- **WHEN** `docker compose build` is run from the project root
- **THEN** a Docker image is produced that contains only nginx and the built static files from `app/dist/`
#### Scenario: Node.js is not present in the final image
- **WHEN** the final Docker image is inspected
- **THEN** it MUST NOT contain Node.js, npm, or `node_modules`
### Requirement: Single-command deployment with docker compose
The project SHALL include a `docker-compose.yml` that builds and runs the app with `docker compose up`.
#### Scenario: Start the app
- **WHEN** `docker compose up -d` is run from the project root
- **THEN** the app is built (if needed) and served on the configured host port
#### Scenario: Rebuild after code changes
- **WHEN** `docker compose up -d --build` is run after source code changes
- **THEN** the image is rebuilt with the latest code and the container is replaced
### Requirement: nginx serves the SPA correctly
nginx SHALL serve the app's static files and fall back to `index.html` for all non-file routes.
#### Scenario: Root path serves the app
- **WHEN** a browser requests `/`
- **THEN** nginx responds with `index.html` and HTTP 200
#### Scenario: Hashed asset files are served
- **WHEN** a browser requests a path under `/assets/` (e.g., `/assets/index-abc123.js`)
- **THEN** nginx responds with the file and HTTP 200
#### Scenario: Unknown paths fall back to index.html
- **WHEN** a browser requests a path that does not match any file (e.g., `/some/route`)
- **THEN** nginx responds with `index.html` and HTTP 200
### Requirement: Gzip compression enabled
nginx SHALL serve gzip-compressed responses for text-based content types.
#### Scenario: JavaScript files are compressed
- **WHEN** a browser requests a `.js` file with `Accept-Encoding: gzip`
- **THEN** the response MUST include `Content-Encoding: gzip`
#### Scenario: CSS files are compressed
- **WHEN** a browser requests a `.css` file with `Accept-Encoding: gzip`
- **THEN** the response MUST include `Content-Encoding: gzip`
### Requirement: Cache headers for hashed and unhashed assets
nginx SHALL set long-lived cache headers for content-hashed assets and no-cache for `index.html`.
#### Scenario: Hashed assets get immutable caching
- **WHEN** a browser requests a file under `/assets/`
- **THEN** the response MUST include `Cache-Control: public, max-age=31536000, immutable`
#### Scenario: index.html is not cached
- **WHEN** a browser requests `/` or `/index.html`
- **THEN** the response MUST include `Cache-Control: no-cache`
### Requirement: .dockerignore excludes unnecessary files
A `.dockerignore` file SHALL prevent large or irrelevant directories from being included in the Docker build context.
#### Scenario: node_modules excluded
- **WHEN** Docker reads the build context
- **THEN** `node_modules/` directories MUST be excluded
#### Scenario: .git excluded
- **WHEN** Docker reads the build context
- **THEN** the `.git/` directory MUST be excluded
### Requirement: Port mapping via docker-compose
The `docker-compose.yml` SHALL map a host port to the container's port 80, defaulting to host port 8080.
#### Scenario: Default port mapping
- **WHEN** `docker compose up` is run without overrides
- **THEN** the app MUST be accessible at `http://localhost:8080`
#### Scenario: Custom port via environment variable
- **WHEN** the user sets an environment variable or edits the compose file to change the host port
- **THEN** the app MUST be accessible on the configured port

View File

@@ -0,0 +1,23 @@
## 1. Docker Ignore
- [x] 1.1 Create `.dockerignore` at project root excluding `node_modules`, `.git`, `dist`, `openspec`, and `*.md`
## 2. Nginx Configuration
- [x] 2.1 Create `nginx.conf` at project root with: gzip enabled for text/html, CSS, JS, JSON; SPA fallback via `try_files $uri $uri/ /index.html`; `Cache-Control: public, max-age=31536000, immutable` for `/assets/`; `Cache-Control: no-cache` for root/index.html; listen on port 80
## 3. Dockerfile
- [x] 3.1 Create multi-stage `Dockerfile` at project root — stage 1: `node:22-alpine`, copy `app/`, install deps, run `npm run build`; stage 2: `nginx:alpine`, copy built `dist/` to nginx html dir, copy `nginx.conf`
## 4. Docker Compose
- [x] 4.1 Create `docker-compose.yml` at project root with a single `app` service that builds from the Dockerfile and maps host port 8080 to container port 80
## 5. Verification
- [x] 5.1 Run `docker compose build` and verify the image builds successfully
- [x] 5.2 Run `docker compose up -d` and verify the app is accessible at `http://localhost:8080`
- [x] 5.3 Verify gzip compression is active on JS/CSS responses
- [x] 5.4 Verify cache headers: immutable on `/assets/*`, no-cache on `/`
- [x] 5.5 Verify SPA fallback: request a non-existent path and confirm index.html is returned