1392 lines
34 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Nginx Reverse Proxy Configuration
## Overview
Nginx serves as the central reverse proxy for Changemaker Lite V2, routing traffic to 15+ backend services via subdomain-based routing. It handles SSL termination, security headers, static file serving, and WebSocket upgrades.
**Key Responsibilities:**
- **Subdomain Routing**: `api.cmlite.org`, `app.cmlite.org`, `db.cmlite.org`, etc.
- **SSL/TLS Termination**: Handles HTTPS certificates (Let's Encrypt, Cloudflare, or Pangolin)
- **Security Headers**: CSP, HSTS, X-Frame-Options, Permissions-Policy
- **Proxy Pass**: Forwards requests to backend Docker containers
- **Static File Serving**: Serves admin GUI production builds + MkDocs site
- **WebSocket Support**: Upgrades connections for n8n, MailHog, MkDocs live reload
- **Iframe Embedding**: CSP policies allow admin to embed services (NocoDB, Gitea, etc.)
**Architecture:**
```
Internet → Nginx (:80, :443) → [Docker Internal Network]
├─ api:4000 (Express)
├─ media-api:4100 (Fastify)
├─ admin:3000 (Vite / static)
├─ nocodb:8080
├─ listmonk:9000
├─ gitea:3000
├─ n8n:5678
├─ mkdocs:8000
├─ code-server:8080
├─ mailhog:8025
├─ mini-qr:8080
├─ homepage:3000
├─ grafana:3000
└─ public-media:80
```
---
## Architecture
```mermaid
graph LR
subgraph "External Access"
USER[User Browser]
TUNNEL[Pangolin Tunnel]
end
subgraph "Nginx Proxy :80, :443"
NGINX{Nginx<br/>Subdomain Router}
end
subgraph "Backend Services (Docker Network)"
API[api:4000<br/>Express]
MEDIA[media-api:4100<br/>Fastify]
ADMIN[admin:3000<br/>Vite]
NOCODB[nocodb:8080]
LISTMONK[listmonk:9000]
GITEA[gitea:3000]
N8N[n8n:5678]
MKDOCS[mkdocs:8000]
CODE[code-server:8080]
end
USER -->|HTTP/HTTPS| NGINX
TUNNEL -->|HTTP| NGINX
NGINX -->|api.cmlite.org| API
NGINX -->|api.cmlite.org/media| MEDIA
NGINX -->|app.cmlite.org| ADMIN
NGINX -->|db.cmlite.org| NOCODB
NGINX -->|listmonk.cmlite.org| LISTMONK
NGINX -->|git.cmlite.org| GITEA
NGINX -->|n8n.cmlite.org| N8N
NGINX -->|docs.cmlite.org| MKDOCS
NGINX -->|code.cmlite.org| CODE
```
---
## Configuration Files
Nginx configuration split across multiple files:
| File | Purpose | Type |
|------|---------|------|
| `nginx/nginx.conf` | Global settings, gzip, security headers | Main config |
| `nginx/conf.d/default.conf` | Localhost fallback, path-based routing | Server block |
| `nginx/conf.d/api.conf` | API subdomain routing (Express + Fastify) | Server block |
| `nginx/conf.d/services.conf` | Supporting service subdomains | Server blocks (12+) |
**Configuration hierarchy**:
```
nginx.conf
├─ Global: worker_processes, events, http
├─ Security headers (applied to all)
├─ Gzip compression
├─ Docker DNS resolver (127.0.0.11)
└─ Include conf.d/*.conf
├─ default.conf (localhost)
├─ api.conf (api.cmlite.org)
└─ services.conf (all other subdomains)
```
---
## Global Configuration (nginx.conf)
**File**: `nginx/nginx.conf`
### Worker Configuration
```nginx
worker_processes auto;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
}
```
**Explanation**:
- `worker_processes auto`: Detects CPU cores (1 worker per core)
- `worker_connections 1024`: Max 1024 concurrent connections per worker
- Total capacity: `auto × 1024` (e.g., 4 cores = 4096 connections)
---
### HTTP Block
```nginx
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
types_hash_max_size 2048;
client_max_body_size 50m; # Default max upload size
# Include server blocks
include /etc/nginx/conf.d/*.conf;
}
```
**Key Settings**:
- `sendfile on`: Optimized file serving (kernel-level copy)
- `tcp_nopush on`: Sends HTTP headers in single packet
- `client_max_body_size 50m`: Default upload limit (overridden per location)
---
### Gzip Compression
```nginx
# Gzip compression
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_types text/plain text/css application/json application/javascript
text/xml application/xml application/xml+rss text/javascript
image/svg+xml;
```
**Performance Impact**:
- **CPU usage**: Level 6 provides 80% compression with moderate CPU cost
- **Bandwidth savings**: ~60-80% reduction for text/JSON responses
- **Excluded**: Images, video (already compressed)
---
### Security Headers
```nginx
# Security headers (applied globally)
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header Permissions-Policy "geolocation=(self), microphone=(), camera=()" always;
```
**Header Explanation**:
- **X-Content-Type-Options**: Prevents MIME sniffing attacks
- **X-XSS-Protection**: Enables browser XSS filter (legacy browsers)
- **Referrer-Policy**: Controls referer header sent to external sites
- **HSTS**: Forces HTTPS for 1 year (31536000 seconds)
- **Permissions-Policy**: Restricts geolocation/media access
**Note**: `X-Frame-Options` set per server block (not global).
---
### Docker DNS Resolver
```nginx
# Docker internal DNS — enables runtime resolution
resolver 127.0.0.11 valid=30s;
```
**Purpose**: Docker's embedded DNS server at `127.0.0.11` resolves container names.
**Why needed**: Allows Nginx to start even when optional services are down. Without this, Nginx fails to start if any upstream is missing.
**Usage pattern**:
```nginx
location / {
set $upstream_api http://changemaker-v2-api:4000;
proxy_pass $upstream_api; # Resolves at request time, not config parse
}
```
**Alternative** (fails if container missing):
```nginx
proxy_pass http://changemaker-v2-api:4000; # Resolved at config parse — fails if down
```
---
## Subdomain Routing
### Default Server (localhost)
**File**: `nginx/conf.d/default.conf`
```nginx
server {
listen 80 default_server;
server_name localhost _;
add_header X-Frame-Options "SAMEORIGIN" always;
# Admin GUI (default)
location / {
set $upstream_admin http://changemaker-v2-admin:3000;
proxy_pass $upstream_admin;
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 Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
# Media API (must come BEFORE /api/ for longest prefix match)
location /api/media/ {
set $upstream_media http://changemaker-media-api:4100;
proxy_pass $upstream_media;
# ... (proxy headers)
# Large upload support
client_max_body_size 10G;
proxy_read_timeout 3600s;
proxy_connect_timeout 75s;
proxy_request_buffering off;
}
# API (Express)
location /api/ {
set $upstream_api http://changemaker-v2-api:4000;
proxy_pass $upstream_api;
# ... (proxy headers)
}
# Public Media Gallery
location /gallery/ {
proxy_pass http://changemaker-public-media:80/;
# ... (proxy headers)
}
}
```
**Routing Logic**:
1. Request to `http://localhost/api/media/videos` → media-api:4100
2. Request to `http://localhost/api/campaigns` → api:4000
3. Request to `http://localhost/` → admin:3000
4. Request to `http://localhost/gallery/` → public-media:80
**Important**: `/api/media/` location **must** come before `/api/` in config file (longest prefix match).
---
### API Subdomain (api.cmlite.org)
**File**: `nginx/conf.d/api.conf`
```nginx
server {
listen 80;
server_name api.cmlite.org;
add_header X-Frame-Options "SAMEORIGIN" always;
# Media API endpoints (must come BEFORE / for longest prefix match)
location /media/ {
set $upstream_media http://changemaker-media-api:4100/api/;
proxy_pass $upstream_media;
# ... (proxy headers)
# Large upload support
client_max_body_size 10G;
proxy_read_timeout 3600s;
proxy_connect_timeout 75s;
proxy_request_buffering off;
# WebSocket support
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
# Main API (Express)
location / {
set $upstream_api http://changemaker-v2-api:4000;
proxy_pass $upstream_api;
# ... (proxy headers)
proxy_read_timeout 300s;
proxy_connect_timeout 75s;
}
}
```
**URL Mapping**:
- `http://api.cmlite.org/media/videos``http://changemaker-media-api:4100/api/videos`
- `http://api.cmlite.org/auth/login``http://changemaker-v2-api:4000/auth/login`
**Critical**: Media API location includes `/api/` in `proxy_pass` to rewrite path.
---
### Service Subdomains
**File**: `nginx/conf.d/services.conf`
#### Gitea (git.cmlite.org)
```nginx
server {
listen 80;
server_name git.cmlite.org;
add_header Content-Security-Policy "frame-ancestors 'self' app.cmlite.org" always;
# Increase max body size for large git pushes (2GB)
client_max_body_size 2048M;
location / {
set $upstream_gitea http://gitea-changemaker:3000;
proxy_pass $upstream_gitea;
proxy_hide_header X-Frame-Options; # Allow iframe embedding
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;
}
}
```
**Key Features**:
- **CSP `frame-ancestors`**: Allows embedding in `app.cmlite.org` (admin GUI)
- **proxy_hide_header X-Frame-Options**: Strips Gitea's default DENY policy
- **2GB upload limit**: For large repository pushes
---
#### n8n (n8n.cmlite.org)
```nginx
server {
listen 80;
server_name n8n.cmlite.org;
add_header Content-Security-Policy "frame-ancestors 'self' app.cmlite.org" always;
location / {
set $upstream_n8n http://n8n-changemaker:5678;
proxy_pass $upstream_n8n;
proxy_hide_header X-Frame-Options;
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 Upgrade $http_upgrade;
proxy_set_header Connection "upgrade"; # WebSocket support
}
}
```
**WebSocket Headers**:
- `Upgrade: $http_upgrade`: Passes WebSocket upgrade header
- `Connection: "upgrade"`: Indicates protocol upgrade
**Required for**: n8n workflow editor, MailHog live updates, MkDocs live reload
---
#### NocoDB (db.cmlite.org)
```nginx
server {
listen 80;
server_name db.cmlite.org;
add_header Content-Security-Policy "frame-ancestors 'self' app.cmlite.org" always;
location / {
set $upstream_nocodb http://changemaker-v2-nocodb:8080;
proxy_pass $upstream_nocodb;
proxy_hide_header X-Frame-Options;
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;
}
}
```
**Iframe Embedding**:
- `frame-ancestors 'self' app.cmlite.org`: Allows admin GUI to embed NocoDB
- `proxy_hide_header X-Frame-Options`: Removes NocoDB's default SAMEORIGIN policy
---
#### MkDocs (docs.cmlite.org)
```nginx
server {
listen 80;
server_name docs.cmlite.org;
add_header Content-Security-Policy "frame-ancestors 'self' app.cmlite.org" always;
location / {
set $upstream_mkdocs http://mkdocs-changemaker:8000;
proxy_pass $upstream_mkdocs;
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 Upgrade $http_upgrade;
proxy_set_header Connection "upgrade"; # Live reload WebSocket
}
}
```
**Live Reload**: MkDocs Material theme uses WebSocket for live reload during development.
---
#### Code Server (code.cmlite.org)
```nginx
server {
listen 80;
server_name code.cmlite.org;
add_header Content-Security-Policy "frame-ancestors 'self' app.cmlite.org" always;
location / {
set $upstream_code http://code-server-changemaker:8080;
proxy_pass $upstream_code;
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 Upgrade $http_upgrade;
proxy_set_header Connection "upgrade"; # VS Code WebSocket
}
}
```
**WebSocket Usage**: Code Server uses WebSockets for terminal, file watching, language server.
---
#### MailHog (mail.cmlite.org)
```nginx
server {
listen 80;
server_name mail.cmlite.org;
add_header Content-Security-Policy "frame-ancestors 'self' app.cmlite.org" always;
location / {
set $upstream_mailhog http://mailhog-changemaker:8025;
proxy_pass $upstream_mailhog;
proxy_hide_header X-Frame-Options;
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;
# WebSocket support for MailHog live updates
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}
```
**Live Updates**: MailHog uses WebSocket to push new emails to browser without polling.
---
#### Listmonk (listmonk.cmlite.org)
```nginx
server {
listen 80;
server_name listmonk.cmlite.org;
add_header X-Frame-Options "SAMEORIGIN" always;
location / {
set $upstream_listmonk http://listmonk-app:9000;
proxy_pass $upstream_listmonk;
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;
}
}
```
**No Iframe**: Listmonk not embedded in admin (accessed directly), so `SAMEORIGIN` policy kept.
---
#### Grafana (grafana.cmlite.org)
```nginx
server {
listen 80;
server_name grafana.cmlite.org;
add_header X-Frame-Options "SAMEORIGIN" always;
location / {
set $upstream_grafana http://grafana-changemaker:3000;
proxy_pass $upstream_grafana;
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 Upgrade $http_upgrade;
proxy_set_header Connection "upgrade"; # Grafana live updates
}
}
```
**WebSocket**: Grafana uses WebSocket for live dashboard updates.
---
#### Mini QR (qr.cmlite.org)
```nginx
server {
listen 80;
server_name qr.cmlite.org;
add_header Content-Security-Policy "frame-ancestors 'self' app.cmlite.org" always;
location / {
set $upstream_miniqr http://mini-qr:8080;
proxy_pass $upstream_miniqr;
proxy_hide_header X-Frame-Options;
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;
}
}
```
**Iframe Embedding**: Admin GUI embeds Mini QR for walk sheet previews.
---
#### Root Domain (cmlite.org)
```nginx
server {
listen 80;
server_name cmlite.org;
location / {
set $upstream_site http://mkdocs-site-server-changemaker:80;
proxy_pass $upstream_site;
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;
}
}
```
**Purpose**: Serves MkDocs static site (production build) on root domain.
---
#### Homepage (home.cmlite.org)
```nginx
server {
listen 80;
server_name home.cmlite.org;
add_header X-Frame-Options "SAMEORIGIN" always;
location / {
set $upstream_homepage http://homepage-changemaker:3000;
proxy_pass $upstream_homepage;
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;
}
}
```
**Dashboard**: Service status dashboard with Docker integration.
---
## Embed Proxy Ports
**Purpose**: Allow admin GUI to iframe services via localhost ports (bypassing subdomain requirements).
**Ports**: 8881-8885 (NocoDB, n8n, Gitea, MailHog, Mini QR)
**Configuration** (in `services.conf`):
```nginx
# NocoDB embed proxy (port 8881)
server {
listen 8881;
location / {
set $upstream_nocodb http://changemaker-v2-nocodb:8080;
proxy_pass $upstream_nocodb;
proxy_hide_header X-Frame-Options;
proxy_hide_header Content-Security-Policy; # Strip all frame restrictions
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;
}
}
# n8n embed proxy (port 8882)
server {
listen 8882;
location / {
set $upstream_n8n http://n8n-changemaker:5678;
proxy_pass $upstream_n8n;
proxy_hide_header X-Frame-Options;
proxy_hide_header Content-Security-Policy;
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 Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}
# Gitea embed proxy (port 8883)
server {
listen 8883;
client_max_body_size 2048M; # Large git pushes
location / {
set $upstream_gitea http://gitea-changemaker:3000;
proxy_pass $upstream_gitea;
proxy_hide_header X-Frame-Options;
proxy_hide_header Content-Security-Policy;
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;
}
}
# MailHog embed proxy (port 8884)
server {
listen 8884;
location / {
set $upstream_mailhog http://mailhog-changemaker:8025;
proxy_pass $upstream_mailhog;
proxy_hide_header X-Frame-Options;
proxy_hide_header Content-Security-Policy;
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 Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}
# Mini QR embed proxy (port 8885)
server {
listen 8885;
location / {
set $upstream_miniqr http://mini-qr:8080;
proxy_pass $upstream_miniqr;
proxy_hide_header X-Frame-Options;
proxy_hide_header Content-Security-Policy;
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;
}
}
```
**Usage in Admin GUI**:
```tsx
<iframe src="http://localhost:8881" /> {/* NocoDB */}
<iframe src="http://localhost:8882" /> {/* n8n */}
<iframe src="http://localhost:8883" /> {/* Gitea */}
<iframe src="http://localhost:8884" /> {/* MailHog */}
<iframe src="http://localhost:8885" /> {/* Mini QR */}
```
**Exposed in docker-compose.yml**:
```yaml
nginx:
ports:
- "80:80"
- "443:443"
- "8881:8881" # NocoDB
- "8882:8882" # n8n
- "8883:8883" # Gitea
- "8884:8884" # MailHog
- "8885:8885" # Mini QR
```
---
## Proxy Configuration
### Standard Proxy Headers
**All proxy locations** should include:
```nginx
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;
```
**Header Explanation**:
- `Host`: Preserves original hostname (e.g., `api.cmlite.org`)
- `X-Real-IP`: Client's IP address
- `X-Forwarded-For`: Chain of proxy IPs (adds to existing list)
- `X-Forwarded-Proto`: HTTP or HTTPS (used by backend for redirect logic)
---
### WebSocket Upgrade
**Required for**: n8n, MailHog, MkDocs, Code Server, Grafana
```nginx
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
```
**Explanation**:
- `Upgrade: websocket`: Browser requests protocol upgrade
- `Connection: upgrade`: Indicates connection will persist
**Without these headers**: WebSocket connections fail with 400 Bad Request.
---
### Timeouts
**Default timeouts**:
```nginx
proxy_read_timeout 300s; # 5 minutes
proxy_connect_timeout 75s; # 75 seconds
```
**Media API timeouts** (video uploads):
```nginx
proxy_read_timeout 3600s; # 1 hour
proxy_connect_timeout 75s;
```
**Why longer**: FFprobe video analysis + large file uploads take time.
---
### Upload Size Limits
**Global default** (`nginx.conf`):
```nginx
client_max_body_size 50m;
```
**Per-location overrides**:
- **Media API**: `client_max_body_size 10G;` (video uploads)
- **Gitea**: `client_max_body_size 2048M;` (large git pushes)
---
### Request Buffering
**Media API** (disable buffering for streaming uploads):
```nginx
proxy_request_buffering off;
```
**Effect**: Nginx streams request body directly to backend (no temp file).
**Benefits**:
- Lower disk I/O on Nginx server
- Faster upload start time
- Reduced memory usage
**Trade-off**: Backend must handle slow clients (Fastify multipart does this).
---
## SSL/TLS Configuration
### Certificate Paths
**Recommended structure**:
```
/etc/letsencrypt/live/cmlite.org/
├─ fullchain.pem (certificate + intermediate)
├─ privkey.pem (private key)
└─ chain.pem (intermediate CA)
```
**Nginx SSL block**:
```nginx
server {
listen 443 ssl http2;
server_name api.cmlite.org;
ssl_certificate /etc/letsencrypt/live/cmlite.org/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/cmlite.org/privkey.pem;
# Strong TLS configuration
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers 'ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384';
ssl_prefer_server_ciphers on;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 10m;
# ... location blocks
}
```
---
### HTTP to HTTPS Redirect
```nginx
server {
listen 80;
server_name api.cmlite.org;
# Redirect all HTTP to HTTPS
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
server_name api.cmlite.org;
# ... SSL config + locations
}
```
---
### HSTS Header
**Already applied globally** (in `nginx.conf`):
```nginx
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
```
**Effect**: Browser caches HTTPS requirement for 1 year.
**Important**: Only enable after verifying HTTPS works (can't easily undo).
---
### Wildcard Certificates
**For `*.cmlite.org`** (Let's Encrypt DNS challenge):
```bash
certbot certonly --dns-cloudflare \
--dns-cloudflare-credentials ~/.secrets/cloudflare.ini \
-d cmlite.org -d "*.cmlite.org"
```
**Single cert** covers all subdomains:
- api.cmlite.org
- app.cmlite.org
- db.cmlite.org
- etc.
See [SSL/TLS](ssl-tls.md) for complete certificate management.
---
## Static File Serving
### Admin GUI Production Build
**Dockerfile multi-stage build** (admin/Dockerfile):
```dockerfile
# Build stage
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Production stage
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/nginx.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
```
**Nginx serves static files** (no Node.js in production):
```nginx
server {
listen 80;
server_name app.cmlite.org;
root /usr/share/nginx/html;
index index.html;
# React Router support (all routes → index.html)
location / {
try_files $uri $uri/ /index.html;
}
# API proxy
location /api/ {
proxy_pass http://changemaker-v2-api:4000;
# ... proxy headers
}
# Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}
```
---
### MkDocs Static Site
**Build process** (via admin GUI or CLI):
```bash
docker compose exec mkdocs mkdocs build
```
**Output**: `mkdocs/site/` directory with static HTML
**Served by** `mkdocs-site-server` (Nginx Alpine container):
```yaml
mkdocs-site-server:
image: lscr.io/linuxserver/nginx:latest
volumes:
- ./mkdocs/site:/config/www
ports:
- "4004:80"
```
**Nginx config** (in `configs/mkdocs-site/default.conf`):
```nginx
server {
listen 80;
root /config/www;
index index.html;
location / {
try_files $uri $uri/ =404;
}
}
```
---
## Performance Optimization
### Gzip Compression
**Already enabled globally** (see nginx.conf above).
**Compression ratio**:
- JSON responses: ~75% reduction
- HTML/CSS/JS: ~60-70% reduction
- Images/video: No compression (already compressed)
**Trade-off**: Slight CPU increase (~5-10%) for bandwidth savings.
---
### Caching Static Assets
```nginx
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
```
**Effect**: Browsers cache static assets for 1 year.
**Caveat**: Use content hashing in filenames (Vite does this automatically).
---
### Proxy Caching
**Optional** (not enabled by default):
```nginx
# In http block
proxy_cache_path /var/cache/nginx levels=1:2 keys_zone=api_cache:10m max_size=1g inactive=60m;
# In location block
location /api/campaigns {
proxy_cache api_cache;
proxy_cache_valid 200 10m;
proxy_cache_key "$scheme$request_method$host$request_uri";
proxy_pass http://changemaker-v2-api:4000;
}
```
**Use cases**:
- Public campaign listing (10-minute cache)
- Public map data (5-minute cache)
- Representative lookup (1-hour cache)
**Avoid caching**:
- Authenticated endpoints
- POST/PUT/DELETE requests
- Real-time data (canvass sessions, email queue)
---
### Connection Pooling
**Keep-alive to backends**:
```nginx
upstream api {
server changemaker-v2-api:4000;
keepalive 32; # Maintain 32 idle connections
}
location /api/ {
proxy_pass http://api;
proxy_http_version 1.1;
proxy_set_header Connection ""; # Clear close header
}
```
**Benefits**:
- Reduced latency (no TCP handshake)
- Lower CPU (fewer connection setups)
- Better throughput under load
---
## Troubleshooting
### 502 Bad Gateway
**Symptoms**: `502 Bad Gateway` error
**Causes**:
1. Backend container not running
2. Backend healthcheck failing
3. Backend listening on wrong port
4. Network connectivity issue
**Diagnosis**:
```bash
# Check backend status
docker compose ps api
# Check backend logs
docker compose logs --tail=50 api
# Test backend directly
docker compose exec nginx curl http://changemaker-v2-api:4000/api/health
# Check Nginx error log
docker compose exec nginx cat /var/log/nginx/error.log
```
**Solution**:
```bash
# Restart backend
docker compose restart api
# Check healthcheck
docker inspect changemaker-v2-api | jq '.[0].State.Health'
# Verify port in docker-compose.yml
grep -A5 "api:" docker-compose.yml
```
---
### 504 Gateway Timeout
**Symptoms**: Request times out after 60 seconds
**Cause**: Backend processing too slow, proxy timeout too short
**Solution**:
```nginx
# Increase timeout for slow endpoints
location /api/locations/geocode {
proxy_read_timeout 300s; # 5 minutes
proxy_pass http://changemaker-v2-api:4000;
}
```
---
### SSL Certificate Errors
**Symptoms**: `SSL_ERROR_RX_RECORD_TOO_LONG` or `ERR_SSL_PROTOCOL_ERROR`
**Cause**: Accessing HTTPS port via HTTP or vice versa
**Diagnosis**:
```bash
# Test HTTPS
curl -I https://api.cmlite.org
# Check certificate
openssl s_client -connect api.cmlite.org:443 -servername api.cmlite.org
# Verify Nginx config
docker compose exec nginx nginx -t
```
**Solution**:
```bash
# Reload Nginx after cert renewal
docker compose exec nginx nginx -s reload
# Check cert paths in config
grep ssl_certificate /path/to/nginx/conf.d/*.conf
```
---
### CORS Errors
**Symptoms**: Browser console shows `CORS policy: No 'Access-Control-Allow-Origin' header`
**Cause**: Backend not setting CORS headers
**Diagnosis**:
```bash
# Test from browser console
fetch('http://api.cmlite.org/api/campaigns')
# Check response headers
curl -H "Origin: http://example.com" -I http://api.cmlite.org/api/campaigns
```
**Solution**: CORS headers set by **backend** (not Nginx). Check `api/src/server.ts`:
```typescript
app.use(cors({
origin: process.env.CORS_ORIGINS?.split(',') || ['http://localhost:3000'],
credentials: true,
}));
```
**Nginx passthrough** (don't modify CORS headers):
```nginx
# DO NOT add these in Nginx (backend handles CORS)
# add_header Access-Control-Allow-Origin "*"; # ❌ WRONG
```
---
### WebSocket Connection Failures
**Symptoms**: WebSocket upgrade fails with `400 Bad Request`
**Cause**: Missing `Upgrade`/`Connection` headers
**Diagnosis**:
```bash
# Check Nginx config
grep -A5 "Upgrade" nginx/conf.d/services.conf
# Test WebSocket
wscat -c ws://localhost:5678
```
**Solution**:
```nginx
# Add to location block
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
```
---
### Large Upload Failures
**Symptoms**: Upload fails with `413 Request Entity Too Large`
**Cause**: `client_max_body_size` too small
**Solution**:
```nginx
# Increase limit for specific location
location /api/media/videos {
client_max_body_size 10G;
proxy_pass http://changemaker-media-api:4100;
}
```
---
### Iframe Not Displaying
**Symptoms**: Service loads in new tab but not in iframe
**Cause**: `X-Frame-Options: DENY` or CSP `frame-ancestors` blocking
**Diagnosis**:
```bash
# Check response headers
curl -I http://db.cmlite.org
# Look for X-Frame-Options or Content-Security-Policy
```
**Solution**:
```nginx
# Hide backend's X-Frame-Options
proxy_hide_header X-Frame-Options;
# Add CSP allowing admin
add_header Content-Security-Policy "frame-ancestors 'self' app.cmlite.org" always;
```
---
### Nginx Won't Start
**Symptoms**: `docker compose up` fails with Nginx error
**Diagnosis**:
```bash
# Test config syntax
docker compose run --rm nginx nginx -t
# Check for duplicate server_name
grep server_name nginx/conf.d/*.conf | sort
# Check for port conflicts
docker compose config | grep -A2 "ports:"
```
**Common mistakes**:
- Missing semicolon
- Duplicate `server_name` (same subdomain in multiple files)
- Invalid regex in `location`
- Unclosed `{` bracket
---
## Production Best Practices
### Rate Limiting
**Limit requests per IP** (prevents abuse):
```nginx
# In http block
limit_req_zone $binary_remote_addr zone=api_limit:10m rate=10r/s;
# In location block
location /api/ {
limit_req zone=api_limit burst=20 nodelay;
proxy_pass http://changemaker-v2-api:4000;
}
```
**Explanation**:
- `rate=10r/s`: 10 requests per second average
- `burst=20`: Allow bursts up to 20 requests
- `nodelay`: Process burst immediately (don't queue)
---
### Security Headers Review
**Production checklist**:
- [x] HSTS enabled (`max-age=31536000`)
- [x] `X-Content-Type-Options: nosniff`
- [x] `X-XSS-Protection: 1; mode=block`
- [x] CSP `frame-ancestors` for embeddable services
- [x] `X-Frame-Options: SAMEORIGIN` for non-embedded services
- [x] `Referrer-Policy: strict-origin-when-cross-origin`
- [x] `Permissions-Policy` restricts sensors
**Optional enhancements**:
```nginx
# Stricter CSP
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline';" always;
# Expect-CT (certificate transparency)
add_header Expect-CT "max-age=86400, enforce" always;
```
---
### Access Logging
**Production log format** (JSON for parsing):
```nginx
log_format json_combined escape=json
'{'
'"time_local":"$time_local",'
'"remote_addr":"$remote_addr",'
'"request":"$request",'
'"status": $status,'
'"body_bytes_sent":$body_bytes_sent,'
'"request_time":$request_time,'
'"http_referrer":"$http_referer",'
'"http_user_agent":"$http_user_agent"'
'}';
access_log /var/log/nginx/access.log json_combined;
```
**Benefits**: Easy parsing with tools like `jq`, Logstash, Loki.
---
### Error Page Customization
**Custom error pages**:
```nginx
error_page 404 /404.html;
error_page 500 502 503 504 /50x.html;
location = /404.html {
root /usr/share/nginx/html;
internal;
}
location = /50x.html {
root /usr/share/nginx/html;
internal;
}
```
**Create files**:
```bash
cat > nginx/html/404.html <<EOF
<!DOCTYPE html>
<html>
<head><title>404 Not Found</title></head>
<body>
<h1>404 - Page Not Found</h1>
<p>Return to <a href="/">homepage</a></p>
</body>
</html>
EOF
```
---
## Related Documentation
- **[Docker Compose](docker-compose.md)** — Container orchestration
- **[Environment Variables](environment-variables.md)** — Configuration reference
- **[SSL/TLS](ssl-tls.md)** — Certificate management
- **[Tunneling](tunneling.md)** — Pangolin tunnel setup
- **[Scaling](scaling.md)** — Load balancing strategies