Web applications, APIs, and dashboards run internally on various ports and servers. Exposing them directly to the internet is a security risk — open ports, missing TLS encryption, and no centralized access control. An Nginx reverse proxy solves these problems: It bundles all services behind a single entry point, terminates TLS, sets security headers, and limits access.
What Is a Reverse Proxy?
A reverse proxy receives incoming client requests and forwards them to internal backend servers. The client communicates only with the proxy — the backend servers are not directly reachable.
Internet → Nginx (Port 443) → Backend 1 (10.0.20.10:3000)
→ Backend 2 (10.0.20.11:8080)
→ Backend 3 (10.0.20.12:9090)
Benefits
- TLS termination: One certificate for all services, centrally managed
- Centralized access control: Rate limiting, IP filtering, basic auth
- Security headers: HSTS, CSP, X-Frame-Options configured in one place
- Load balancing: Distribute traffic across multiple backend servers
- Caching: Cache static content, reduce backend load
- Logging: Centralized access and error logs
Installing and Configuring Nginx
Installation
# Debian/Ubuntu
apt update && apt install nginx -y
# Start and enable Nginx
systemctl enable --now nginx
# Check status
systemctl status nginx
Configuration Structure
Nginx uses a hierarchical configuration:
/etc/nginx/
├── nginx.conf # Main configuration
├── sites-available/ # Available server blocks
│ ├── default
│ ├── grafana.example.com
│ └── nextcloud.example.com
├── sites-enabled/ # Active server blocks (symlinks)
│ ├── grafana.example.com -> ../sites-available/grafana.example.com
│ └── nextcloud.example.com -> ../sites-available/nextcloud.example.com
├── snippets/ # Reusable configuration blocks
│ ├── ssl-params.conf
│ ├── security-headers.conf
│ └── proxy-params.conf
└── conf.d/ # Additional configurations
Global Nginx Optimization
Optimize base settings in /etc/nginx/nginx.conf:
worker_processes auto;
worker_rlimit_nofile 65535;
events {
worker_connections 4096;
multi_accept on;
use epoll;
}
http {
# Base settings
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
types_hash_max_size 2048;
server_tokens off; # Do not expose Nginx version
# Logging
log_format main '$remote_addr - $remote_user [$time_local] '
'"$request" $status $body_bytes_sent '
'"$http_referer" "$http_user_agent" '
'$upstream_response_time';
access_log /var/log/nginx/access.log main;
error_log /var/log/nginx/error.log warn;
# Gzip
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;
include /etc/nginx/conf.d/*.conf;
include /etc/nginx/sites-enabled/*;
}
Server Blocks and proxy_pass
Simple Reverse Proxy
Example: Make Grafana (running internally on port 3000) accessible via grafana.example.com:
# /etc/nginx/sites-available/grafana.example.com
server {
listen 80;
server_name grafana.example.com;
# HTTP → HTTPS redirect
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
server_name grafana.example.com;
ssl_certificate /etc/letsencrypt/live/grafana.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/grafana.example.com/privkey.pem;
include snippets/ssl-params.conf;
include snippets/security-headers.conf;
location / {
proxy_pass http://10.0.20.10:3000;
include snippets/proxy-params.conf;
}
}
Enable the site:
ln -s /etc/nginx/sites-available/grafana.example.com /etc/nginx/sites-enabled/
nginx -t && systemctl reload nginx
Proxy Parameters as Snippet
Create /etc/nginx/snippets/proxy-params.conf for reusable proxy settings:
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;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port $server_port;
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
proxy_buffering on;
proxy_buffer_size 4k;
proxy_buffers 8 4k;
These headers are critical:
- X-Real-IP: The actual client IP (not the proxy IP)
- X-Forwarded-For: Chain of all proxies in the path
- X-Forwarded-Proto: Whether the client is using HTTPS or HTTP
- Host: The original hostname of the request
Without these headers, the backend only sees the Nginx server’s IP and does not know whether the client is using TLS.
SSL Termination with Let’s Encrypt
Install Certbot
apt install certbot python3-certbot-nginx -y
Request Certificate
# Automatic with Nginx plugin
certbot --nginx -d grafana.example.com
# Or manual (standalone)
certbot certonly --standalone -d grafana.example.com
# Wildcard certificate (DNS challenge)
certbot certonly --manual --preferred-challenges dns -d '*.example.com'
SSL Parameters as Snippet
Create /etc/nginx/snippets/ssl-params.conf:
# Modern 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 off;
# SSL session cache
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
ssl_session_tickets off;
# OCSP stapling
ssl_stapling on;
ssl_stapling_verify on;
resolver 1.1.1.1 9.9.9.9 valid=300s;
# DH parameters (generate once: openssl dhparam -out /etc/nginx/dhparam.pem 4096)
ssl_dhparam /etc/nginx/dhparam.pem;
Automatic Renewal
# Check certbot timer
systemctl status certbot.timer
# Manual renewal test
certbot renew --dry-run
Certbot automatically renews certificates 30 days before expiration and reloads Nginx via post-hook:
# /etc/letsencrypt/renewal-hooks/post/reload-nginx.sh
#!/bin/bash
systemctl reload nginx
WebSocket Support
Many modern applications use WebSockets (Grafana, Proxmox Console, Nextcloud Talk). WebSockets require special proxy configuration:
# /etc/nginx/sites-available/proxmox.example.com
server {
listen 443 ssl http2;
server_name proxmox.example.com;
ssl_certificate /etc/letsencrypt/live/proxmox.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/proxmox.example.com/privkey.pem;
include snippets/ssl-params.conf;
location / {
proxy_pass https://10.0.20.5:8006;
include snippets/proxy-params.conf;
# WebSocket support
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
# Longer timeouts for VNC/console
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
}
The two critical lines for WebSockets are:
proxy_set_header Upgrade $http_upgrade;— signals the protocol upgrade to the backendproxy_set_header Connection "upgrade";— keeps the connection open for WebSocket
Rate Limiting
Rate limiting protects against brute-force attacks, DDoS, and excessive API usage.
Define Rate Limit Zones
In /etc/nginx/nginx.conf (within the http block):
# 10 requests/second per IP, 10 MB storage for tracking
limit_req_zone $binary_remote_addr zone=general:10m rate=10r/s;
# Stricter limit for login pages
limit_req_zone $binary_remote_addr zone=login:10m rate=3r/s;
# API limit
limit_req_zone $binary_remote_addr zone=api:10m rate=30r/s;
Apply Rate Limiting
server {
listen 443 ssl http2;
server_name app.example.com;
# General rate limiting
location / {
limit_req zone=general burst=20 nodelay;
proxy_pass http://10.0.20.15:8080;
include snippets/proxy-params.conf;
}
# Strict limit for login
location /login {
limit_req zone=login burst=5 nodelay;
proxy_pass http://10.0.20.15:8080;
include snippets/proxy-params.conf;
}
# API with separate limit
location /api/ {
limit_req zone=api burst=50 nodelay;
proxy_pass http://10.0.20.15:8080;
include snippets/proxy-params.conf;
}
}
Parameters:
- rate=10r/s: Maximum requests per second
- burst=20: Temporarily allows 20 additional requests (processed with delay)
- nodelay: Process burst requests immediately instead of delaying
Security Headers
Create /etc/nginx/snippets/security-headers.conf:
# HSTS: Browser should only use HTTPS (2 years)
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
# Clickjacking protection
add_header X-Frame-Options "SAMEORIGIN" always;
# XSS protection
add_header X-Content-Type-Options "nosniff" always;
# Referrer policy
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# Permissions policy
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
# Content Security Policy (adjust per application)
# add_header Content-Security-Policy "default-src 'self'; script-src 'self'" always;
The Content-Security-Policy is commented out because it must be individually adjusted for each application. An overly restrictive CSP can break web application functionality.
Testing Headers
# Header check with curl
curl -I https://grafana.example.com
# Or online: securityheaders.com
Multiple Services on One Server
A typical setup with several internal services:
# Grafana
server {
listen 443 ssl http2;
server_name grafana.example.com;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
include snippets/ssl-params.conf;
include snippets/security-headers.conf;
location / {
proxy_pass http://10.0.20.10:3000;
include snippets/proxy-params.conf;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}
# Nextcloud
server {
listen 443 ssl http2;
server_name cloud.example.com;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
include snippets/ssl-params.conf;
include snippets/security-headers.conf;
client_max_body_size 10G; # Allow large uploads
location / {
proxy_pass http://10.0.20.11:80;
include snippets/proxy-params.conf;
}
}
# Proxmox VE
server {
listen 443 ssl http2;
server_name pve.example.com;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
include snippets/ssl-params.conf;
location / {
proxy_pass https://10.0.20.5:8006;
include snippets/proxy-params.conf;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 3600s;
}
}
With a wildcard certificate (*.example.com), a single certificate covers all subdomains.
Troubleshooting
Common Errors
| Symptom | Cause | Solution |
|---|---|---|
| 502 Bad Gateway | Backend unreachable | Check backend service, firewall rules |
| 504 Gateway Timeout | Backend responds too slowly | Increase proxy_read_timeout |
| 413 Request Entity Too Large | Upload too large | Increase client_max_body_size |
| WebSocket connection drops | Upgrade headers missing | Set Upgrade and Connection headers |
| Backend sees only proxy IP | Header forwarding missing | Set X-Real-IP and X-Forwarded-For |
| Mixed content warning | Backend generates HTTP links | Set X-Forwarded-Proto https |
Testing Configuration
# Check syntax
nginx -t
# Enable detailed debug logging
error_log /var/log/nginx/error.log debug;
# Log upstream responses
log_format upstream '$remote_addr - $upstream_addr - $upstream_status - $upstream_response_time';
Conclusion
Nginx as a reverse proxy is the standard for securely publishing internal services. TLS termination with Let’s Encrypt, WebSocket support, rate limiting, and security headers can be implemented with just a few configuration lines. The snippet-based configuration with reusable blocks keeps the configuration DRY and maintainable. Anyone exposing internal services to the internet should never do so without a reverse proxy.
More on these topics:
More articles
Backup Strategy for SMBs: Proxmox PBS + TrueNAS as a Reliable Backup Solution
Backup strategy for SMBs with Proxmox PBS and TrueNAS: implement the 3-2-1 rule, PBS as primary backup target, TrueNAS replication as offsite copy, retention policies, and automated restore tests.
OPNsense Suricata Custom Rules: Write and Optimize Your Own IDS/IPS Signatures
Suricata custom rules on OPNsense: rule syntax, custom signatures for internal services, performance tuning, suppress lists, and EVE JSON logging.
Systemd Security: Hardening and Securing Linux Services
Systemd security hardening: unit hardening with ProtectSystem, PrivateTmp, NoNewPrivileges, CapabilityBoundingSet, systemd-analyze security, sandboxing, resource limits, and creating custom timers.