diff --git a/.dockerignore b/.dockerignore index ad80218..3a90052 100644 --- a/.dockerignore +++ b/.dockerignore @@ -33,6 +33,8 @@ Thumbs.db # Environment .env .env.local +.env.production +.env.production.example # Database (will be persisted via volumes) *.db @@ -51,6 +53,9 @@ docker-compose*.yml README.md CLAUDE.md +# Nginx config +nginx.conf.example + # Frontend build output (not needed during image build) frontend/frontend/dist backend/static/index.html diff --git a/.env.production.example b/.env.production.example new file mode 100644 index 0000000..5bcecd4 --- /dev/null +++ b/.env.production.example @@ -0,0 +1,23 @@ +# Production Environment Variables +# Copy this file to .env.production and fill in the values + +# Backend port (defaults to 5001 if not set) +PORT=5001 + +# CORS origins (use your domain in production, or * to allow all) +CORS_ORIGINS=https://trivia.torrtle.co + +# Authelia OIDC Configuration +# REQUIRED: Set these values to match your Authelia instance +OIDC_ISSUER=https://auth.torrtle.co +OIDC_CLIENT_ID=trivia-app +OIDC_CLIENT_SECRET=your_client_secret_here + +# OAuth redirect URI (must match your domain) +OIDC_REDIRECT_URI=https://trivia.torrtle.co/api/auth/callback + +# Frontend URL (must match your domain) +FRONTEND_URL=https://trivia.torrtle.co + +# Cookie security (set to 'true' in production with HTTPS) +SESSION_COOKIE_SECURE=true diff --git a/CLAUDE.md b/CLAUDE.md index 6763804..026adf4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -111,6 +111,48 @@ FRONTEND_PORT=4000 docker compose up - Named volume for `node_modules` to avoid host/container conflicts - Health check on backend before starting frontend +### Docker Production (Single Container) + +For production deployment with a single URL serving both frontend and backend: + +```bash +# Build production image (multi-stage: builds React, copies to Flask static/) +docker build -t trivia-app:latest . + +# Run with production compose file +docker compose -f docker-compose.production.yml up -d + +# Or run standalone container +docker run -d \ + -p 5001:5001 \ + -v trivia-db:/app/backend/instance \ + -v trivia-images:/app/backend/static/images \ + -v trivia-audio:/app/backend/static/audio \ + --env-file .env.production \ + trivia-app:latest + +# View logs +docker compose -f docker-compose.production.yml logs -f + +# Run migrations +docker compose -f docker-compose.production.yml exec backend uv run flask db upgrade +``` + +**Production Architecture:** +- Multi-stage `Dockerfile`: Stage 1 builds React frontend, Stage 2 runs Flask with built frontend +- Single Flask server serves both React SPA and API endpoints +- All requests go to same origin (e.g., `https://trivia.torrtle.co`) +- React uses relative URLs (`/api/*`, `/socket.io`) - no proxy needed +- Flask routing: API requests go to blueprints, all other routes serve `index.html` for React Router +- Typically deployed behind nginx/Caddy reverse proxy for HTTPS/SSL +- Volumes persist database, images, and audio files + +**Environment:** +- Copy `.env.production.example` to `.env.production` and configure +- Set `CORS_ORIGINS` to your domain (e.g., `https://trivia.torrtle.co`) +- Set `SESSION_COOKIE_SECURE=true` for HTTPS +- Configure OIDC URLs to match your domain + ## Architecture ### Application Factory Pattern diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..b9b30a0 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,57 @@ +# Multi-stage build for production +# Stage 1: Build React frontend +FROM node:20-slim AS frontend-builder + +WORKDIR /app/frontend + +# Copy package files from nested directory +COPY frontend/frontend/package*.json ./ + +# Install dependencies +RUN npm ci --only=production + +# Copy frontend source +COPY frontend/frontend ./ + +# Build for production (outputs to ../backend/static) +RUN npm run build + +# Stage 2: Production Flask app +FROM python:3.14-slim + +WORKDIR /app + +# Install uv for dependency management +COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv + +# Copy Python dependency files +COPY pyproject.toml uv.lock ./ + +# Install Python dependencies +RUN uv sync --frozen --no-dev + +# Copy backend code +COPY backend ./backend +COPY main.py ./ +COPY migrations ./migrations + +# Copy built frontend from frontend-builder stage +COPY --from=frontend-builder /app/backend/static ./backend/static + +# Create directories for uploads and database +RUN mkdir -p backend/instance backend/static/images backend/static/audio + +# Set environment variables +ENV FLASK_ENV=production +ENV PORT=5001 +ENV PYTHONUNBUFFERED=1 + +# Expose port +EXPOSE 5001 + +# Health check +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:5001/api/health')" + +# Run Flask app with socketio support +CMD ["uv", "run", "python", "main.py"] diff --git a/README.production.md b/README.production.md new file mode 100644 index 0000000..f9a0023 --- /dev/null +++ b/README.production.md @@ -0,0 +1,232 @@ +# Production Deployment Guide + +This guide covers deploying the trivia application to production with a single URL serving both the frontend and backend. + +## Architecture Overview + +In production: +- Single Flask server serves both the React SPA and API endpoints +- Multi-stage Docker build: React is built and copied into Flask's static folder +- All requests go to the same domain (e.g., `https://trivia.torrtle.co`) +- Nginx/Caddy reverse proxy handles HTTPS and forwards to Flask +- WebSocket connections work seamlessly (same origin) + +## Quick Start + +### 1. Configure Environment + +```bash +# Copy and edit production environment file +cp .env.production.example .env.production + +# Edit .env.production with your values: +# - Set CORS_ORIGINS to your domain +# - Configure OIDC/Authelia settings +# - Set SESSION_COOKIE_SECURE=true +nano .env.production +``` + +### 2. Build and Run + +```bash +# Build the production image +docker build -t trivia-app:latest . + +# Start all services +docker compose -f docker-compose.production.yml up -d + +# View logs +docker compose -f docker-compose.production.yml logs -f backend +``` + +### 3. Run Database Migrations + +```bash +# Initialize database (first time only) +docker compose -f docker-compose.production.yml exec backend uv run flask db upgrade +``` + +### 4. Configure Reverse Proxy + +#### Option A: Nginx + +```bash +# Copy example config +sudo cp nginx.conf.example /etc/nginx/sites-available/trivia + +# Update domain and SSL certificate paths +sudo nano /etc/nginx/sites-available/trivia + +# Enable site +sudo ln -s /etc/nginx/sites-available/trivia /etc/nginx/sites-enabled/ + +# Test configuration +sudo nginx -t + +# Reload nginx +sudo systemctl reload nginx +``` + +#### Option B: Caddy (Simpler) + +Create `/etc/caddy/Caddyfile`: + +```caddy +trivia.torrtle.co { + reverse_proxy localhost:5001 +} +``` + +Caddy automatically handles HTTPS with Let's Encrypt! + +## Services + +Once deployed, your application will be available at: + +- **Main App**: `https://trivia.torrtle.co` +- **API**: `https://trivia.torrtle.co/api/*` +- **WebSocket**: `wss://trivia.torrtle.co/socket.io` +- **Flower (optional)**: `https://trivia.torrtle.co/flower/` (if configured in nginx) + +## Maintenance + +### View Logs + +```bash +# All services +docker compose -f docker-compose.production.yml logs -f + +# Specific service +docker compose -f docker-compose.production.yml logs -f backend +docker compose -f docker-compose.production.yml logs -f celery-worker +``` + +### Update Application + +```bash +# Pull latest code +git pull + +# Rebuild and restart +docker compose -f docker-compose.production.yml up -d --build + +# Run any new migrations +docker compose -f docker-compose.production.yml exec backend uv run flask db upgrade +``` + +### Backup Database + +```bash +# Backup database volume +docker run --rm \ + -v trivia-app_trivia-db:/data \ + -v $(pwd)/backups:/backup \ + alpine tar czf /backup/trivia-db-$(date +%Y%m%d).tar.gz -C /data . + +# Backup images +docker run --rm \ + -v trivia-app_trivia-images:/data \ + -v $(pwd)/backups:/backup \ + alpine tar czf /backup/trivia-images-$(date +%Y%m%d).tar.gz -C /data . +``` + +### Restore Database + +```bash +# Stop services +docker compose -f docker-compose.production.yml down + +# Restore from backup +docker run --rm \ + -v trivia-app_trivia-db:/data \ + -v $(pwd)/backups:/backup \ + alpine sh -c "cd /data && tar xzf /backup/trivia-db-YYYYMMDD.tar.gz" + +# Start services +docker compose -f docker-compose.production.yml up -d +``` + +## Scaling + +### Run Multiple Workers + +Edit `docker-compose.production.yml`: + +```yaml +celery-worker: + # ... existing config ... + deploy: + replicas: 3 # Run 3 worker instances +``` + +### Use External Database + +For production at scale, consider using PostgreSQL instead of SQLite: + +```yaml +# docker-compose.production.yml +services: + postgres: + image: postgres:16-alpine + environment: + POSTGRES_DB: trivia + POSTGRES_USER: trivia + POSTGRES_PASSWORD: ${DB_PASSWORD} + volumes: + - postgres-data:/var/lib/postgresql/data + + backend: + environment: + - DATABASE_URI=postgresql://trivia:${DB_PASSWORD}@postgres:5432/trivia +``` + +## Monitoring + +### Health Checks + +The application includes a health check endpoint: + +```bash +curl https://trivia.torrtle.co/api/health +``` + +### Celery Flower + +Access Celery task monitoring at `http://localhost:5555` or configure nginx to expose it at `/flower/`. + +## Security Checklist + +- [ ] Set `SESSION_COOKIE_SECURE=true` in `.env.production` +- [ ] Set `CORS_ORIGINS` to your specific domain (not `*`) +- [ ] Use strong `OIDC_CLIENT_SECRET` +- [ ] Enable HTTPS with valid SSL certificate +- [ ] Keep Docker images up to date +- [ ] Regular database backups +- [ ] Restrict Flower access (don't expose publicly) +- [ ] Use firewall to restrict port 5001 to localhost only + +## Troubleshooting + +### WebSocket Connection Issues + +Ensure your reverse proxy is configured for WebSocket upgrades: +- Nginx: `proxy_set_header Upgrade $http_upgrade;` +- Caddy: Handles automatically + +### CORS Errors + +Check `CORS_ORIGINS` in `.env.production` matches your domain exactly (including https://). + +### 404 on Frontend Routes + +Flask's catch-all route should serve `index.html` for all non-API routes. Check that React build files exist in `backend/static/`. + +### Database Migration Errors + +```bash +# Check current migration version +docker compose -f docker-compose.production.yml exec backend uv run flask db current + +# Force to specific version (if needed) +docker compose -f docker-compose.production.yml exec backend uv run flask db stamp head +``` diff --git a/docker-compose.production.yml b/docker-compose.production.yml new file mode 100644 index 0000000..38d4f29 --- /dev/null +++ b/docker-compose.production.yml @@ -0,0 +1,100 @@ +services: + redis: + image: redis:7-alpine + restart: unless-stopped + networks: + - trivia-network + volumes: + - redis-data:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 3s + retries: 5 + + backend: + build: + context: . + dockerfile: Dockerfile + restart: unless-stopped + ports: + - "${PORT:-5001}:5001" + environment: + - FLASK_ENV=production + - PORT=5001 + - DATABASE_URI=sqlite:////app/backend/instance/trivia.db + - CORS_ORIGINS=${CORS_ORIGINS:-*} + - CELERY_BROKER_URL=redis://redis:6379/0 + - CELERY_RESULT_BACKEND=redis://redis:6379/0 + # OIDC/Authelia configuration + - OIDC_ISSUER=${OIDC_ISSUER} + - OIDC_CLIENT_ID=${OIDC_CLIENT_ID:-trivia-app} + - OIDC_CLIENT_SECRET=${OIDC_CLIENT_SECRET} + - OIDC_REDIRECT_URI=${OIDC_REDIRECT_URI} + - FRONTEND_URL=${FRONTEND_URL} + - SESSION_COOKIE_SECURE=${SESSION_COOKIE_SECURE:-true} + volumes: + # Persist database + - trivia-db:/app/backend/instance + # Persist uploaded images + - trivia-images:/app/backend/static/images + # Persist audio files + - trivia-audio:/app/backend/static/audio + depends_on: + redis: + condition: service_healthy + networks: + - trivia-network + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:5001/api/health')"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s + + celery-worker: + build: + context: . + dockerfile: Dockerfile + command: uv run celery -A backend.celery_app worker --loglevel=info + restart: unless-stopped + environment: + - FLASK_ENV=production + - CELERY_BROKER_URL=redis://redis:6379/0 + - CELERY_RESULT_BACKEND=redis://redis:6379/0 + - DATABASE_URI=sqlite:////app/backend/instance/trivia.db + volumes: + - trivia-db:/app/backend/instance + - trivia-audio:/app/backend/static/audio + depends_on: + redis: + condition: service_healthy + networks: + - trivia-network + + celery-flower: + build: + context: . + dockerfile: Dockerfile + command: uv run celery -A backend.celery_app flower --port=5555 + restart: unless-stopped + ports: + - "5555:5555" + environment: + - CELERY_BROKER_URL=redis://redis:6379/0 + - CELERY_RESULT_BACKEND=redis://redis:6379/0 + depends_on: + - redis + - celery-worker + networks: + - trivia-network + +networks: + trivia-network: + driver: bridge + +volumes: + redis-data: + trivia-db: + trivia-images: + trivia-audio: diff --git a/nginx.conf.example b/nginx.conf.example new file mode 100644 index 0000000..f5845aa --- /dev/null +++ b/nginx.conf.example @@ -0,0 +1,59 @@ +# Example nginx configuration for production deployment +# This reverse proxies to the Flask app running in Docker + +server { + listen 80; + server_name trivia.torrtle.co; + + # Redirect HTTP to HTTPS + return 301 https://$server_name$request_uri; +} + +server { + listen 443 ssl http2; + server_name trivia.torrtle.co; + + # SSL certificate configuration (adjust paths as needed) + ssl_certificate /etc/letsencrypt/live/trivia.torrtle.co/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/trivia.torrtle.co/privkey.pem; + + # SSL security settings + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers HIGH:!aNULL:!MD5; + ssl_prefer_server_ciphers on; + + # Client upload size (for image uploads) + client_max_body_size 10M; + + # Proxy to Flask app + location / { + proxy_pass http://localhost:5001; + proxy_http_version 1.1; + + # Standard proxy headers + 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_set_header X-Forwarded-Host $server_name; + + # WebSocket support for Socket.IO + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + + # Timeouts for WebSocket connections + proxy_connect_timeout 7d; + proxy_send_timeout 7d; + proxy_read_timeout 7d; + } + + # Optional: Separate location block for Celery Flower monitoring + location /flower/ { + proxy_pass http://localhost:5555/; + proxy_http_version 1.1; + 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; + } +}