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-Content-Type-Options "nosniff" always;
|
||||||
add_header X-XSS-Protection "1; mode=block" 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
|
# Cache static assets
|
||||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||||
expires 1y;
|
expires 1y;
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,9 @@
|
||||||
|
|
||||||
import type { Recipe, Tag, ApiResponse } from '../types/recipe';
|
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
|
* Fetch recipes with optional filters
|
||||||
|
|
@ -14,7 +16,7 @@ export async function fetchRecipes(params?: {
|
||||||
offset?: number;
|
offset?: number;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
}): Promise<Recipe[]> {
|
}): Promise<Recipe[]> {
|
||||||
const url = new URL(`${API_BASE_URL}/recipes`);
|
const url = new URL(`${API_BASE_URL}/recipes`, window.location.origin);
|
||||||
|
|
||||||
if (params?.search) {
|
if (params?.search) {
|
||||||
url.searchParams.set('search', params.search);
|
url.searchParams.set('search', params.search);
|
||||||
|
|
|
||||||
|
|
@ -4,4 +4,13 @@ import react from '@vitejs/plugin-react'
|
||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
// Proxy API requests to backend during development
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:3000',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue