The Problem
Your application server listens on port 3000. You need it accessible on port 80/443, with SSL termination, rate limiting, and the ability to route traffic to multiple backend services based on URL path. Running your application directly on port 80 means running as root, no SSL offloading, no buffering, and no graceful failover.
Nginx as a reverse proxy solves all of these problems while adding a resilient front layer to your infrastructure.
Installing Nginx
# Ubuntu/Debian
sudo apt update && sudo apt install nginx -y
# RHEL/CentOS/Amazon Linux
sudo yum install nginx -y
# Verify installation
nginx -v
sudo systemctl start nginx
sudo systemctl enable nginx
Confirm Nginx is running:
curl -I http://localhost
# Should return HTTP/1.1 200 OK with Server: nginx
Basic Reverse Proxy Configuration
The simplest reverse proxy forwards all traffic to a backend application:
# /etc/nginx/sites-available/myapp
server {
listen 80;
server_name app.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/myapp /etc/nginx/sites-enabled/
sudo nginx -t # Test configuration syntax
sudo systemctl reload nginx
Why those proxy headers matter
Host— Your backend needs the original hostname, not127.0.0.1X-Real-IP— The actual client IP (otherwise backend sees Nginx's IP)X-Forwarded-For— Chain of proxies the request passed throughX-Forwarded-Proto— Whether the client connected via HTTP or HTTPS
Without these headers, your application logs show all traffic coming from 127.0.0.1 and cannot determine if the original connection was secure.
SSL Termination with Let's Encrypt
In production, Nginx handles SSL so your backend doesn't have to:
# Install certbot
sudo apt install certbot python3-certbot-nginx -y
# Obtain certificate (Nginx plugin handles config automatically)
sudo certbot --nginx -d app.example.com
# Verify auto-renewal
sudo certbot renew --dry-run
After certbot runs, your config will look like:
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;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256;
ssl_prefer_server_ciphers off;
# HSTS — tell browsers to always use HTTPS
add_header Strict-Transport-Security "max-age=63072000" always;
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;
}
}
# Redirect HTTP to HTTPS
server {
listen 80;
server_name app.example.com;
return 301 https://$server_name$request_uri;
}
Load Balancing Multiple Backends
When running multiple instances of your application:
upstream app_backend {
# Round-robin by default
server 10.0.1.10:3000;
server 10.0.1.11:3000;
server 10.0.1.12:3000;
# Mark a server as backup (used only when others are down)
server 10.0.1.13:3000 backup;
# Health checks — remove server after 3 failures
server 10.0.1.10:3000 max_fails=3 fail_timeout=30s;
}
server {
listen 443 ssl http2;
server_name app.example.com;
location / {
proxy_pass http://app_backend;
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;
# Retry next upstream on failure
proxy_next_upstream error timeout http_502 http_503;
proxy_next_upstream_tries 2;
}
}
Load balancing algorithms
upstream app_backend {
# Least connections — sends to server with fewest active connections
least_conn;
server 10.0.1.10:3000;
server 10.0.1.11:3000;
}
upstream app_backend_sticky {
# IP hash — same client always hits same server (sticky sessions)
ip_hash;
server 10.0.1.10:3000;
server 10.0.1.11:3000;
}
upstream app_backend_weighted {
# Weighted — server with weight 3 gets 3x more traffic
server 10.0.1.10:3000 weight=3;
server 10.0.1.11:3000 weight=1;
}
Path-Based Routing to Different Services
Route traffic to different backends based on URL path — a common microservices pattern:
upstream api_service {
server 10.0.1.10:8080;
server 10.0.1.11:8080;
}
upstream frontend_service {
server 10.0.1.20:3000;
}
upstream auth_service {
server 10.0.1.30:4000;
}
server {
listen 443 ssl http2;
server_name app.example.com;
# API requests
location /api/ {
proxy_pass http://api_service/;
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;
}
# Authentication endpoints
location /auth/ {
proxy_pass http://auth_service/;
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;
}
# Everything else goes to frontend
location / {
proxy_pass http://frontend_service;
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;
}
}
Note the trailing slash in proxy_pass http://api_service/ — this strips the /api/ prefix before forwarding. Without it, the backend receives the full path including /api/.
WebSocket Proxying
WebSocket connections need special handling because they upgrade from HTTP:
location /ws/ {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
# Increase timeouts for long-lived connections
proxy_read_timeout 86400s;
proxy_send_timeout 86400s;
}
Without the Upgrade and Connection headers, the WebSocket handshake fails and clients get a 400 error.
Production Hardening
Buffering and timeouts
server {
# Buffer responses from backend
proxy_buffering on;
proxy_buffer_size 4k;
proxy_buffers 8 16k;
proxy_busy_buffers_size 32k;
# Timeouts — prevent hanging connections
proxy_connect_timeout 5s;
proxy_read_timeout 60s;
proxy_send_timeout 60s;
# Client body size — increase for file uploads
client_max_body_size 50m;
}
Rate limiting
# Define rate limit zone in http block
http {
limit_req_zone $binary_remote_addr zone=api_limit:10m rate=10r/s;
limit_req_zone $binary_remote_addr zone=login_limit:10m rate=1r/s;
}
server {
location /api/ {
limit_req zone=api_limit burst=20 nodelay;
proxy_pass http://api_service;
}
location /auth/login {
limit_req zone=login_limit burst=5;
proxy_pass http://auth_service;
}
}
Security headers
server {
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;
# Hide Nginx version
server_tokens off;
# Deny access to hidden files
location ~ /\. {
deny all;
return 404;
}
}
Testing and Debugging
# Test configuration syntax before reloading
sudo nginx -t
# Reload without dropping connections
sudo systemctl reload nginx
# Check error log for proxy issues
sudo tail -f /var/log/nginx/error.log
# Check access log for request patterns
sudo tail -f /var/log/nginx/access.log
# Test with curl — verbose shows headers
curl -vI https://app.example.com
# Test specific backend directly (bypass Nginx)
curl http://127.0.0.1:3000/health
Common error patterns in logs
# Backend not responding
connect() failed (111: Connection refused) while connecting to upstream
# Backend too slow
upstream timed out (110: Connection timed out) while reading response header
# Request body too large
client intended to send too large body
Common Mistakes
proxy_set_header Host $host — Backend receives Host: 127.0.0.1 instead of the real domain. This breaks virtual hosting and redirect generation.proxy_pass http://backend/ (with slash) strips the matched location prefix. proxy_pass http://backend (no slash) passes the full URI.client_max_body_size — Default is 1MB. File uploads fail with 413 Request Entity Too Large.http:// get an Nginx default page instead of being redirected.proxy_pass with variables without resolver — If you use variables in proxy_pass (e.g., proxy_pass http://$backend), you must set resolver 8.8.8.8 or Nginx cannot resolve the hostname.nginx -t before reload — A syntax error in any config file takes down all sites, not just the one you edited.Quick Reference
| Task | Directive |
|---|---|
| Basic proxy | <code class="inline-code">proxy_pass http://127.0.0.1:3000</code> |
| SSL termination | <code class="inline-code">listen 443 ssl http2</code> + cert paths |
| Load balance | <code class="inline-code">upstream name { server ip:port; }</code> |
| Sticky sessions | <code class="inline-code">ip_hash</code> in upstream block |
| WebSocket support | <code class="inline-code">proxy_set_header Upgrade $http_upgrade</code> |
| Rate limiting | <code class="inline-code">limit_req_zone</code> + <code class="inline-code">limit_req zone=name</code> |
| Max upload size | <code class="inline-code">client_max_body_size 50m</code> |
| Hide version | <code class="inline-code">server_tokens off</code> |
| Health check retry | <code class="inline-code">proxy_next_upstream error timeout</code> |
| Test config | <code class="inline-code">sudo nginx -t</code> |
Summary
Nginx as a reverse proxy gives you SSL termination, load balancing, path-based routing, rate limiting, and connection buffering — all without modifying your application code. Start with the basic proxy config, add SSL via certbot, then layer on load balancing and hardening as your traffic grows.
---
Frequently Asked Questions
What is a reverse proxy and why use NGINX for it?
A reverse proxy sits in front of backend servers, forwarding client requests and returning responses. NGINX is popular for this because it handles thousands of concurrent connections efficiently, provides load balancing, SSL termination, caching, and request routing. It reduces backend load and provides a single entry point for security and routing rules.
How do I configure NGINX as a reverse proxy?
Create a server block with location / { proxy_pass http://backend:8080; } to forward all requests to your backend. Add essential headers: proxy_set_header Host $host;, proxy_set_header X-Real-IP $remote_addr;, and proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;. Place the configuration in /etc/nginx/conf.d/ and reload with nginx -s reload.
Why is NGINX returning 502 Bad Gateway?
502 means NGINX cannot reach the upstream backend server. Common causes: backend process isn't running, wrong port in proxy_pass, firewall blocking the connection, or backend taking too long to respond. Check backend health, verify the proxy_pass URL, review NGINX error logs (/var/log/nginx/error.log), and increase proxy_read_timeout if the backend is slow.
How do I set up SSL/TLS with NGINX?
Add listen 443 ssl;, ssl_certificate /path/to/cert.pem;, and ssl_certificate_key /path/to/key.pem; to your server block. Redirect HTTP to HTTPS with a separate server block on port 80 using return 301 https://$host$request_uri;. Use ssl_protocols TLSv1.2 TLSv1.3; and strong cipher suites. For free certificates, use certbot with Let's Encrypt.
How do I load balance between multiple backends with NGINX?
Define an upstream block: upstream backend { server 10.0.0.1:8080; server 10.0.0.2:8080; } and reference it with proxy_pass http://backend;. NGINX uses round-robin by default. Add weight=3 for weighted distribution, least_conn; for least-connections algorithm, or ip_hash; for session persistence based on client IP.
---
Related Resources
- Nginx Config Generator — Nginx config generator
- Kubernetes Ingress with Nginx — Kubernetes Ingress with Nginx
- curl Command Reference — curl command for testing