# 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
Subdomain Router}
end
subgraph "Backend Services (Docker Network)"
API[api:4000
Express]
MEDIA[media-api:4100
Fastify]
ADMIN[admin:3000
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
{/* NocoDB */}
{/* n8n */}
{/* Gitea */}
{/* MailHog */}
{/* 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 <
404 Not Found
404 - Page Not Found
Return to homepage
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