nginx Reverse Proxy Setup on Ubuntu 22.04

by Liam Foster
nginx Reverse Proxy Setup on Ubuntu 22.04

Most teams I've worked with started their reverse proxy journey by accident—a load balancer became a bottleneck, or they needed to hide internal service ports. nginx is the right tool for this, but the setup docs assume you already know what you're doing.

I'm going to walk you through a nginx reverse proxy setup on Ubuntu 22.04 that won't embarrass you in production. We'll cover SSL termination, upstream configuration, and the two mistakes that waste the most time.

Prerequisites and package installation

Start with a fresh Ubuntu 22.04 instance. I'm assuming you've already hardened the OS—if not, lock down SSH and the firewall first. If you're also thinking about reproducible development environments to keep your team's local setups consistent with production, that's worth a look before you go further.

Install nginx and certbot for SSL:

sudo apt update
sudo apt install -y nginx certbot python3-certbot-nginx

Verify the version:

nginx -v

You should see nginx/1.18.0 or newer. The version matters because older releases had socket leak bugs under high load.

Start the service and enable it on boot:

sudo systemctl start nginx
sudo systemctl enable nginx

Configuring upstream backends

Here's the gotcha nobody mentions: nginx reads upstream blocks at startup, not per-request. If a backend is down when nginx starts, it won't auto-recover without a reload.

Create a new config file for your reverse proxy:

sudo nano /etc/nginx/sites-available/myapp-proxy

Add this skeleton. Replace 10.0.1.5:8080 and 10.0.1.6:8080 with your actual backend IPs and ports:

upstream myapp_backend {
    least_conn;
    server 10.0.1.5:8080 max_fails=3 fail_timeout=30s;
    server 10.0.1.6:8080 max_fails=3 fail_timeout=30s;
}

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

    location / {
        proxy_pass http://myapp_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;
        proxy_connect_timeout 60s;
        proxy_send_timeout 60s;
        proxy_read_timeout 60s;
    }
}

Let me break down what matters here:

least_conn: Sends traffic to whichever backend has the fewest active connections. Better than round-robin for long-lived connections (WebSockets, gRPC).

max_fails=3 fail_timeout=30s: After 3 failed requests within 30 seconds, nginx stops sending traffic to that backend for 30 seconds. Then it tries again. This prevents hammering a dying service.

The proxy_set_header lines: These forward the client's real IP and protocol to your backend. If you skip these, your app logs will show nginx's IP as the client, and you'll spend hours debugging.

Timeouts: Set these to match your slowest expected request. 60 seconds is safe for most APIs; adjust down if you know better.

Enable the site:

sudo ln -s /etc/nginx/sites-available/myapp-proxy /etc/nginx/sites-enabled/

Test the config syntax:

sudo nginx -t

If you see syntax is ok, reload:

sudo systemctl reload nginx

Reload doesn't drop connections—it's safe to run during traffic.

SSL termination with Let's Encrypt

SSL at the proxy layer saves you from managing certs on every backend. Use certbot:

sudo certbot certonly --nginx -d myapp.example.com

Follow the prompts. Certbot will validate domain ownership and drop the cert at /etc/letsencrypt/live/myapp.example.com/.

Update your nginx config to use HTTPS:

server {
    listen 443 ssl http2;
    server_name myapp.example.com;

    ssl_certificate /etc/letsencrypt/live/myapp.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/myapp.example.com/privkey.pem;
    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_ciphers HIGH:!aNULL:!MD5;
    ssl_prefer_server_ciphers on;

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

server {
    listen 80;
    server_name myapp.example.com;
    return 301 https://$server_name$request_uri;
}

The second block redirects all HTTP traffic to HTTPS. This is standard.

Test and reload:

sudo nginx -t && sudo systemctl reload nginx

Certbot auto-renews every 60 days. Check the renewal timer:

sudo systemctl list-timers | grep certbot

If it's not there, enable it:

sudo systemctl enable certbot.timer

Monitoring and debugging

Watch the access log in real time:

sudo tail -f /var/log/nginx/access.log

You should see requests flowing through. If you see 502 Bad Gateway, a backend is down or unreachable.

Check the error log for more detail:

sudo tail -f /var/log/nginx/error.log

Common errors:

  • connect() failed: Backend is down or firewall is blocking. Verify IPs and ports.
  • upstream timed out: Backend is slow or overloaded. Increase timeouts or add more backends.
  • SSL_ERROR_RX_RECORD_TOO_LONG: You're trying to proxy HTTPS to an HTTP backend (or vice versa). Check your proxy_pass protocol.

Monitor nginx process memory and connections:

ps aux | grep nginx
netstat -tupln | grep nginx

For production, I'd add Prometheus metrics. Install the nginx exporter:

sudo apt install -y nginx-prometheus-exporter

Then add a stub_status endpoint to your config:

server {
    listen 127.0.0.1:8888;
    location /nginx_status {
        stub_status on;
        access_log off;
    }
}

Reload and scrape http://localhost:8888/nginx_status with your monitoring stack.

The two mistakes that cost time

Mistake 1: Forgetting the Host header.

If your backend app checks the Host header (most do), and you don't forward it, you'll get 400 errors or routing loops. Always include:

proxy_set_header Host $host;

Mistake 2: Not setting X-Forwarded-Proto.

If your backend generates redirect URLs or security headers, it needs to know whether the client used HTTP or HTTPS. Without this, you get mixed-content warnings and broken redirects:

proxy_set_header X-Forwarded-Proto $scheme;

What to do tomorrow

Test your setup under load. Use ab or wrk to hammer the proxy and watch the backends:

ab -n 10000 -c 100 https://myapp.example.com/

Watch the error log and access log for timeouts or errors. If you see 502 responses, your timeouts are too short or your backends are overloaded.

Set up a monitoring alert on the error log. A spike in 502 errors usually means a backend went down, and you want to know before your users do.

Finally, test failover: kill one backend and verify nginx still routes traffic to the other. Then restart it and confirm nginx picks it back up within 30 seconds (the fail_timeout window). For a broader look at how hosting infrastructure choices affect performance at this layer, the comparison on wpcompass.io covers some useful real-world benchmarks.

Your nginx reverse proxy is now production-ready. Keep the config simple, monitor the logs, and you'll spend far less time debugging than most teams.