How to Set Up a Reverse Proxy with Nginx for Self-Hosted Apps
Step-by-step guide to configuring Nginx as a reverse proxy for self-hosted applications like Grafana, Portainer, and Nextcloud — with SSL, load balancing, and security hardening.
What Is a Reverse Proxy and Why Use One?
A reverse proxy sits between the internet and your backend services. Instead of exposing each application on a different port (:3000, :8080, :9090), you route everything through a single entry point — port 443 with SSL.
Benefits:
- Single SSL certificate for all your apps
- Clean URLs —
grafana.example.cominstead of192.168.1.50:3000 - Security layer — hide backend IPs, add rate limiting, block bad bots
- Load balancing — distribute traffic across multiple servers
- Centralized logging — one place to monitor all HTTP traffic
Prerequisites
- A Linux server (Ubuntu 22.04/24.04 or Debian 12)
- A domain name pointing to your server's public IP
- One or more backend applications running on local ports
- Root or sudo access
Step 1: Install Nginx
# Ubuntu/Debian
sudo apt update && sudo apt install nginx -y
# Verify installation
nginx -v
# Start and enable
sudo systemctl enable --now nginxVerify it's running:
curl -I http://localhost
# Should return: HTTP/1.1 200 OKStep 2: Basic Reverse Proxy Configuration
Let's say you have Grafana running on port 3000. Create a new site config:
sudo nano /etc/nginx/sites-available/grafana.example.comserver {
listen 80;
server_name grafana.example.com;
location / {
proxy_pass http://127.0.0.1:3000;
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;
}
}Enable the site:
sudo ln -s /etc/nginx/sites-available/grafana.example.com /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginxWhat Each Header Does
| Header | Purpose |
|--------|---------|
| Host | Passes the original domain to the backend |
| X-Real-IP | Sends the client's real IP address |
| X-Forwarded-For | Chain of all proxies the request passed through |
| X-Forwarded-Proto | Tells the backend if the original request was HTTP or HTTPS |
Step 3: Add SSL with Let's Encrypt
Install Certbot:
sudo apt install certbot python3-certbot-nginx -yGenerate and auto-configure SSL:
sudo certbot --nginx -d grafana.example.comCertbot automatically:
- Obtains a free SSL certificate
- Modifies your Nginx config to use HTTPS
- Adds a redirect from HTTP to HTTPS
- Sets up auto-renewal via systemd timer
Verify auto-renewal:
sudo certbot renew --dry-runYour config now looks like this (auto-modified by Certbot):
server {
listen 443 ssl;
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 /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
location / {
proxy_pass http://127.0.0.1:3000;
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;
}
}
server {
listen 80;
server_name grafana.example.com;
return 301 https://$host$request_uri;
}Step 4: Proxy Multiple Applications
Here's a complete setup proxying Grafana, Portainer, and Nextcloud on different subdomains:
# /etc/nginx/sites-available/grafana.example.com
server {
listen 443 ssl;
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;
location / {
proxy_pass http://127.0.0.1:3000;
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;
}
}# /etc/nginx/sites-available/portainer.example.com
server {
listen 443 ssl;
server_name portainer.example.com;
ssl_certificate /etc/letsencrypt/live/portainer.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/portainer.example.com/privkey.pem;
location / {
proxy_pass http://127.0.0.1:9000;
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;
}
}# /etc/nginx/sites-available/cloud.example.com
server {
listen 443 ssl;
server_name cloud.example.com;
ssl_certificate /etc/letsencrypt/live/cloud.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/cloud.example.com/privkey.pem;
client_max_body_size 10G;
location / {
proxy_pass http://127.0.0.1:8080;
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;
}
}Generate certificates for all domains at once:
sudo certbot --nginx -d grafana.example.com -d portainer.example.com -d cloud.example.comStep 5: WebSocket Support
Apps like Grafana Live, Portainer, and chat applications use WebSockets. Add WebSocket upgrade headers:
location / {
proxy_pass http://127.0.0.1:3000;
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_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}Without these headers, WebSocket connections will fail with 400 Bad Request.
Step 6: Security Hardening
Add Security Headers
server {
# ... existing config ...
# Security headers
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 Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
}Rate Limiting
Prevent brute-force attacks on login pages:
# Define rate limit zone (in http block or top of config)
limit_req_zone $binary_remote_addr zone=login:10m rate=5r/s;
server {
# ... existing config ...
# Apply rate limit to login endpoints
location /login {
limit_req zone=login burst=10 nodelay;
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}Block Common Exploits
# Block access to hidden files
location ~ /\. {
deny all;
return 404;
}
# Block common attack patterns
location ~* (\.php|\.asp|\.aspx|\.jsp|\.cgi)$ {
deny all;
return 404;
}IP Whitelisting (for admin panels)
location /admin {
allow 192.168.1.0/24;
allow 10.0.0.0/8;
deny all;
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}Step 7: Load Balancing
Distribute traffic across multiple backend servers:
upstream grafana_backends {
least_conn;
server 192.168.1.10:3000;
server 192.168.1.11:3000;
server 192.168.1.12:3000 backup;
}
server {
listen 443 ssl;
server_name grafana.example.com;
location / {
proxy_pass http://grafana_backends;
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;
}
}Load Balancing Methods
| Method | Directive | Use Case |
|--------|-----------|----------|
| Round Robin | (default) | Equal-capacity servers |
| Least Connections | least_conn | Varying request durations |
| IP Hash | ip_hash | Session persistence needed |
| Weighted | weight=3 | Servers with different capacity |
Health Checks
upstream grafana_backends {
server 192.168.1.10:3000 max_fails=3 fail_timeout=30s;
server 192.168.1.11:3000 max_fails=3 fail_timeout=30s;
}Nginx automatically removes a server from the pool after 3 failed attempts and retries after 30 seconds.
Step 8: Performance Tuning
Enable Gzip Compression
# In /etc/nginx/nginx.conf (http block)
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml text/javascript image/svg+xml;Proxy Buffering
location / {
proxy_pass http://127.0.0.1:3000;
proxy_buffering on;
proxy_buffer_size 4k;
proxy_buffers 8 4k;
proxy_busy_buffers_size 8k;
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
}Static File Caching
location ~* \.(css|js|png|jpg|jpeg|gif|ico|svg|woff2)$ {
expires 30d;
add_header Cache-Control "public, immutable";
}Complete Production Config Template
Here's a battle-tested config you can copy and adapt:
server {
listen 443 ssl http2;
server_name app.example.com;
ssl_certificate /etc/letsencrypt/live/app.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/app.example.com/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# Logging
access_log /var/log/nginx/app.example.com.access.log;
error_log /var/log/nginx/app.example.com.error.log;
# Max upload size
client_max_body_size 50M;
location / {
proxy_pass http://127.0.0.1:3000;
# 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_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
# Timeouts
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
}
# Block hidden files
location ~ /\. {
deny all;
return 404;
}
}
# HTTP to HTTPS redirect
server {
listen 80;
server_name app.example.com;
return 301 https://$host$request_uri;
}Troubleshooting
502 Bad Gateway
The backend application isn't running or isn't listening on the expected port:
# Check if the backend is running
ss -tlnp | grep 3000
# Check Nginx error log
sudo tail -f /var/log/nginx/error.log504 Gateway Timeout
The backend is too slow to respond. Increase timeouts:
proxy_connect_timeout 120s;
proxy_send_timeout 120s;
proxy_read_timeout 120s;SSL Certificate Issues
# Test certificate validity
sudo certbot certificates
# Force renewal
sudo certbot renew --force-renewal
# Check certificate expiry
echo | openssl s_client -connect app.example.com:443 2>/dev/null | openssl x509 -noout -datesPermission Denied Errors
# Check Nginx is running as the right user
ps aux | grep nginx
# Fix socket permissions if using Unix sockets
sudo chmod 666 /var/run/app.sockNginx vs Alternatives
| Feature | Nginx | Caddy | Traefik | HAProxy | |---------|-------|-------|---------|---------| | Auto SSL | With Certbot | Built-in | Built-in | No | | Config style | File-based | Caddyfile | Labels/YAML | File-based | | Docker integration | Manual | Good | Excellent | Manual | | Performance | Excellent | Good | Good | Excellent | | Learning curve | Medium | Low | Medium | High | | Best for | General purpose | Simplicity | Docker/K8s | High traffic |
Nginx remains the most widely deployed reverse proxy — powering over 30% of all websites. Its flexibility, performance, and massive community make it the safe default choice.