Caddy or nginx for a one-person VPS stack, compared
· 9 min read
On this page (8)
Your app listens on 127.0.0.1:3000. You need something in front of it that terminates HTTPS, redirects HTTP, compresses responses and survives a deploy. The two usual answers are Caddy and nginx. Both are fine. The question is which one costs you less attention over the next three years, on a server that you alone operate.
We lean towards Caddy for this situation, and we will say why, but nginx is the better pick in several real cases, and we list them. Neither is a wrong answer.
Where the proxy sits
Who needs this
- A developer launching the first public app. You have a domain, a VPS and a Node or Go service. You have never configured TLS by hand. The difference between "four lines and it works" and "certbot, a renewal timer, a redirect server block and a dhparam question from a three-year-old tutorial" is the difference between shipping tonight and shipping next weekend.
- Someone running three small apps behind one IP. Each gets a hostname and a backend port. The proxy config is a list of near-identical blocks, and you want that list to be short and hard to get wrong.
- A team that already knows nginx. Someone has years of nginx config in their head, a snippet library and a working
nginx -thabit. Switching has a cost that the feature table does not show. - A server that serves a lot of static files or runs PHP. The shape of the workload changes the answer, and we cover where.
The same job, twice
Here is the entire job for one app, in Caddy:
example.com {
encode zstd gzip
reverse_proxy 127.0.0.1:3000
}
That is the whole file. Caddy obtains a certificate for example.com from a public CA on first start, redirects HTTP to HTTPS on its own, renews the certificate before it expires, and compresses responses. The prerequisites are real, though: the domain's A (and AAAA, if you use IPv6) record must already point at this server, and ports 80 and 443 must be reachable, because validation uses them.
The nginx version, with Let's Encrypt via certbot:
# /etc/nginx/sites-available/example.com
server {
listen 80;
listen [::]:80;
server_name example.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name example.com;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
gzip on;
gzip_types text/plain text/css application/json application/javascript image/svg+xml;
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;
}
}
sudo apt install -y nginx certbot python3-certbot-nginx
sudo certbot --nginx -d example.com
sudo nginx -t && sudo systemctl reload nginx
systemctl list-timers | grep certbot
The http2 on; directive is for nginx 1.25.1 and later; Debian 12 ships 1.22, where HTTP/2 is enabled with listen 443 ssl http2; instead. That one detail is a fair example of the cost of nginx's flexibility: the right line depends on the version, and the tutorial you found may be written for another one. Certbot in --nginx mode will also edit your file to add the certificate lines and the redirect, so the result is partly written by a tool, which is fine until you need to understand it at 2 a.m.
Counting honestly, the Caddy file is 4 lines and the nginx file is around 30, plus a separate tool and its timer. For one site this is a convenience. For six it becomes the main reason people pick Caddy.
Config lines for one proxied app
What "automatic TLS" really means
It is worth being exact, because the feature is the main reason to choose Caddy and also the source of most Caddy support questions.
On start, for each hostname in the config, Caddy requests a certificate using the ACME protocol (Let's Encrypt and ZeroSSL are the default issuers), proves control of the name through a challenge on port 80 or 443, stores the result in its data directory, and schedules renewal well before expiry. It handles OCSP stapling and sensible TLS defaults. Nothing to cron.
The things that go wrong are the same every time:
- DNS not pointing at the server yet. Validation fails. Caddy retries with backoff, and
journalctl -u caddyshows the reason. - Port 80 or 443 blocked by ufw, a provider firewall, or another process already bound to the port. Fix the block, and Caddy picks up.
- Rate limits. Repeatedly restarting a misconfigured Caddy with a fresh data directory can run into the CA's limits. Keep
/var/lib/caddybetween runs. - Wildcard certificates. These need the DNS challenge, which needs a DNS provider plugin, which means a custom Caddy build (
xcaddy) or a package that includes it. This is where Caddy stops being "four lines".
You can try the config without DNS or certificates: change the site address to :80 and curl -i http://localhost/ on the server. You should see your app's response with a Server: Caddy header, and if you stop the app, a 502, which is Caddy telling you accurately that the backend is not listening. caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile checks syntax, and caddy fmt --overwrite normalizes formatting.
With nginx, certificates come from certbot (or acme.sh, or a provider's tooling). certbot installs a systemd timer that renews twice daily, and renewals only replace certificates that are close to expiry. When a renewal fails, nothing happens to your site until the certificate actually expires, so you need to watch expiry yourself. That is a good habit with either tool; monitor one VPS without a stack has a one-line openssl check.
Where each one is better
| Caddy | nginx | |
|---|---|---|
| Config for one proxied app | About 4 lines | About 30 lines plus certbot |
| HTTPS certificates | Built in, automatic | Separate tool and timer |
| HTTP to HTTPS redirect | Automatic | You write the server block |
| Reload without dropping connections | systemctl reload caddy (graceful) | systemctl reload nginx (graceful) |
| Config check before applying | caddy validate | nginx -t |
| Static file serving at scale | Good | Excellent, with a long track record |
| Built-in rate limiting | No (needs a module) | Yes (limit_req, limit_conn) |
| TCP/UDP proxying | Needs a module | Built in (stream module) |
| FastCGI/PHP-FPM | Supported (php_fastcgi) | The classic setup, most docs |
| Version in Debian 12 | 2.6.2 | 1.22 |
| Amount of community snippets | Growing | Enormous |
| Extending | Custom builds with xcaddy | Dynamic modules, Lua via OpenResty |
A few notes on this table, because tables flatten nuance.
Reloads. Both reload gracefully: new config is loaded and old workers finish their requests. Neither will drop in-flight requests on a reload. The thing that does is restarting your app, which is a different problem; see systemctl restart drops requests and zero-downtime deploys on one VPS.
Rate limiting. If you want to rate-limit an expensive endpoint at the proxy, nginx does it with two lines. Caddy needs a third-party module. You can also do it in the app, which is often the better place.
Versions. Debian 12 packages Caddy 2.6.2, which supports everything in this post. The Caddy project also publishes its own apt repository with current releases. Debian's nginx is older but stable, and security fixes are backported by Debian.
Logs. Caddy writes structured JSON logs when you enable access logging; nginx writes the classic combined format, which every log tool and every blog post understands. If you grep your logs by hand, you will probably find nginx's format friendlier and JSON friendlier to jq. Neither is better.
When nginx is the better pick
We would choose nginx without hesitation in these cases.
- You already know it, and the team does too. Operational familiarity beats elegance. A config you can read half asleep is a feature.
- Heavy static content or caching. nginx's
proxy_cache,sendfiletuning,open_file_cacheand years of production tuning are hard to beat. If the proxy is serving gigabytes of images, use it. - You need
stream-style TCP proxying (a database or a mail port behind the same box) or built-in rate limiting without custom builds. - Complex rewrite logic, many
locationblocks with regex, or a migration of an old site whose behaviour is defined by an nginx config nobody wants to translate. - A distribution or compliance rule says to use packages from the distro only, and you want the oldest-and-boring end of the spectrum.
- Certificates come from somewhere else (a company CA, a provider's load balancer, a wildcard from another system), so automatic ACME buys you nothing.
And Caddy for the opposite cases: one person, a few apps, HTTP traffic to a backend you wrote, and no appetite to maintain a certificate pipeline.
Quick pick for one small server
Running either one safely
Whichever you choose, a few habits matter more than the choice.
Bind your app to loopback only and let the proxy be the only public listener. Check with ss -ltnp that nothing but the proxy, and SSH, listens on a public address. Keep ports 80 and 443 open in ufw and nothing else new.
Always validate before reloading, and chain the two so that a bad config never reaches the running process:
sudo caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile && sudo systemctl reload caddy
# or
sudo nginx -t && sudo systemctl reload nginx
Put the config in version control. A directory of proxy config you can diff and restore is part of rebuilding the server, and your restore drill should include it (write a restore runbook has a template for what to list). And keep a health endpoint in your app that checks the database, so that the proxy's 502 and your own 503 mean different things.
If you do blue-green style deploys by switching the upstream port, Caddy makes the switch a one-line edit and a reload; the blue-green Caddy config walkthrough shows the layout.
Rule of thumb: pick on operations, not benchmarks. The proxy is not the bottleneck on a small VPS.
Mistakes we see
Choosing by benchmark. On a small VPS the proxy is not your bottleneck. Both handle far more requests per second than a $6 server's application can produce. Pick on operations.
Forgetting X-Forwarded-Proto and friends with nginx. The app thinks every request is plain HTTP and generates http:// redirect URLs. Caddy sets the forwarded headers by default, which removes a class of bugs.
Running Caddy before DNS is ready. Not harmful, but you get log noise and CA rate-limit exposure if you restart repeatedly. Point DNS first, then start.
Editing the live file without validating. For nginx, a syntax error means the reload is refused (the old config keeps running), but a restart would take the site down. Use reload.
Mixing both on port 80. One of them fails to bind. If you migrate, stop and disable the old one first (systemctl disable --now nginx).
Leaving the default site on. A fresh nginx install serves a default page on port 80. Remove the symlink from sites-enabled so unknown hostnames do not reach something you did not intend.
FAQ
Is Caddy production ready for a small business site?
Yes. It is widely used in production, written in Go and memory safe, and a single binary with a small footprint. For one app behind a proxy it is a normal, boring choice. The practical risks are not stability, they are the prerequisites: DNS, open ports, and a place to keep its data directory.
Can I migrate from nginx to Caddy without downtime?
Mostly, yes. Write the Caddyfile, validate it, and test on a different port or hostname with curl --resolve. Then stop nginx and start Caddy in the same command, which is a few seconds of ports being unavailable. If that is too much, use a temporary second listener and switch DNS or the port forward. Keep the nginx config around until you have seen one successful certificate renewal.
Does Caddy use less memory than nginx?
For a single small site the difference is not something you should decide on. Nginx is typically very light; Caddy, being a Go binary, uses a bit more at idle but not enough to matter on a 1 GB box. Measure on your own machine with systemctl status rather than trusting either side's claims.
Do I need certbot if I use Caddy?
No. Caddy manages issuance and renewal itself. Installing certbot as well just creates two tools that both want port 80.
If you want a tested Caddyfile and the surrounding setup in one place, the VPS Handbook has a chapter on HTTPS with a reverse proxy, a template Caddyfile that validates as shipped, and the failure table for 502 and 503; see the handbook.