diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..66ab938 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,56 @@ +version: '3.8' + +services: + # Backend API server + backend: + build: + context: . + dockerfile: Dockerfile + container_name: recipe-manager-backend + ports: + - "3000:3000" # Expose for direct API access (optional, can remove if only accessed via frontend) + volumes: + - recipe-data:/app/data # Persist SQLite database + environment: + NODE_ENV: production + DATABASE_PATH: /app/data/recipes.db + healthcheck: + test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:3000/api/recipes"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 10s + restart: unless-stopped + networks: + - recipe-network + + # Frontend (React + nginx) + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + container_name: recipe-manager-frontend + ports: + - "8080:80" # Access app at http://localhost:8080 + depends_on: + backend: + condition: service_healthy + healthcheck: + test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 5s + restart: unless-stopped + networks: + - recipe-network + +volumes: + # Named volume for SQLite database persistence + recipe-data: + driver: local + +networks: + # Internal network for service communication + recipe-network: + driver: bridge diff --git a/docs/docker-compose.md b/docs/docker-compose.md new file mode 100644 index 0000000..c5dab13 --- /dev/null +++ b/docs/docker-compose.md @@ -0,0 +1,367 @@ +# Docker Compose Setup + +**Created:** 2026-03-24 +**Purpose:** Orchestrate Recipe Manager backend and frontend containers + +--- + +## Overview + +The `docker-compose.yml` file orchestrates two services: +1. **Backend:** Node.js API server (Express + SQLite) +2. **Frontend:** React SPA served by nginx with API proxy + +### Architecture + +``` +┌─────────────────────────────────────────────┐ +│ Host Machine (http://localhost:8080) │ +└────────────────┬────────────────────────────┘ + │ +┌────────────────┴────────────────────────────┐ +│ Frontend Container (nginx:alpine) │ +│ - Serves React SPA on port 80 │ +│ - Proxies /api/* to backend service │ +│ - Health check: /health endpoint │ +└────────────────┬────────────────────────────┘ + │ Docker network (recipe-network) +┌────────────────┴────────────────────────────┐ +│ Backend Container (node:22-alpine) │ +│ - Express API on port 3000 │ +│ - SQLite database in /app/data volume │ +│ - Runs migrations on startup │ +│ - Health check: GET /api/recipes │ +└────────────────┬────────────────────────────┘ + │ +┌────────────────┴────────────────────────────┐ +│ Named Volume: recipe-data │ +│ - Persists SQLite database │ +│ - Survives container restarts │ +└─────────────────────────────────────────────┘ +``` + +--- + +## Services + +### Backend Service + +**Build context:** Root directory +**Dockerfile:** `./Dockerfile` +**Exposed port:** 3000 (optional, can be removed) +**Internal port:** 3000 +**Volume mount:** `recipe-data:/app/data` + +**Environment variables:** +- `NODE_ENV=production` +- `DATABASE_PATH=/app/data/recipes.db` + +**Health check:** `wget http://localhost:3000/api/recipes` +**Restart policy:** `unless-stopped` + +### Frontend Service + +**Build context:** `./frontend` +**Dockerfile:** `./frontend/Dockerfile` +**Exposed port:** 8080 → 80 +**Internal port:** 80 + +**Dependencies:** Waits for backend health check to pass +**Health check:** `wget http://localhost/health` +**Restart policy:** `unless-stopped` + +**Nginx configuration:** +- Serves static files from `/usr/share/nginx/html` +- Proxies `/api/*` requests to `http://backend:3000` +- SPA fallback routing (all routes → `index.html`) +- Gzip compression for text files +- Security headers (X-Frame-Options, etc.) +- Aggressive caching for static assets (1 year) + +--- + +## Usage + +### Build and Start + +```bash +# Build both services +docker compose build + +# Start services in detached mode +docker compose up -d + +# View logs +docker compose logs -f + +# View logs for specific service +docker compose logs -f backend +docker compose logs -f frontend +``` + +### Access the Application + +- **Frontend:** http://localhost:8080 +- **Backend API (direct):** http://localhost:3000/api +- **Health check:** http://localhost:8080/health + +### Stop and Remove + +```bash +# Stop services +docker compose stop + +# Stop and remove containers +docker compose down + +# Stop, remove containers, and delete volumes (⚠️ deletes database!) +docker compose down -v +``` + +### Rebuild After Changes + +```bash +# Rebuild specific service +docker compose build backend +docker compose build frontend + +# Rebuild and restart +docker compose up -d --build + +# Force complete rebuild (no cache) +docker compose build --no-cache +``` + +--- + +## Networking + +### Internal Communication + +Services communicate via the `recipe-network` bridge network: +- Frontend → Backend: `http://backend:3000/api/*` +- Docker DNS resolves service names automatically + +### External Access + +- Frontend exposed on host port **8080** +- Backend exposed on host port **3000** (optional, can be removed for security) + +### API Routing + +**From browser:** +``` +User → http://localhost:8080/api/recipes + ↓ +nginx (frontend container) → proxy_pass to http://backend:3000/api/recipes + ↓ +Express API (backend container) +``` + +**Local development (npm run dev):** +``` +User → http://localhost:5173/api/recipes + ↓ +Vite dev server → proxy to http://localhost:3000/api/recipes + ↓ +Express API (running separately) +``` + +--- + +## Volume Management + +### Recipe Data Volume + +**Name:** `recipe-data` +**Mount point:** `/app/data` in backend container +**Contents:** `recipes.db` (SQLite database) + +**Inspect volume:** +```bash +docker volume inspect recipe-manager_recipe-data +``` + +**Backup database:** +```bash +# Copy database from container to host +docker compose cp backend:/app/data/recipes.db ./backup-recipes.db + +# Or access volume directly +docker run --rm -v recipe-manager_recipe-data:/data -v $(pwd):/backup alpine cp /data/recipes.db /backup/recipes.db +``` + +**Restore database:** +```bash +# Copy database from host to container +docker compose cp ./backup-recipes.db backend:/app/data/recipes.db + +# Restart backend to apply changes +docker compose restart backend +``` + +--- + +## Environment Configuration + +### Development vs Production + +The current `docker-compose.yml` is configured for local testing. For production deployment: + +1. **Remove backend port exposure** (line 12-13) — only frontend should be exposed +2. **Add Caddy reverse proxy** for HTTPS (see ARCHITECTURE.md) +3. **Configure environment-specific variables** via `.env` file +4. **Set up Litestream** for database backups + +### Environment Variables + +Create a `.env` file for customization: + +```env +# Backend +NODE_ENV=production +DATABASE_PATH=/app/data/recipes.db + +# Frontend (if needed) +COMPOSE_PROJECT_NAME=recipe-manager + +# Ports +FRONTEND_PORT=8080 +BACKEND_PORT=3000 +``` + +Update `docker-compose.yml` to use variables: + +```yaml +ports: + - "${FRONTEND_PORT:-8080}:80" +``` + +--- + +## Troubleshooting + +### Backend won't start + +**Check logs:** +```bash +docker compose logs backend +``` + +**Common issues:** +- Migration fails → Check schema.sql syntax +- Port 3000 already in use → Change `BACKEND_PORT` in .env +- Database permission denied → Check volume permissions + +**Solution:** +```bash +# Remove containers and volumes, rebuild +docker compose down -v +docker compose up -d --build +``` + +### Frontend can't reach backend + +**Check network:** +```bash +docker compose exec frontend ping backend +``` + +**Check nginx config:** +```bash +docker compose exec frontend cat /etc/nginx/conf.d/default.conf +``` + +**Check backend health:** +```bash +docker compose exec backend wget -O- http://localhost:3000/api/recipes +``` + +### API returns 502 Bad Gateway + +**Cause:** Nginx can't reach backend service +**Check:** +1. Backend container is running: `docker compose ps` +2. Backend health check passing: `docker compose ps` (should show "healthy") +3. Network connectivity: `docker compose exec frontend ping backend` + +**Fix:** +```bash +docker compose restart backend +docker compose restart frontend +``` + +### Database not persisting + +**Cause:** Volume not mounted correctly +**Check:** +```bash +docker volume ls | grep recipe +docker compose exec backend ls -la /app/data +``` + +**Fix:** Ensure `volumes:` section in docker-compose.yml matches Dockerfile paths + +--- + +## Testing the Setup + +### Validation Checklist + +```bash +# 1. Validate docker-compose.yml syntax +docker compose config + +# 2. Build services (should succeed without errors) +docker compose build + +# 3. Start services +docker compose up -d + +# 4. Check service status (both should be "healthy") +docker compose ps + +# 5. Test backend API directly +curl http://localhost:3000/api/recipes + +# 6. Test frontend +curl http://localhost:8080 + +# 7. Test API through frontend proxy +curl http://localhost:8080/api/recipes + +# 8. Test in browser +open http://localhost:8080 +``` + +### Expected Results + +✅ Backend container running and healthy +✅ Frontend container running and healthy +✅ Database created at `/app/data/recipes.db` +✅ Migrations applied (recipes, tags, recipe_tags tables exist) +✅ Frontend accessible at http://localhost:8080 +✅ API returns empty recipes array: `{"success":true,"data":[]}` +✅ Frontend UI loads without console errors + +--- + +## Next Steps + +After verifying this setup works: + +1. **Add Caddy reverse proxy** for HTTPS (production) +2. **Configure Litestream** for database backups +3. **Set up CI/CD** for automated builds +4. **Document deployment** to paje.ca + +See `ROADMAP.md` for post-MVP features. + +--- + +## Related Documentation + +- `docker-backend.md` — Backend Dockerfile details +- `docker-frontend.md` — Frontend Dockerfile and nginx config +- `ARCHITECTURE.md` — Overall system architecture +- `docs/deployment.md` — Production deployment guide (TBD) diff --git a/frontend/nginx.conf b/frontend/nginx.conf index 7353360..6af292d 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -15,6 +15,19 @@ server { add_header X-Content-Type-Options "nosniff" always; add_header X-XSS-Protection "1; mode=block" always; + # Proxy API requests to backend service + location /api/ { + proxy_pass http://backend:3000; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_cache_bypass $http_upgrade; + } + # Cache static assets location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { expires 1y; diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 50554d4..330a2b4 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -4,7 +4,9 @@ import type { Recipe, Tag, ApiResponse } from '../types/recipe'; -const API_BASE_URL = 'http://localhost:3000/api'; +// Use relative URL - nginx will proxy to backend in production +// For local development (npm run dev), configure vite.config.ts proxy +const API_BASE_URL = '/api'; /** * Fetch recipes with optional filters @@ -14,7 +16,7 @@ export async function fetchRecipes(params?: { offset?: number; limit?: number; }): Promise { - const url = new URL(`${API_BASE_URL}/recipes`); + const url = new URL(`${API_BASE_URL}/recipes`, window.location.origin); if (params?.search) { url.searchParams.set('search', params.search); diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 8b0f57b..a860b6b 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -4,4 +4,13 @@ import react from '@vitejs/plugin-react' // https://vite.dev/config/ export default defineConfig({ plugins: [react()], + server: { + proxy: { + // Proxy API requests to backend during development + '/api': { + target: 'http://localhost:3000', + changeOrigin: true, + }, + }, + }, })