# 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