Reverse proxies and SSL

A reverse proxy usually becomes the first real boundary between the internet and your application. It is not just a way to forward traffic from port 443 to port 8080. In production, it is where you usually handle TLS, redirects, request limits, upstream selection, client IP forwarding, timeouts, and sometimes basic protection against traffic spikes.

The tricky part is that reverse proxies look simple until something fails: a backend hangs, a certificate renewal breaks, a websocket silently stops working, or your application logs every request as coming from 127.0.0.1.

Choosing the tool

For small and medium deployments, the choice is usually between Caddy and nginx.

Caddy is excellent when you want HTTPS to mostly disappear as a problem. The configuration is small, Let's Encrypt is automatic, and certificate renewal is handled for you.

example.com {
    reverse_proxy localhost:8080
}

That is enough for many internal tools, side projects, dashboards, and simple production services.

nginx gives you more explicit control. You usually reach for it when you want to be precise about headers, caching, routing, rate limits, static files, custom TLS settings, or when the deployment environment already standardizes on it.

HAProxy is more specialized. It is very strong when load balancing behavior matters: active health checks, detailed backend selection, TCP-level proxying, and more advanced traffic routing. For many normal web apps it may be overkill, but for edge/load-balancing heavy setups it is a very serious tool.

My default rule is simple: use Caddy when simplicity is the priority, nginx when control is the priority, and HAProxy when load-balancing behavior itself is a major part of the system.

TLS termination

TLS termination means the proxy handles HTTPS from the client, then forwards plain HTTP or HTTPS to the backend.

With Caddy, TLS is automatic:

example.com {
    reverse_proxy localhost:8080
}

With nginx, you are usually more explicit. For Let's Encrypt, certbot can either install the nginx configuration automatically or just issue the certificates.

certbot --nginx -d example.com

A minimal nginx HTTPS proxy looks like this:

server {
    listen 443 ssl;
    server_name example.com;

    ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;

    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;
    }
}

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

Those proxy headers matter. Without them, the backend may not know the original host, scheme, or client IP. This affects logging, redirects, rate limiting, auth callbacks, and anything that generates absolute URLs.

Timeouts are not optional

A common mistake is to configure only the happy path. In production, you also need to decide what happens when the backend is slow, stuck, or unreachable.

location / {
    proxy_pass http://backend;

    proxy_connect_timeout 5s;
    proxy_send_timeout 30s;
    proxy_read_timeout 30s;
}

Timeouts are a policy decision. Too low and you kill valid slow requests. Too high and a broken backend can keep connections hanging for too long. The right values depend on the service, but having no explicit values is usually worse.

For APIs, I prefer making slow operations asynchronous instead of allowing very long proxy timeouts.

Upstreams and failover

nginx can define upstream backends:

upstream backend {
    server app1:8080 max_fails=3 fail_timeout=30s;
    server app2:8080 max_fails=3 fail_timeout=30s;
}

server {
    location / {
        proxy_pass http://backend;
    }
}

One important detail: in open-source nginx, this is mostly passive failure handling. nginx marks a backend as failed after real requests fail. It is not the same thing as active health checking where the proxy continuously probes /health.

For active health checks, you usually use HAProxy, NGINX Plus, Kubernetes readiness probes, a service mesh, or another external mechanism.

Health endpoints

A health endpoint should be boring. It should answer fast and should not trigger heavy database work, external API calls, cache rebuilds, or expensive checks.

A good split is often:

  • /live: is the process running?
  • /ready: can this instance receive traffic?

For example, Kubernetes readiness checks should usually be stricter than liveness checks. Killing a process because a database was briefly slow can make an outage worse.

Rate limiting

Rate limiting at the proxy can protect a backend from simple traffic spikes or accidental client abuse.

limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;

server {
    location /api/ {
        limit_req zone=api burst=20 nodelay;
        proxy_pass http://backend;
    }
}

This is not a full DDoS solution, but it is a useful first layer. The important part is to choose limits based on actual traffic patterns. Randomly low limits can break legitimate users.

WebSockets and streaming

Some applications need connection upgrades, especially WebSockets.

location /ws/ {
    proxy_pass http://backend;

    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
}

This is one of those things that often works locally and then fails behind a proxy because the upgrade headers were not forwarded correctly.

Sticky sessions

Sticky sessions can be useful, but I treat them as a last resort.

upstream backend {
    ip_hash;
    server app1:8080;
    server app2:8080;
}

This keeps a client routed to the same backend based on IP. It can help with stateful applications, but it also makes scaling and failover harder.

A better long-term design is usually to keep application instances stateless and move shared session state to Redis, a database, or another external store.

HSTS caution

HSTS tells browsers to always use HTTPS for your domain. It is good security, but I avoid enabling it too early.

Once a browser sees HSTS, it can keep forcing HTTPS even if your DNS, certificate, or redirect setup is broken. First make sure your HTTPS setup is stable. Then enable HSTS deliberately.

Practical checklist

Before exposing a service publicly, I usually check:

  • HTTP redirects to HTTPS.
  • The backend receives correct Host, X-Forwarded-For, and X-Forwarded-Proto headers.
  • Timeouts are explicit.
  • Upload/body size limits are intentional.
  • Logs include the real client IP.
  • Certificate renewal has been tested.
  • Health/readiness behavior is understood.
  • WebSockets or streaming endpoints have special proxy config if needed.
  • Rate limits are based on realistic traffic.

Final thought

A reverse proxy is easy to configure for the happy path. The real production value is in the failure behavior: what happens when certificates expire, backends hang, clients reconnect, health checks lie, or traffic suddenly doubles.

For simple services, Caddy is often the fastest safe choice. For more controlled deployments, nginx is still a very practical default. When active load-balancing behavior matters, HAProxy deserves serious consideration.