Why Take Care of Your Reverse Proxy on a VPS
On a VPS, the front-end web server is the piece that orchestrates everything: it terminates TLS, distributes traffic to your containers (app, forge, media server, LLM), serves static files, and applies security headers. Well chosen, it radically simplifies enabling HTTPS on all your subdomains. Caddy obtains and renews Let's Encrypt certificates automatically, with no configuration, using a readable Caddyfile of a few lines. Nginx, the market reference, offers extremely fine control (cache, rewrite rules, load balancing, rate limiting) but requires manual certificate management or management via Certbot. The choice pits modern automation against proven, total control.
What a Good Front End Brings to Your VPS
- Centralized TLS termination for all your subdomains
- A single reverse proxy to multiple Docker containers
- Fast static file serving and compression (gzip/brotli)
- Security headers (HSTS, CSP, X-Content-Type-Options) applied in the same place
- With Caddy: Let's Encrypt certificates obtained and renewed automatically
- With Nginx: cache, rate limiting, and load balancing finely tuned
- HTTP/3 and QUIC to reduce latency on degraded connections
Prerequisites: A Frugal Front End
A reverse proxy is very lightweight: both Caddy and Nginx run comfortably on 1 vCPU and 512 MB to 1 GB of RAM, even in front of several services. The resource to watch is rather the bandwidth and the number of simultaneous connections. You need a domain and its subdomains pointing to the VPS IP (A/AAAA records), ports 80 and 443 open on the firewall (port 80 is required for certificate validation), and Docker if you containerize the proxy. No GPU or large storage: here, it is the configuration that makes the difference, not raw power.
Set Up Caddy (or Nginx) as a Reverse Proxy
Point the DNS
Create the A records (and AAAA if IPv6) for each subdomain (app, git, media) pointing to the VPS IP. Let's Encrypt validation will fail as long as DNS resolution is not effective, so verify it first with
dig app.yourdomain.comor an online tool.Open Ports 80 and 443
Allow only 80 and 443 on the firewall (
ufw allow 80/tcp && ufw allow 443/tcp). Port 80 remains essential for the Let's Encrypt HTTP challenge and to automatically redirect traffic to HTTPS.Write the Base Configuration
With Caddy, one block is enough:
app.yourdomain.com { reverse_proxy 127.0.0.1:3000 }and HTTPS is automatic. With Nginx, write oneserverblock per subdomain withproxy_passand theX-Forwarded-*headers, then obtain the certificate via Certbot:certbot --nginx -d app.yourdomain.com.Configure Multiple Subdomains
With Caddy, list each block in the same
Caddyfile:git.yourdomain.com { reverse_proxy 127.0.0.1:3001 }. With Nginx, create one file per subdomain in/etc/nginx/conf.d/or/etc/nginx/sites-available/, then enable it withln -s. Both approaches let you manage dozens of services without repetition.Enable Compression
Caddy enables gzip by default; to add brotli:
encode zstd br gzipin the site block. Under Nginx, add tonginx.conf:gzip on; gzip_types text/plain text/css application/json application/javascript; gzip_min_length 256;. Compression reduces text response size by 60 to 80% in most cases.Launch and Reload Without Downtime
Start the service (
docker compose up -dorsystemctl start caddy). After each change, validate the config (nginx -torcaddy validate) and then hot-reload (nginx -s reload/caddy reload) to never interrupt traffic.Verify Certificates
With Caddy, run
caddy validate --config /etc/caddy/Caddyfileto catch errors before reloading, and check logs (journalctl -u caddy -f) to confirm certificate issuance. With Nginx + Certbot,certbot certificateslists expiry dates andcertbot renew --dry-runsimulates renewal.Harden Security and Set Up Logs
Enable HSTS, hide the server version (
server_tokens offunder Nginx, automatic under Caddy), enforce TLS 1.2+, and add basic rate limiting. Enable access and error logs, and watch for 502/504 codes that reveal an unreachable downstream container.
Once the proxy is operational and certificates have been issued, two complementary steps strengthen security and performance: advanced header and rate limiting configuration, and monitoring setup. Order matters: validate that basic routing works first (curl -I https://app.yourdomain.com) before stacking configuration layers.
Advanced Configuration: Security, Compression and HTTP/3
Once the basics are in place, three areas concretely improve your reverse proxy's posture.
Security headers. Apply at the proxy level the headers that each downstream service will forget: Strict-Transport-Security: max-age=31536000; includeSubDomains (HSTS), X-Content-Type-Options: nosniff, X-Frame-Options: SAMEORIGIN, and a Content-Security-Policy tailored to your application. Centralizing these headers at the proxy level ensures they cover all your subdomains in one move.
Rate limiting. Under Caddy, the rate_limit module (available via xcaddy build) lets you cap requests per IP. Under Nginx, the directive limit_req_zone $binary_remote_addr zone=api:10m rate=30r/m; in http {} then limit_req zone=api burst=10 nodelay; in the relevant location is enough to protect an API against abuse without external dependencies.
HTTP/3 and QUIC. Caddy enables HTTP/3 by default as soon as UDP port 443 is open. Nginx has supported QUIC since the mainline branch (quic parameter in the listen block); check the installed version with nginx -v before enabling the directive. HTTP/3 reduces perceived latency on mobile connections and high packet-loss networks, without any change in behavior for clients that do not support it.
Caddy vs Nginx — Comparison Table
Scroll the table
| Criterion | Caddy | Nginx |
|---|---|---|
| HTTPS / certificates | Automatic, zero config | Manual or via Certbot |
| Configuration syntax | Caddyfile, very concise | More verbose, very expressive |
| Learning curve | Low | Moderate to high |
| Fine-grained control (cache, rewrite, LB) | Good, sometimes via plugins | Very complete and proven |
| HTTP/3 / QUIC | Enabled by default | Supported (mainline branch) |
| Native rate limiting | Via xcaddy module | Built-in (`limit_req`) |
| Dynamic modules | Compilation via xcaddy | Dynamic modules (.so) |
| RAM footprint at rest | ~30–50 MB | ~20–40 MB (workers) |
| Log format | Structured JSON by default | Text, configurable |
| Ideal for | Quick HTTPS setup, multi-subdomain | Advanced tuning, high traffic |
The deployment steps cover the ideal configuration. In practice, several types of errors come up regularly: a certificate blocked by an intermediate network, a downstream service that is not yet responding, or a Docker container unreachable from the proxy because the two are on separate networks. The most useful dashboard in these situations is the proxy log (journalctl -u caddy -f or tail -f /var/log/nginx/error.log), combined with curl locally to isolate the problem.
Troubleshooting: The Most Common Errors
Even a well-configured proxy produces errors at startup or under load. Here are the five cases that come up most often.
Caddy — "no certificate" behind a load balancer. When Caddy sits behind a load balancer that already terminates TLS, it cannot receive the Let's Encrypt HTTP-01 challenge and fails to issue the certificate. Solution: use the DNS-01 challenge via your registrar's plugin (e.g. tls { dns cloudflare {env.CF_API_TOKEN} }), or delegate TLS management entirely to the load balancer and force Caddy to http:// internally.
Nginx — 502 Bad Gateway (upstream timeout). A persistent 502 after startup means the downstream service is not listening yet or has crashed. Check with curl -v http://127.0.0.1:<port> from the server. If the service starts slowly, increase proxy_read_timeout and proxy_connect_timeout. An intermittent 502 under load points to a lack of keepalive connections: add keepalive 32; in the upstream block.
Caddy with Docker — container unreachable. When Caddy and the target service run on separate Docker networks, reverse_proxy 127.0.0.1:3000 does not work: 127.0.0.1 is the loopback address of the Caddy container, not the host. Solution: connect both containers to the same named Docker bridge network and use the service name as the target address: reverse_proxy service-name:3000.
Nginx — "too many open files" under load. The error worker_connections are not enough or open() failed (24: Too many open files) appears when the VPS receives a traffic spike. Increase the system limit (ulimit -n 65535 or DefaultLimitNOFILE=65535 in the systemd unit) and align worker_connections 4096; in nginx.conf. The maximum simultaneous connections is worker_processes * worker_connections.
Caddy — silently blocked renewal. Caddy renews in the background, but if UDP port 443 is closed by the firewall, HTTP/3 fails and logs may mask the real cause. Monitor journalctl -u caddy -f around renewal dates (60 days after issuance) and test the challenge with caddy run --config /etc/caddy/Caddyfile --watch in the foreground to see errors in real time.
Caddy, Nginx or Traefik: The Third Choice
For infrastructure with many Docker microservices with automatic container discovery, Traefik emerges as a third option: it reads Docker labels (traefik.http.routers.myapp.rule=Host("app.yourdomain.com")) and configures routes on the fly without manual reloading. The downside is its more complex configuration and denser documentation. Caddy and Nginx remain the natural choices for a reasonably-sized VPS (1 to 20 services); Traefik takes over beyond that, especially in a Kubernetes or Docker Swarm context. For a full comparison of all three, see the article choosing your VPS reverse proxy: Caddy, Traefik or Nginx.
Combining Both Rather Than Choosing
If you are still hesitating, know that Caddy and Nginx are not mutually exclusive. A common pattern places Caddy as the very first front end to automatically manage TLS for all your subdomains, then leaves Nginx downstream for fine caching and rewrite rules of a specific service. This way you combine automatic certificates with tuning control, without picking a side for good.