feat(devops): add docker-compose orchestration with nginx API proxy
- Created docker-compose.yml with backend and frontend services - Backend: Node.js API on port 3000 with SQLite volume persistence - Frontend: nginx on port 8080 with health checks and restart policies - Configured nginx to proxy /api/* requests to backend service - Updated frontend API client to use relative URLs (/api instead of http://localhost:3000/api) - Added Vite dev server proxy for local development workflow - Implemented health checks for both services with service dependency - Created comprehensive documentation in docs/docker-compose.md - Verified: docker-compose.yml syntax is valid YAML Next step: Test local deployment (docker compose up)
This commit is contained in:
parent
210514fc1f
commit
853374f060
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<Recipe[]> {
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in New Issue