Why Your Apps Need a Reverse Proxy

If you run more than one application on a single server — a Node.js API on port 3000, a Python backend on 8000, a database admin tool on 8080 — you have probably faced the problem: how do you serve all of them on the standard web ports (80 and 443) without port numbers in every URL?

This is what a reverse proxy solves. It sits in front of your application servers, accepts client requests on ports 80 and 443, and forwards each request to the correct backend based on the domain name or URL path. Your users see https://app.example.com and https://api.example.com instead of https://example.com:3000 and https://example.com:8000.

Beyond clean URLs, a reverse proxy gives you SSL termination (one place to manage certificates), load balancing across multiple backend instances, caching, rate limiting, and a central place to enforce security policies. Nginx is the most widely used reverse proxy in production — it handles millions of requests per second at companies like Netflix, Cloudflare, and WordPress.com, and it works just as well on a single $10 VPS.

In this guide, we will walk through installing Nginx from the official repository, configuring it as a reverse proxy for multiple backends, enabling SSL with Let’s Encrypt, adding load balancing, WebSocket support, and production hardening. By the end, you will have a professional-grade reverse proxy that you can drop in front of any web application.

What You Will Need

Before you start, you need:

  • A Linux server (Ubuntu 22.04/24.04, Debian 12, or Rocky Linux 9)
  • Root or sudo access
  • A domain name pointed to your server’s IP address
  • At least one backend application to proxy to (Node.js, Python, static files — any HTTP server works)
  • Ports 80 and 443 open in your firewall

For this guide, we recommend a Cloud VPS with at least 1 GB of RAM and 2 CPU cores, running Ubuntu 24.04 LTS. Nginx itself uses very little memory (idle: ~64 MB), but you need room for your application servers too. Canadian Web Hosting offers Cloud VPS plans with Canadian data centres in Vancouver and Toronto, 24/7 support, and full root access — exactly what you need to set up your own reverse proxy.

Installing Nginx from the Official Repository

We strongly recommend using the official Nginx repository rather than your distribution’s default package. Distribution packages (like Ubuntu’s nginx package from apt) often trail the stable release by months. The official Nginx repository gives you the latest stable binary with all modules compiled in.

Ubuntu and Debian

sudo apt update
sudo apt install curl gnupg2 ca-certificates lsb-release ubuntu-keyring

# Import the official Nginx signing key
curl -fsSL https://nginx.org/keys/nginx_signing.key | sudo gpg --dearmor 
  -o /usr/share/keyrings/nginx-archive-keyring.gpg

# Add the stable repository (replace 'ubuntu' with 'debian' for Debian)
echo "deb [signed-by=/usr/share/keyrings/nginx-archive-keyring.gpg] 
  https://nginx.org/packages/ubuntu `lsb_release -cs` nginx" | 
  sudo tee /etc/apt/sources.list.d/nginx.list

sudo apt update
sudo apt install nginx

RHEL, Rocky Linux, AlmaLinux

sudo tee /etc/yum.repos.d/nginx.repo << 'EOF'
[nginx-stable]
name=nginx stable repo
baseurl=https://nginx.org/packages/centos/$releasever/$basearch/
gpgcheck=1
enabled=1
gpgkey=https://nginx.org/keys/nginx_signing.key
module_hotfixes=true
EOF

sudo dnf install nginx

Docker (Containerized Environments)

If you are already running your applications in Docker containers, a Docker-based Nginx proxy fits naturally into your stack. The nginx:stable-alpine image is about 7 MB and includes all common modules.

docker pull nginx:stable-alpine
docker run --name nginx-proxy -p 80:80 -p 443:443 
  -v /path/to/nginx.conf:/etc/nginx/nginx.conf:ro 
  nginx:stable-alpine

Verify the installation:

nginx -v
# Expected output: nginx version: nginx/x.y.z  # version varies by repository and release channel

Basic Reverse Proxy Configuration

Now that Nginx is installed, let us configure it as a reverse proxy. The core directive is proxy_pass, which tells Nginx where to forward incoming requests.

Single Backend Proxy

The simplest case: you have one application running on localhost:3000 and you want to serve it at https://app.example.com.

Create a new configuration file in /etc/nginx/conf.d/app.conf:

server {
    listen 80;
    server_name app.example.com;

    location / {
        proxy_pass http://localhost: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;
    }
}

What each header does:

  • Host $host — Passes the original hostname so the backend knows what domain was requested. Without this, the backend sees localhost:3000 and may generate incorrect redirects or links.
  • X-Real-IP $remote_addr — Passes the real client IP address. Without this, the backend sees all requests as coming from Nginx’s IP (127.0.0.1).
  • X-Forwarded-For $proxy_add_x_forwarded_for — Appends the client IP to any existing X-Forwarded-For chain, preserving the full proxy hop history.
  • X-Forwarded-Proto $scheme — Tells the backend whether the original request was HTTP or HTTPS. Essential when Nginx handles SSL termination or your backend generates protocol-sensitive redirects.

Test the configuration and reload:

sudo nginx -t
# Expected: syntax is ok, test is successful

sudo systemctl reload nginx

# Verify with curl
curl -I http://localhost

Multiple Domains on One Server

The real power of a reverse proxy is serving multiple applications on a single server using different domain names. Create one server block per domain:

# /etc/nginx/conf.d/api.conf
server {
    listen 80;
    server_name api.example.com;

    location / {
        proxy_pass http://localhost:4000;
        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/conf.d/blog.conf
server {
    listen 80;
    server_name blog.example.com;

    location / {
        proxy_pass http://localhost: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;
    }
}

Nginx matches the Host header from the incoming request to the server_name directive and routes it to the correct backend. No port numbers in URLs, no conflict between applications.

Path-Based Routing

If you want to serve multiple applications under the same domain but different URL paths, use separate location blocks:

server {
    listen 80;
    server_name example.com;

    location /api/ {
        proxy_pass http://localhost:4000/;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    }

    location /app/ {
        proxy_pass http://localhost: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;
    }

    location / {
        root /var/www/static;
        index index.html;
    }
}

Important gotcha: When proxy_pass includes a URI path (the trailing slash in http://localhost:4000/), Nginx replaces the matching location prefix with that URI path. A request to /api/login becomes http://localhost:4000/login, not http://localhost:4000/api/login. If you omit the trailing slash (proxy_pass http://localhost:4000;), the full request URI is passed unchanged. Get this wrong and your application routes will break in subtle ways.

Ops Note: Test the Proxy Before Touching DNS

The safest reverse proxy rollout is local first: validate nginx -t, curl the backend directly, curl the proxy locally with the expected host header, then change DNS or load balancer routing. Most production mistakes come from skipping one of those checks and discovering a bad upstream, header, or TLS path only after traffic arrives.

SSL Termination with Let’s Encrypt

A reverse proxy is the ideal place to handle SSL termination. One certificate, one place to manage renewals, and your backends can communicate over plain HTTP internally (on a private network or localhost).

Install Certbot and obtain a certificate:

sudo apt install certbot python3-certbot-nginx
sudo certbot --nginx -d app.example.com

Certbot automatically modifies your Nginx configuration to add the SSL server block and redirect HTTP to HTTPS. The resulting config looks like this:

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         HIGH:!aNULL:!MD5;
    ssl_session_cache   shared:SSL:10m;
    ssl_session_timeout 10m;

    location / {
        proxy_pass http://localhost: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 app.example.com;
    return 301 https://$host$request_uri;
}

The http2 parameter on the listen directive enables HTTP/2, which multiplexes requests over a single connection and can significantly reduce page load times for sites with many assets. Certbot also sets up automatic renewal via a systemd timer — no manual certificate management needed.

Key security detail: Always set X-Forwarded-Proto $scheme when you terminate SSL at Nginx. Without this header, your backend application thinks it is running over HTTP and may generate HTTPS redirects that create redirect loops, especially with frameworks like Django or Ruby on Rails that enforce HTTPS.

Load Balancing Across Multiple Backends

When one backend instance is not enough, Nginx can distribute traffic across multiple servers using the upstream block:

upstream backend {
    least_conn;
    server app1.example.com:3000 weight=3;
    server app2.example.com:3000 weight=2;
    server 10.0.0.3:3000;
}

server {
    listen 80;
    server_name app.example.com;

    location / {
        proxy_pass http://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;
    }
}
Method Directive Behaviour
Round-robin (default) Distributes evenly in order
Least connections least_conn Sends to the server with fewest active connections
IP hash ip_hash Consistent routing by client IP (session stickiness)
Generic hash hash $request_uri Custom key-based distribution

For most setups, least_conn is the best default — it naturally balances load when requests have varying processing times. Use ip_hash only if your application requires session persistence and you are not using a shared session store like Redis.

WebSocket Proxying

Applications using WebSockets (live chat, real-time dashboards, collaborative editing) need special handling because the connection needs to be upgraded from HTTP to the WebSocket protocol. Nginx has supported WebSocket proxying since version 1.3.13:

map $http_upgrade $connection_upgrade {
    default upgrade;
    ''      close;
}

server {
    listen 80;
    server_name chat.example.com;

    location /ws/ {
        proxy_pass http://localhost:8080;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection $connection_upgrade;
        proxy_set_header Host $host;
        proxy_read_timeout 86400s;
    }
}

The critical setting: proxy_read_timeout 86400s (24 hours). Without this, Nginx closes idle WebSocket connections after the default 60-second timeout. Your WebSocket connections will drop every 60 seconds of inactivity, causing reconnection errors in the browser.

Also ensure your backend application sends periodic ping/pong frames to keep the connection alive. Most WebSocket libraries do this automatically, but some need explicit configuration.

Verifying Your Proxy Is Working

After configuring your reverse proxy, run through these checks to confirm everything works:

# 1. Configuration syntax check (always first)
sudo nginx -t
# Expected: syntax is ok, test is successful

# 2. Verify Nginx is running and listening
sudo ss -tlnp | grep nginx
# Expected: shows 0.0.0.0:80 and 0.0.0.0:443 (if SSL configured)

# 3. Test proxy routing locally
curl -I http://localhost
# Expected: HTTP/1.1 200 OK (or whichever status your backend returns)

# 4. Test via domain name
curl -H "Host: app.example.com" http://localhost/ -w "nHTTP %{http_code}n"
# Expected: 200 OK, proxied to your backend

# 5. Check upstream response headers
curl -I https://app.example.com/ 2>/dev/null | grep -i "x-forwarded"
# Expected: X-Forwarded-For, X-Forwarded-Proto headers present

# 6. Enable the status module for live metrics
# Add to /etc/nginx/conf.d/status.conf:
# server {
#     listen 127.0.0.1:80;
#     location = /nginx_status {
#         stub_status;
#         allow 127.0.0.1;
#         deny all;
#     }
# }

The stub_status module gives you real-time metrics:

curl http://127.0.0.1/nginx_status

# Sample output:
# Active connections: 291
# server accepts handled requests
#  16630948 16630948 31070465
# Reading: 6 Writing: 179 Waiting: 106

What to look for: The Reading and Writing counters should be low in steady state. A high Reading count may indicate slow clients. handled should equal accepts — if it is lower, Nginx is hitting resource limits (increase worker_connections).

Production Hardening

A reverse proxy is a security boundary. Here is how to lock it down for production use.

Firewall Rules

Only expose ports 80 and 443 to the internet. Block all other ports at the firewall level:

# UFW (Ubuntu/Debian)
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw default deny incoming
sudo ufw enable

# firewalld (RHEL/Rocky)
sudo firewall-cmd --permanent --add-service=http
sudo firewall-cmd --permanent --add-service=https
sudo firewall-cmd --reload

Your backend applications (port 3000, 4000, etc.) should bind to localhost or a private network, never 0.0.0.0. Test this: ss -tlnp | grep 3000 should show 127.0.0.1:3000, not 0.0.0.0:3000.

Security Headers

Add these headers at the proxy level so every proxied application inherits them:

add_header X-Content-Type-Options    "nosniff" always;
add_header X-Frame-Options           "SAMEORIGIN" always;
add_header Referrer-Policy           "strict-origin-when-cross-origin" always;
add_header X-XSS-Protection          "0" always;

Rate Limiting

Protect your backends from abuse with Nginx’s built-in rate limiting:

# In the http block of nginx.conf
limit_req_zone $binary_remote_addr zone=api_limit:10m rate=10r/s;

# In the location block
location /api/ {
    limit_req zone=api_limit burst=20 nodelay;
    proxy_pass http://backend;
}

This limits each client IP to 10 requests per second, with a burst allowance of 20 before requests are dropped. Adjust the rate based on your application’s normal traffic patterns.

Hide Nginx Version

Prevent attackers from targeting version-specific vulnerabilities:

# In the http block of nginx.conf
server_tokens off;

This removes the Nginx version number from error pages and response headers.

Troubleshooting Common Issues

“upstream sent too big header” in Error Logs

Symptom: Nginx returns 502 Bad Gateway, and the error log shows upstream sent too big header.

Cause: The default proxy buffer size (4 KB) is too small for your backend’s response headers. Common triggers: large authentication cookies, SAML/OAuth responses, or custom application headers.

Fix: Increase the buffer sizes in the offending location block:

location / {
    proxy_buffer_size 8k;
    proxy_buffers 8 8k;
    proxy_busy_buffers_size 16k;
    proxy_pass http://localhost:3000;
}

WebSocket Connections Dropping After 60 Seconds

Symptom: Real-time features (chat, live dashboards) disconnect after exactly 60 seconds of inactivity and the client tries to reconnect.

Cause: Nginx’s default proxy_read_timeout is 60 seconds. When the WebSocket has no data for 60 seconds, Nginx closes the connection.

Fix: Increase the timeout for WebSocket locations:

proxy_read_timeout 86400s;
proxy_send_timeout 86400s;

Redirect Loop After Enabling HTTPS

Symptom: Your browser shows a redirect loop error after enabling HTTPS.

Cause: Your backend application detects an HTTP request and redirects to HTTPS, but Nginx terminates SSL and forwards to the backend over HTTP. The backend keeps redirecting because it does not know that the original request was HTTPS.

Fix: Ensure these headers are set in your proxy configuration:

proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Port $server_port;

Then configure your application framework to trust the X-Forwarded-Proto header. For example, in Django:

SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')

proxy_pass Trailing Slash Pitfalls

Symptom: Your application routes are not matching correctly. Requests to /app/login reach the backend as /login or /apilogin depending on your configuration.

Rule of thumb: If proxy_pass has a URI path (e.g., http://backend/api/), the matching location prefix is replaced by that URI path. If it has no URI (just http://backend), the full original request URI is passed through. Test with a small change if unsure — add or remove the trailing slash and use curl -I to observe the difference.

Next Steps

Once your reverse proxy is running, here are the logical next steps for your server infrastructure:

  • Not sure Nginx is right for you? Compare Nginx, Caddy, and Traefik in our Caddy vs Nginx vs Traefik: Choosing the Right Reverse Proxy guide.
  • If managing your own reverse proxy sounds like more than you want to take on, Canadian Web Hosting offers Managed Support — our team handles the setup, security patches, and ongoing maintenance so you can focus on your applications.

    If your reverse proxy stops working because a container won’t start, our Docker deployment troubleshooting guide covers port conflicts and other common causes.

    Ops Note: Test the Proxy Before Touching DNS

    The safest reverse proxy rollout is local first: validate nginx -t, curl the backend directly, curl the proxy locally with the expected host header, then change DNS or load balancer routing. Most production mistakes come from skipping one of those checks and discovering a bad upstream, header, or TLS path only after traffic arrives.

    Sources and Command Notes

    This Nginx reverse proxy guide was refreshed in June 2026. The sample proxy server block was smoke-tested with nginx -t in Ubuntu 24.04. Package repository steps and current Nginx versions can vary by distribution, so verify against the official Nginx package documentation for your OS before production use.