Skip to main content
The stack uses nginx as a reverse proxy to handle SSL/TLS termination, route traffic, and provide security features like rate limiting and security headers.

Configuration Files

Two nginx configurations are provided:

nginx.dev.conf

Development configuration with HTTP on port 8080

nginx.conf

Production configuration with HTTPS, rate limiting, and security headers

Development Configuration

The development configuration (nginx.dev.conf) is optimized for local testing:
  • HTTP only (no SSL/TLS)
  • Listens on port 8080
  • Simplified logging
  • No rate limiting
  • WebSocket support enabled

Usage

Create a development override:
cp docker-compose.override.example.yml docker-compose.override.yml
docker compose up -d
Access points:
  • Headscale: http://localhost:8000
  • Headplane UI: http://localhost:8000/admin
  • Health check: http://localhost:8000/health
  • Metrics: http://localhost:8000/metrics

Production Configuration

The production configuration (nginx.conf) includes enterprise-grade security:
  • HTTPS with TLS 1.2/1.3
  • HTTP to HTTPS redirect
  • Rate limiting (3-tier strategy)
  • Security headers (HSTS, CSP, etc.)
  • OCSP stapling
  • HTTP/2 support
  • DDoS protection

Worker Process Settings

worker_processes
string
default:"auto"
Number of worker processes. Set to auto to use number of CPU cores.
worker_processes auto;
worker_rlimit_nofile
integer
default:65535
Maximum number of open file descriptors per worker (production only).
worker_rlimit_nofile 65535;
worker_connections
integer
default:4096
Maximum number of simultaneous connections per worker.Development: 2048Production: 4096
events {
  worker_connections 4096;
  use epoll;
  multi_accept on;
}

Upstream Configuration

Headscale Backend

upstream headscale_backend {
  server headscale:8080 max_fails=3 fail_timeout=30s;
  keepalive 32;
  keepalive_requests 100;
  keepalive_timeout 60s;
}
max_fails
integer
default:3
Number of failed attempts before marking backend as down.
fail_timeout
duration
default:"30s"
Time to wait before retrying a failed backend.
keepalive
integer
default:32
Number of idle keepalive connections to maintain for reuse.

Headplane Backend

upstream headplane_backend {
  server headplane:3000 max_fails=3 fail_timeout=30s;
  keepalive 16;
}

Proxy Buffering

proxy_buffering
string
default:"on"
Enable buffering of responses from proxied servers.
proxy_buffering on;
proxy_buffer_size 8k;
proxy_buffers 16 8k;
proxy_busy_buffers_size 16k;
Buffering is enabled for Headscale traffic but disabled for Headplane UI for better interactivity.

Proxy Timeouts

proxy_connect_timeout 90s;
proxy_send_timeout 90s;
proxy_read_timeout 90s;

SSL/TLS Configuration (Production)

Protocols and Ciphers

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:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305';
ssl_prefer_server_ciphers off;
Only TLS 1.2 and 1.3 are enabled. TLS 1.0 and 1.1 are deprecated and insecure.

Session Cache

ssl_session_cache shared:SSL:10m;
ssl_session_timeout 10m;
ssl_session_tickets off;

OCSP Stapling

ssl_stapling on;
ssl_stapling_verify on;
resolver 1.1.1.1 1.0.0.1 valid=300s;
resolver_timeout 5s;

Certificate Paths

ssl_certificate /etc/letsencrypt/live/${DOMAIN}/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/${DOMAIN}/privkey.pem;
ssl_trusted_certificate /etc/letsencrypt/live/${DOMAIN}/chain.pem;

Rate Limiting (Production)

Rate Limit Zones

# General traffic: 10 requests/second
limit_req_zone $binary_remote_addr zone=general:10m rate=10r/s;

# API traffic: 30 requests/second
limit_req_zone $binary_remote_addr zone=api:10m rate=30r/s;

# Health checks: 100 requests/second
limit_req_zone $binary_remote_addr zone=health:10m rate=100r/s;

Connection Limits

# Limit connections per IP
limit_conn_zone $binary_remote_addr zone=addr:10m;

server {
  limit_conn addr 10;
}

Applying Rate Limits

location / {
  limit_req zone=general burst=20 nodelay;
  # ...
}

location /api/ {
  limit_req zone=api burst=50 nodelay;
  # ...
}

location = /health {
  limit_req zone=health burst=20 nodelay;
  # ...
}

Security Headers

add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
add_header X-Frame-Options "SAMEORIGIN" always;
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 Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline';" always;
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;
Strict-Transport-Security
string
Forces browsers to use HTTPS for 1 year, including all subdomains.
X-Frame-Options
string
Prevents clickjacking by disallowing the site to be framed.
X-Content-Type-Options
string
Prevents MIME type sniffing.
Content-Security-Policy
string
Controls which resources the browser is allowed to load.

WebSocket Support

WebSocket support is critical for the Tailscale protocol to function properly.
# WebSocket upgrade mapping
map $http_upgrade $connection_upgrade {
  default upgrade;
  ''      close;
}

location / {
  proxy_set_header Upgrade $http_upgrade;
  proxy_set_header Connection $connection_upgrade;
  # ...
}

Location Blocks

Health Check Endpoint

location = /health {
  limit_req zone=health burst=20 nodelay;
  
  proxy_pass http://headscale_backend/health;
  proxy_http_version 1.1;
  proxy_set_header Connection "";
  proxy_set_header Host $host;
  
  # Fast fail for health checks
  proxy_connect_timeout 5s;
  proxy_read_timeout 5s;
  
  access_log off;
}

Metrics Endpoint

location = /metrics {
  # Restrict access to internal networks
  # allow 127.0.0.1;
  # allow 10.0.0.0/8;
  # deny all;
  
  limit_req zone=health burst=10 nodelay;
  
  proxy_pass http://headscale_backend/metrics;
  proxy_http_version 1.1;
  proxy_set_header Connection "";
  proxy_set_header Host $host;
  
  add_header Cache-Control "no-cache, no-store, must-revalidate";
}
Uncomment the IP restrictions in production to prevent unauthorized metric access.

Headplane Admin Interface

location /admin {
  limit_req zone=general burst=20 nodelay;
  
  proxy_pass http://headplane_backend;
  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;
  
  # WebSocket support
  proxy_set_header Upgrade $http_upgrade;
  proxy_set_header Connection $connection_upgrade;
  
  # Disable buffering for interactive UI
  proxy_buffering off;
}

API Endpoints

location /api/ {
  limit_req zone=api burst=50 nodelay;
  
  proxy_pass http://headscale_backend;
  proxy_http_version 1.1;
  
  # Essential 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;
  
  # WebSocket support
  proxy_set_header Upgrade $http_upgrade;
  proxy_set_header Connection $connection_upgrade;
  
  # Error handling
  proxy_next_upstream error timeout invalid_header http_502 http_503 http_504;
  proxy_next_upstream_tries 2;
  proxy_next_upstream_timeout 30s;
  
  # Request ID for tracing
  proxy_set_header X-Request-ID $request_id;
}

Main Headscale Proxy

location / {
  limit_req zone=general burst=20 nodelay;
  
  proxy_pass http://headscale_backend;
  proxy_http_version 1.1;
  
  # Essential 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;
  
  # WebSocket support (critical for Tailscale)
  proxy_set_header Upgrade $http_upgrade;
  proxy_set_header Connection $connection_upgrade;
  
  # Enable buffering
  proxy_buffering on;
  
  # Error handling
  proxy_next_upstream error timeout invalid_header http_502 http_503 http_504;
  proxy_next_upstream_tries 2;
  proxy_next_upstream_timeout 30s;
  
  # Request ID for tracing
  proxy_set_header X-Request-ID $request_id;
}

Compression

gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_min_length 1000;
gzip_disable "msie6";
gzip_types
  text/plain
  text/css
  text/xml
  text/javascript
  application/json
  application/javascript
  application/xml+rss;

Error Pages

error_page 502 503 504 /50x.html;
location = /50x.html {
  internal;
  add_header Content-Type text/plain always;
  return 503 "Service Temporarily Unavailable\nPlease try again in a few moments.\n";
}

error_page 429 /429.html;
location = /429.html {
  internal;
  add_header Content-Type text/plain always;
  return 429 "Too Many Requests\nPlease slow down and try again.\n";
}

Testing Configuration

Verify nginx configuration syntax:
docker exec nginx nginx -t
Reload configuration without downtime:
docker exec nginx nginx -s reload

Performance Tuning

For high-traffic deployments, consider:
worker_connections 8192;
worker_rlimit_nofile 100000;

keepalive_timeout 120;
keepalive_requests 1000;

proxy_buffers 32 8k;

Monitoring

View real-time logs:
# All logs
docker compose logs -f nginx

# Access logs only
docker exec nginx tail -f /var/log/nginx/access.log

# Error logs only
docker exec nginx tail -f /var/log/nginx/error.log

Additional Resources

nginx script reference

Management script for nginx operations

Production deployment

Deploy nginx with SSL/TLS in production

Build docs developers (and LLMs) love