Setting up a caddy reverse proxy on a VPS is the fastest way to get your apps onto the internet with proper HTTPS. No Certbot timers to babysit, no 40-line Nginx server blocks, no certificate expiry pages at 3am. You install Caddy, write a three-line config, and it fetches and renews your certificates automatically. This guide walks you through the whole thing on Ubuntu — install, DNS, your first Caddyfile, subdomains, headers, wildcard certificates, and a troubleshooting checklist. Facts checked October 12, 2026.
Why pick Caddy over Nginx + Certbot?
The standard VPS stack — Nginx in front, Certbot fetching Let’s Encrypt certificates, a cron job renewing them — works fine. It is also three separate pieces you have to keep agreeing with each other. Caddy collapses that into one binary:
- Automatic HTTPS, actually automatic. If your config contains a public domain name, Caddy provisions a certificate for it and keeps it renewed. It also redirects HTTP to HTTPS for you. There is no separate ACME client and no renewal cron.
- Config you can read. A complete reverse proxy is
example.com { reverse_proxy 127.0.0.1:3000 }. The Nginx equivalent is a server block withproxy_set_headerlines you will inevitably forget one of. - Modern defaults. HTTP/2 and HTTP/3 (QUIC) out of the box, OCSP stapling, safe TLS ciphers.
Be honest about the trade-off, though: at truly extreme throughput Nginx’s event loop still has an edge, and if your infrastructure is already built around Nginx, switching for switching’s sake is not worth it. If you want the Nginx path instead, see How to Set Up an Nginx Reverse Proxy for Node.js Apps. For everything from side projects to serious single-box production, Caddy is the low-friction choice.
Prerequisites
- A VPS running Ubuntu 22.04 or 24.04 with a non-root sudo user. If you haven’t done the basics yet, How to Set Up a VPS From Scratch: Ubuntu, Nginx, and SSL covers the initial hardening.
- A domain name you control.
- A DNS A record pointing your domain (and any subdomains you plan to use) at your VPS’s public IP. Caddy cannot prove you own a domain otherwise.
- Ports 80 and 443 reachable on the VPS — Caddy needs port 80 for the ACME HTTP-01 challenge and 443 to serve HTTPS.
- An app listening on the VPS (even a toy Node, Python, or Go server on port 3000 is enough to test with).
Step 1: Install Caddy from the official package
Caddy ships an official package repository for Debian and Ubuntu, and installing it is the method the project recommends for production servers. The package starts Caddy as a systemd service automatically. Run this verbatim from the official install docs:
sudo apt install --yes debian-keyring debian-archive-keyring apt-transport-https curl
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list
sudo chmod o+r /usr/share/keyrings/caddy-stable-archive-keyring.gpg
sudo chmod o+r /etc/apt/sources.list.d/caddy-stable.list
sudo apt update
sudo apt install caddy
Verify the service is alive:
systemctl is-active caddy
caddy version
On a fresh install Caddy serves a placeholder page. That placeholder is your proof the service is running — it will be replaced by your real config in the next step.
Step 2: Point DNS and open the firewall
Before touching the config, confirm DNS actually resolves to your box. From your own machine:
dig +short example.com
If that does not return your VPS IP, stop here — no ACME challenge can succeed until DNS is right, and every failed attempt burns you closer to Let’s Encrypt’s rate limits.
Now open the two ports Caddy needs. If you use UFW:
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw reload
sudo ufw status numbered
Step 3: Your first Caddy reverse proxy
The config lives at /etc/caddy/Caddyfile. Replace its contents with your domain and your app’s port:
example.com {
reverse_proxy 127.0.0.1:3000
}
Two commands, in order. First validate, then reload:
sudo caddy fmt --overwrite /etc/caddy/Caddyfile
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
caddy validate is the habit that saves you: a broken Caddyfile reloaded into a running service will refuse the new config, but validating first means you find typos before anything touches production.
Visit https://example.com. You should see your app, on a valid certificate, with plain HTTP requests automatically redirecting to HTTPS. You configured nothing about TLS. That is the whole point: Caddy activates automatic HTTPS the moment it sees a public domain name in your config, obtains the certificate in the background, and renews it before expiry. If your CA of choice is unavailable it falls back — by default Caddy tries Let’s Encrypt and then ZeroSSL.
Watch the certificate happen
If you want to see the issuance in real time, reload and tail the service log in another terminal:
sudo journalctl -u caddy -f
You will see Caddy attempt the HTTP and TLS-ALPN challenges and then start serving HTTPS. Certificate management runs in the background and never blocks startup — the server comes up first and the certificates follow, with retries on failure.
Step 4: Run several apps with subdomains
The pattern scales by adding site blocks. Each one gets its own automatically-managed certificate:
example.com {
reverse_proxy 127.0.0.1:3000
}
api.example.com {
reverse_proxy 127.0.0.1:8000
}
grafana.example.com {
reverse_proxy 127.0.0.1:3001
}
Point each subdomain’s A record at the same VPS IP, reload, and Caddy provisions a certificate per name. WebSocket traffic proxies transparently — no extra Upgrade header dance like Nginx requires. If a backend needs the original Host header or client IP, add them explicitly:
api.example.com {
reverse_proxy 127.0.0.1:8000 {
header_up Host {host}
header_up X-Real-IP {remote_host}
header_up X-Forwarded-For {remote_host}
header_up X-Forwarded-Proto {scheme}
}
}
Path-based routing instead of subdomains
If you would rather serve everything from one domain, use handle blocks. This proxies /api/* to the backend while serving the frontend from everything else:
example.com {
handle /api/* {
reverse_proxy 127.0.0.1:8000
}
handle {
reverse_proxy 127.0.0.1:3000
}
}
If your backend does not expect the /api prefix, strip it with handle_path plus a URI rewrite:
example.com {
handle_path /api/* {
uri strip_prefix /api
reverse_proxy 127.0.0.1:8000
}
handle {
reverse_proxy 127.0.0.1:3000
}
}
handle keeps the path as-is; handle_path strips the matched prefix before proxying. Mixing them up is one of the most common Caddy head-scratchers, so when a backend returns 404s only behind the proxy, check this first.
Security headers and compression
Caddy makes response headers and encoding nearly free to add. This block is a solid baseline for most apps:
example.com {
encode zstd gzip
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options "nosniff"
X-Frame-Options "DENY"
Referrer-Policy "strict-origin-when-cross-origin"
-Server
}
reverse_proxy 127.0.0.1:3000
}
The -Server line removes Caddy’s Server response header, which is a small but easy information-hiding win. encode zstd gzip compresses responses for clients that support it. Note that Caddy already sends HSTS-worthy redirects by default; the explicit header above pins the policy so browsers remember it.
Logs that are actually useful
By default Caddy logs to the systemd journal, which is fine for debugging but noisy for access analysis. Add a structured access log per site:
example.com {
log {
output file /var/log/caddy/example-access.log
format json
}
reverse_proxy 127.0.0.1:3000
}
Make sure the log directory exists and is writable by the caddy user:
sudo mkdir -p /var/log/caddy
sudo chown caddy:caddy /var/log/caddy
sudo systemctl reload caddy
JSON access logs are trivially greppable with jq — for example, to list status codes per minute when something starts misbehaving. Keep the journal for errors and certificate events: sudo journalctl -u caddy --since "1 hour ago" | grep -i "error\|certificate" is usually the first thing you run when HTTPS breaks.
Wildcard certificates via the DNS challenge
If you serve many subdomains (*.example.com), a wildcard certificate is cleaner than dozens of individual ones. Wildcards cannot be validated over HTTP — they require the DNS challenge, where Caddy sets a temporary TXT record with your DNS provider. Two things follow:
- Caddy needs a DNS provider plugin (for example Cloudflare, DigitalOcean, Route53) compiled in. The stock package does not include them. Build a custom binary with the plugin you need — the project’s download page lets you assemble one, or use
xcaddyto build it yourself. - Your Caddyfile gets a
tlsblock with your provider credentials:
*.example.com {
tls {
dns cloudflare {env.CF_API_TOKEN}
}
reverse_proxy 127.0.0.1:3000
}
The DNS challenge needs no open ports at all — the server requesting the certificate does not even have to be publicly reachable. That makes it the right choice for boxes behind NAT or internal services that still need publicly-trusted certs.
Quick sanity test checklist
After every Caddyfile change, run through these before declaring victory:
- Config valid?
sudo caddy validate --config /etc/caddy/Caddyfile— zero output means zero problems. - Reloaded?
sudo systemctl reload caddyreloads config without dropping connections. Editing the file alone changes nothing. - HTTPS working?
curl -vI https://example.com— look forHTTP/2 200and a valid cert chain. - Backend reachable?
curl -v 127.0.0.1:3000from the VPS itself. If the proxy returns a 502, the problem is almost always the backend, not Caddy. - Certificate events?
sudo journalctl -u caddy -fwhile reloading — watch for challenge failures.
Troubleshooting the common failures
| Symptom | Likely cause | Fix |
|---|---|---|
| Browser shows a certificate warning or connection refused | DNS does not point at the VPS, or ports 80/443 are blocked | Fix the A record, open the ports, then reload and watch the journal |
| “too many certificates already issued” in the logs | You hit Let’s Encrypt rate limits while testing | Point Caddy at the staging ACME endpoint while experimenting, and test against it until the config is stable |
| Reload succeeds but nothing changed | You edited the file but never reloaded, or another process holds port 80/443 | systemctl reload caddy; check ss -lntup | grep -E ':(80|443)' for squatters |
| 502 Bad Gateway from the proxy | Backend is down or listening on a different interface | curl the backend directly from the VPS; check it binds to 127.0.0.1, not just ::1 |
| Backend sees wrong Host or client IP | App expects forwarded headers | Add the header_up block from the subdomains section |
When Caddy is the wrong tool
Caddy is not a universal answer. Stick with (or keep) Nginx when you are operating at load-balancer scale with tuned worker_processes and connection pooling, when your deployment tooling already generates Nginx configs, or when you need modules from the Nginx ecosystem that have no Caddy equivalent. Both are fine reverse proxies; Caddy just removes the certificate busywork. For the Nginx route, use the Nginx reverse proxy guide instead.
Further Reading & References
- Install — Caddy Documentation — the official Debian/Ubuntu package instructions used in this guide.
- Reverse proxy quick-start — Caddy Documentation — the official minimal reverse proxy walkthrough, including the
caddy reverse-proxyone-liner. - Automatic HTTPS — Caddy Documentation — how activation, challenges, rate limits, and the staging endpoint work.
- Using Caddy as a Reverse Proxy with Automatic HTTPS (dev.to) — a community walkthrough with a home-lab Caddyfile example.
- Cloudflare vs Nginx vs Caddy: Reverse Proxy for Developers in 2026 (dev.to) — an honest comparison of where each option wins.
- Deploy a Reverse Proxy with Caddy in VPS (perlod.com) — step-by-step VPS setup including multi-subdomain hosting.
- Understanding How Caddy Works with Multiple Subdomains as a Reverse Proxy (Caddy Community) — the classic forum thread on multi-site blocks.
- Caddy Reverse Proxy quick reference (GitHub) — a handy syntax cheat sheet for matchers, headers, and wildcard TLS.



