Caddy as an nginx Drop-in on a Laravel VPS: Mostly Great, Sometimes a Fight
I swapped nginx for Caddy on a client VPS last year. Automatic HTTPS is genuinely magic. A few things will bite you if you're not ready.
I've been running nginx in front of PHP-FPM for so long that switching felt like switching hands for a handshake. But after helping a client migrate a Laravel app to a fresh Ubuntu VPS last year, I decided to give Caddy a real shot instead of reaching for my usual nginx boilerplate. The automatic HTTPS alone saved me forty minutes of certbot ceremony. It also fought me on two things I didn't expect.
Here's my honest take after running it in production for about eight months.
What Caddy Actually Does Differently
The pitch is simple: Caddy is a web server that handles TLS certificates automatically via ACME (Let's Encrypt or ZeroSSL), with a config syntax that's dramatically less verbose than nginx. It's not a CDN, not a load balancer service, not a SaaS — it's just a binary you put on your server that acts like nginx but manages its own certs.
For a managed hosting shop like mine, where I'm spinning up Laravel VPS environments for clients on a regular basis, the nginx + certbot + cron + renewal-hook dance is well-worn but genuinely tedious. Every time. And it's stateful in annoying ways — certbot writes files in /etc/letsencrypt, nginx needs reloading after renewal, the hook has to be configured correctly or you end up with expired certs and a 3am phone call.
Caddy collapses all of that into zero configuration. You give it a domain name, it handles the rest.
The Caddyfile for a Standard Laravel App
Here's what a real Caddyfile looks like for a Laravel app. This is close to what I'm running in production:
yourdomain.com {
root * /var/www/yourapp/public
php_fastcgi unix//run/php/php8.3-fpm.sock
file_server
encode gzip
@notStatic {
not file
}
rewrite @notStatic /index.php
header {
X-Content-Type-Options nosniff
X-Frame-Options DENY
Referrer-Policy strict-origin-when-cross-origin
-Server
}
log {
output file /var/log/caddy/yourapp-access.log
format json
}
}
Compare that to the nginx equivalent, which includes a server block for port 80, a redirect, a server block for port 443, ssl_certificate paths, ssl_certificate_key paths, ssl_protocols, ssl_ciphers, try_files, fastcgi_pass, a pile of fastcgi_param lines, and usually a separate include file. The Caddyfile above is the whole thing. No certbot. No cron. No renewal hook. On first request to the domain, Caddy goes and gets the cert, stores it, renews it, and reloads itself.
That's not a toy demo. That's what I deployed.
The PHP-FPM Socket Line Is a Small Trap
Notice the double slash in unix//run/php/php8.3-fpm.sock. That's not a typo. Caddy's php_fastcgi directive uses a URI-style path where unix/ is the scheme and the rest is the path. So a socket at /run/php/php8.3-fpm.sock becomes unix//run/php/php8.3-fpm.sock — two slashes total. Get this wrong and you'll get a cryptic 502 and spend twenty minutes staring at it.
If you're using TCP instead of a socket (less common on a single-server setup, but it happens):
php_fastcgi 127.0.0.1:9000
That one's more obvious. But use the socket if you can — one less network hop.
Where Automatic HTTPS Is Genuinely Magic
I want to dwell on this because it's easy to dismiss as a gimmick if you haven't lived through enough cert renewals.
On a recent project — a Laravel-based client portal for a Seattle-area industrial company — I was standing up four environments: production, staging, demo, and a QA instance. In the old workflow, that's four certbot runs, four nginx TLS configs, four renewal hooks to verify. With Caddy, I updated the Caddyfile to add the new domain names, reloaded Caddy with systemctl reload caddy, and walked away. Certs appeared within seconds on first HTTP request to each domain.
Caddy also handles the HTTP-to-HTTPS redirect automatically. You don't configure it. It just does it. The moment you put a real domain name in a Caddyfile block, Caddy assumes HTTPS is the goal and sets up the redirect from port 80 automatically.
For local development or internal hostnames where ACME won't work, you can tell it to use a self-signed cert:
localhost {
tls internal
root * /var/www/yourapp/public
php_fastcgi unix//run/php/php8.3-fpm.sock
file_server
rewrite * /index.php
}
Caddy ships its own local CA for tls internal. You can trust it in your browser for development. It's not as seamless as Laravel Valet or Laravel Herd on a Mac, but for a Linux dev box or a Vagrant VM it's handy.
Where It Fights You
Complex Rewrite Rules
This is the one that cost me real time. I had a client with a legacy route structure that involved some non-trivial nginx rewrites — capturing URI segments and rewriting them before they hit PHP. In nginx, that's a rewrite directive with a regex and a replacement. In Caddy, the equivalent is a rewrite plus a @named matcher and sometimes a map directive.
The Caddy docs are fine, but the mental model is different enough that porting complex nginx rewrites isn't mechanical. It requires actually understanding what the rewrite is doing and expressing it in Caddy's matcher syntax. For simple Laravel apps with standard routing, you'll never hit this. For apps with legacy URL structures or apps that do a lot of server-side redirects, budget an hour or two to work through it.
Rate Limiting
nginx has limit_req_zone and limit_req baked in. Caddy's rate limiting is not in the standard distribution — it's in a separate plugin called caddy-ratelimit that you have to compile in or use a pre-built binary that includes it. If rate limiting matters to your security posture (and for healthcare or e-commerce clients it does), you either need to source a custom Caddy build, put something like Fail2ban in front, or handle it at the application layer in Laravel.
This isn't a dealbreaker, but it surprised me. It's one of the places where nginx's maturity shows.
Log Format Differences
Caddy's default logs are JSON. That's actually better for structured logging pipelines, but if you have any tooling — fail2ban rules, log parsers, monitoring scripts — that's built around nginx's combined log format, you'll need to update them. I had a Fail2ban jail looking for patterns in nginx access logs that needed rewriting. Not hard, but another thing to remember.
You can get something close to combined format with a custom log template, but at that point you're fighting the grain of the tool.
When I'd Reach for Caddy
- New Laravel VPS with a straightforward routing setup. This is the sweet spot. Standard Laravel routing, one or a handful of domains, no exotic rewrite requirements. Caddy is faster to configure and easier to maintain.
- Multiple domains/environments on one server. Adding a new site is adding a block to the Caddyfile and reloading. That's it.
- When I want to stop thinking about cert renewal. For client servers I manage long-term, removing certbot from the mental checklist is genuinely valuable.
- Internal tooling servers. The
tls internaloption is convenient for admin panels and internal apps where I don't want to bother with a real cert.
When I'd Stick with nginx
- Existing servers with complex nginx configs. The migration cost is real. Don't do it mid-project.
- When I need rate limiting without an extra build step. For a healthcare client where I care a lot about abuse prevention at the server layer, nginx's built-in rate limiting is one less moving part.
- When the team is nginx-fluent. My own mental model of nginx config is deep enough that I can debug it fast. If something goes wrong at 11pm, fluency matters.
- Complex proxy setups. Caddy can proxy, but the configuration for sophisticated upstream setups with health checks and failover is less battle-tested in my experience than nginx's.
The Bottom Line
Caddy earns its keep on a straightforward Laravel VPS. The automatic HTTPS is not a gimmick — it's a real reduction in operational overhead, and I've stopped thinking about cert renewal on the servers where I've deployed it. The config syntax is genuinely cleaner. But it's not a zero-thought nginx replacement: complex rewrites require real porting effort, and you'll notice the gaps (rate limiting especially) if your requirements are more involved than a typical app.
For new builds, it's my default now. For migrations, I'd think twice unless there's a good reason to touch the server config anyway.
Need help shipping something like this? Get in touch.