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:
6
.dockerignore
Normal file
6
.dockerignore
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
node_modules
|
||||||
|
.git
|
||||||
|
dist
|
||||||
|
openspec
|
||||||
|
*.md
|
||||||
|
.claude
|
||||||
12
Dockerfile
Normal file
12
Dockerfile
Normal 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
6
docker-compose.yml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
services:
|
||||||
|
app:
|
||||||
|
build: .
|
||||||
|
ports:
|
||||||
|
- "${PORT:-8080}:80"
|
||||||
|
restart: unless-stopped
|
||||||
21
nginx.conf
Normal file
21
nginx.conf
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-03-01
|
||||||
72
openspec/changes/docker-compose-deployment/design.md
Normal file
72
openspec/changes/docker-compose-deployment/design.md
Normal 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.
|
||||||
25
openspec/changes/docker-compose-deployment/proposal.md
Normal file
25
openspec/changes/docker-compose-deployment/proposal.md
Normal 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
|
||||||
@@ -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
|
||||||
23
openspec/changes/docker-compose-deployment/tasks.md
Normal file
23
openspec/changes/docker-compose-deployment/tasks.md
Normal 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
|
||||||
Reference in New Issue
Block a user