How to Set Up a Reverse Proxy for Your Homelab with Caddy
Why this matters
Every self-hosted service wants port 80 and port 443. You only have one of each. A reverse proxy is the thing that sits in front and says: “requests for app1.example.com go to container A, requests for app2.example.com go to container B.”
Caddy is the easiest one to get right, because it handles HTTPS for you.
What I tested on
- OS: Debian 13 (trixie)
- Caddy: 2.8.x
- Setup: 9 services behind one Caddy instance
- Duration: running for 12 months at the time of writing
⚠️ If you expose a service directly to the internet without a proxy or a firewall rule, you are one vulnerability away from a bad day. This guide assumes a domain name you control.
Step 1: Install Caddy
sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https
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 apt update
sudo apt install -y caddy
Step 2: The Caddyfile
# /etc/caddy/Caddyfile
example.com {
redir https://www.example.com{uri}
}
www.example.com {
reverse_proxy localhost:8080
}
uptime.example.com {
reverse_proxy localhost:3001
}
docs.example.com {
reverse_proxy localhost:8000
}
localhost works because Caddy runs on the host and talks to your containers’ published ports. If Caddy is also in Docker, use the container name instead.
Step 3: Point DNS at your server
In your DNS provider (Cloudflare is the common choice), add A records:
| Name | Type | Value |
|---|---|---|
| @ | A | your server IP |
| www | A | your server IP |
| uptime | A | your server IP |
| docs | A | your server IP |
Each subdomain you add needs its own A record pointing at the same IP.
Step 4: Let Caddy handle HTTPS
That’s it. Caddy requests a certificate from Let’s Encrypt, renews it automatically, and redirects HTTP to HTTPS. You don’t configure any of that — it’s the default behavior when a domain is in the Caddyfile.
Verify with systemctl status caddy and journalctl -u caddy -f if something fails. The most common failure is DNS not pointing at your server yet.
The mistakes I made
1. Forgetting the trailing dot on internal hostnames. Inside Docker, use the service name; outside, use localhost and the published port. Mixing them up causes “connection refused.”
2. Letting Caddy bind port 80 before the old web server stopped. Stop Apache/Nginx first, or Caddy won’t start.
3. Not setting up a fallback. One bad line in the Caddyfile takes every site down. Test with caddy validate before reloading.
4. Wildcard DNS. A single *.example.com A record looks clever but doesn’t work the way people expect — Caddy won’t issue a wildcard certificate without DNS-01 challenge configuration.
Certificate renewal: the failure nobody notices
The first certificate is the easy part. What bites is the renewal sixty days later, and it fails in a way you will not see until a browser screams at you.
Three things break it, in order of how often I have seen them:
Port 80 closed. Most ACME challenges use HTTP-01, which means the certificate authority has to reach your box on port 80. If your ISP blocks it, or a firewall rule was tightened during a cleanup, renewal fails silently while the current certificate keeps working. DNS-01 avoids this entirely — Caddy supports it with a DNS plugin, and it is the right choice if port 80 is not reliably reachable.
A DNS record that moved. If the A record now points at a different host, the challenge lands on the wrong machine. Caddy logs the ACME failure clearly; the fix is to check DNS before you check the config.
Rate limits after repeated failures. Let’s Encrypt limits failed validations per account per hostname. Five failures in an hour and you are waiting. Restart in a loop and you can lock yourself out for a day.
The habit that protects you: once a month, look at the certificate dates, not just the sites.
# what does the proxy think it is serving?
echo | openssl s_client -connect example.com:443 -servername example.com 2>/dev/null \
| openssl x509 -noout -dates
If the expiry is under 30 days and it is not renewing, you have a DNS or port problem, and you have time to fix it. When I ran Nginx with certbot, this check was a cron job I never wrote. With Caddy it is a dashboard I glance at — and that difference is most of why I switched. The head-to-head is in Caddy vs Nginx.
When Caddy is the wrong choice
Caddy is my default, not a universal answer. Four cases where I would install something else:
You need rate limiting at the edge. Caddy has no built-in equivalent of limit_req. Protecting a login endpoint from credential stuffing at the proxy layer means Nginx, or a Caddy build with a plugin, or putting something in front.
You serve media. HLS and RTMP belong on Nginx. Adding Caddy in front of a streaming stack buys nothing.
You have pathological routing. Rewrites, try_files fallbacks, header manipulation per path — Nginx’s directives handle it without plugins. Caddy’s matchers are clean but finite.
You need someone else to have solved it already. Nginx has twenty years of answers indexed. Caddy’s documentation is good; its long tail is shorter. At 1am with a broken site, that difference is real.
None of this argues against Caddy for the normal case: a handful of hostnames, TLS, reverse proxy to containers, done in fifteen lines. It argues against pretending one tool covers every case. If you are still deciding, read the comparison before you migrate six services.
Everything above assumes the containers already exist. If you are starting from nothing, the Docker Compose layout is the file structure these services live in, and the proxy is the last piece you add — not the first.
Pros and cons
Pros:
- Automatic HTTPS — this single feature saves more time than any other
- Config is readable; you can understand it a month later
- One place to enforce headers, auth, and rate limits
Cons:
- No GUI — it’s config-file-only, which some people dislike
- Caddy is younger than Nginx; enterprise features are thinner
- Advanced rewriting rules are harder to express than in Nginx
Best for / Skip if
Best for: homelabbers who want HTTPS without spending a weekend on it.
Skip if: you already know Nginx inside out and have existing configs. Don’t switch for the sake of it.
Alternatives
- Nginx — more modules, more guides, steeper config
- Nginx Proxy Manager — a web UI on top of Nginx; popular with beginners
- Traefik — dynamic, container-aware, great for Docker; complex to configure
Verdict
Caddy is the right default for a homelab because it removes the one part of reverse-proxying that isn’t fun: certificates. If you set up one service, you might skip it; set up three, and you’ll wish you had it.
References
- Caddy documentation — Caddyfile syntax and reverse proxy directives
- Automatic HTTPS in Caddy — how certificates are obtained and renewed
FAQ
Do I need a wildcard certificate?
No. One certificate per subdomain is simpler and works fine for a home setup.
Can I add basic auth?
Yes — `basicauth { uri }` inside a site block. But for anything serious, put authentication in front of the app itself.
What if I don't have a domain?
You can use `http://localhost` and edit your hosts file, but you won't get real HTTPS. For a home network, that's often acceptable.
How do I test the config before reloading?
`caddy validate -config /etc/caddy/Caddyfile`.