diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..aefc5eb --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +node_modules +.git +dist +openspec +*.md +.claude diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e5f952f --- /dev/null +++ b/Dockerfile @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..4904962 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,6 @@ +services: + app: + build: . + ports: + - "${PORT:-8080}:80" + restart: unless-stopped diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..951c77c --- /dev/null +++ b/nginx.conf @@ -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; + } +} diff --git a/openspec/changes/docker-compose-deployment/.openspec.yaml b/openspec/changes/docker-compose-deployment/.openspec.yaml new file mode 100644 index 0000000..0b4defe --- /dev/null +++ b/openspec/changes/docker-compose-deployment/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-03-01 diff --git a/openspec/changes/docker-compose-deployment/design.md b/openspec/changes/docker-compose-deployment/design.md new file mode 100644 index 0000000..2792082 --- /dev/null +++ b/openspec/changes/docker-compose-deployment/design.md @@ -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. diff --git a/openspec/changes/docker-compose-deployment/proposal.md b/openspec/changes/docker-compose-deployment/proposal.md new file mode 100644 index 0000000..90b3d93 --- /dev/null +++ b/openspec/changes/docker-compose-deployment/proposal.md @@ -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 + + +## 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 diff --git a/openspec/changes/docker-compose-deployment/specs/docker-deployment/spec.md b/openspec/changes/docker-compose-deployment/specs/docker-deployment/spec.md new file mode 100644 index 0000000..d206a33 --- /dev/null +++ b/openspec/changes/docker-compose-deployment/specs/docker-deployment/spec.md @@ -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 diff --git a/openspec/changes/docker-compose-deployment/tasks.md b/openspec/changes/docker-compose-deployment/tasks.md new file mode 100644 index 0000000..5dbce3c --- /dev/null +++ b/openspec/changes/docker-compose-deployment/tasks.md @@ -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