Why a Reverse Proxy
Right now you're probably accessing your homelab services like this:
http://192.168.1.20:8989 (Sonarr)
http://192.168.1.20:7878 (Radarr)
http://192.168.1.21:5601 (Kibana)
http://192.168.1.10:8188 (ComfyUI)
This is ugly, hard to remember, and completely unencrypted. A reverse proxy fixes all three:
https://sonarr.yourdomain.com
https://radarr.yourdomain.com
https://kibana.yourdomain.com
But it's not just cosmetic. A reverse proxy also gives you:
- HTTPS everywhere — encrypted traffic even on your LAN
- Centralized access control — add authentication in one place
- Single point of entry — one IP to firewall, one place to check logs
- Easy migration — move a service to a different host/port without changing bookmarks
- HTTP/2 and compression — Caddy handles this automatically
Why Caddy Over Nginx or Traefik
Nginx is powerful but verbose. A simple reverse proxy requires 15+ lines of config, manual cert management with certbot, and a reload command every time you change anything.
Traefik is great for dynamic container environments but has a steep learning curve and YAML config that makes your eyes bleed.
Caddy gives you:
- Automatic HTTPS — obtains and renews Let's Encrypt certs with zero config
- Dead simple syntax — a reverse proxy is literally 3 lines
- Hot reload — edit the Caddyfile and Caddy picks up changes automatically
- Single binary — no modules, no dependencies, no plugin system to learn
- Sane defaults — HTTP/2, HSTS, OCSP stapling all enabled by default
The tradeoff: Caddy has fewer features than Nginx for complex use cases (rate limiting, load balancing at scale). For a homelab, you won't hit those limits.
Step 1: Create the Project Directory
Create a dedicated directory for Caddy's config and compose file:
mkdir -p ~/docker/caddy && cd ~/docker/caddyStep 2: Write the Docker Compose File
Create the compose file. This maps ports 80 (HTTP) and 443 (HTTPS) from the container to the host. The named volumes store certificates and config so they persist across restarts and updates:
cat > ~/docker/caddy/docker-compose.yml << 'COMPOSE'
services:
caddy:
image: caddy:latest
container_name: caddy
restart: unless-stopped
ports:
- "80:80"
- "443:443"
- "443:443/udp" # HTTP/3 (QUIC)
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
networks:
- proxy-net
networks:
proxy-net:
driver: bridge
volumes:
caddy_data:
caddy_config:
COMPOSEImportant Notes About Ports
Ports 80 and 443 MUST be free on the host machine. If something else is using them, Caddy can't start.
Check what's using those ports:
sudo ss -tulnp | grep -E ':80 |:443 '
Common culprits: Apache (apache2), Nginx (nginx), another Caddy instance, or a Docker container.
Stop the conflicting service:
sudo systemctl stop apache2 && sudo systemctl disable apache2
sudo systemctl stop nginx && sudo systemctl disable nginx
Or if it's a Docker container:
docker stop <container-name>
Step 3: Write Your First Caddyfile
The Caddyfile is where you define which domains point to which backend services. Start simple — proxy one service to test.
For INTERNAL-ONLY services (no public domain, LAN only), use the IP or hostname with an explicit port and the tls internal directive, which generates a self-signed cert:
For a service running on 192.168.1.20 port 8989 (like Sonarr):
cat > ~/docker/caddy/Caddyfile << 'CADDYFILE'
# Internal service example — self-signed cert, LAN only
# Replace the domain with your actual internal domain
# and the IP:port with your actual service
sonarr.homelab.local {
tls internal
reverse_proxy 192.168.1.20:8989
}
CADDYFILEStep 3b: For Public Domains
If you have a real domain (like yourdomain.com) with DNS pointing to your server's public IP, Caddy gets a real Let's Encrypt certificate automatically. No tls directive needed:
sonarr.yourdomain.com {
reverse_proxy 192.168.1.20:8989
}
Requirements for automatic public HTTPS:
- Port 80 must be reachable from the internet (for the ACME HTTP challenge)
- DNS for that domain must point to your server's public IP
- If you're behind NAT, port-forward 80 and 443 from your router to the Caddy host
If port 80 isn't reachable (common behind CGNAT or strict firewalls), you'll need DNS challenge validation instead — covered in the Cloudflare section below.
Step 4: Set Up DNS for Internal Domains
For .homelab.local (or whatever internal domain you choose), you need DNS records pointing to the Caddy host's IP. You have three options:
Option A — If you're running Technitium (see our DNS guide):
Add a zone for homelab.local in the Technitium dashboard, then add A records for each service pointing to the Caddy host's IP.
Option B — /etc/hosts on each client machine:
echo '192.168.1.12 sonarr.homelab.local radarr.homelab.local' | sudo tee -a /etc/hosts
Option C — Wildcard DNS:
Add a wildcard A record (*.homelab.local -> 192.168.1.12) in your DNS server. This way any subdomain automatically resolves to Caddy, and you only need to add the proxy rules in the Caddyfile.
Option A or C is preferred — Option B requires editing every client machine.
Step 5: Start Caddy
From the project directory:
cd ~/docker/caddy && docker compose up -dStep 5b: Verify It's Running
Check the container status and look for errors in the logs:
docker ps --filter name=caddy --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}'
echo '---'
docker logs caddy 2>&1 | tail -15Validate: What You Should See
The container should show "Up" status with ports 80, 443 mapped.
In the logs, you should see lines like:
{"level":"info","msg":"serving initial configuration"}
If you see errors:
- "bind: address already in use" — port 80 or 443 is taken. Go back to the port check step.
- "loading Caddyfile: ..." — syntax error in your Caddyfile. Check for typos.
- "error obtaining certificate" — DNS isn't pointing to this server, or port 80 isn't reachable from the internet.
Step 6: Test the Proxy
Test from the Caddy host itself first. For internal domains with tls internal, use -k to accept the self-signed cert:
curl -kI https://sonarr.homelab.local 2>/dev/null | head -5Expected Output
You should see an HTTP response from your backend service:
HTTP/2 200
content-type: text/html
...
If you get "Could not resolve host" — DNS isn't set up for that domain. Add it to /etc/hosts or your DNS server.
If you get "Connection refused" — Caddy isn't running or the port mapping is wrong. Check: docker ps
If you get a 502 Bad Gateway — Caddy is running but can't reach the backend. Check:
- Is the backend service actually running? curl http://192.168.1.20:8989 directly
- Can the Caddy container reach that IP? docker exec caddy ping 192.168.1.20
- Firewall on the backend host blocking connections from the Docker network?
Step 7: Add More Services
Add additional services to the Caddyfile. Each service gets its own block:
Edit ~/docker/caddy/Caddyfile and add more entries:
radarr.homelab.local {
tls internal
reverse_proxy 192.168.1.20:7878
}
kibana.homelab.local {
tls internal
reverse_proxy 192.168.1.21:5601
}
proxmox.homelab.local {
tls internal
reverse_proxy https://192.168.1.5:8006 {
transport http {
tls_insecure_skip_verify
}
}
}
Note the Proxmox example: it already runs on HTTPS with a self-signed cert, so we proxy to https:// and skip cert verification on the backend.
After editing, reload Caddy without restarting the container:
docker exec caddy caddy reload --config /etc/caddy/CaddyfileValidate the Reload
Check for errors after reloading:
docker logs caddy 2>&1 | tail -5Expected Output
You should see:
{"level":"info","msg":"config is unchanged"}
or
{"level":"info","msg":"serving initial configuration"}
If you see any error messages about the Caddyfile, fix the syntax and reload again. Caddy keeps running with the old config if the new one has errors — your existing proxies stay up.
Cloudflare DNS Challenge (For CGNAT/No Port 80)
If you can't open port 80 to the internet (CGNAT, strict ISP, no port forwarding), you can use Cloudflare's DNS challenge instead. This requires a custom Caddy build with the Cloudflare plugin.
Create a Dockerfile:
FROM caddy:builder AS builder
RUN xcaddy build --with github.com/caddy-dns/cloudflare
FROM caddy:latest
COPY --from=builder /usr/bin/caddy /usr/bin/caddy
Update docker-compose.yml to build from this Dockerfile instead of using the caddy:latest image:
build: .
# instead of image: caddy:latest
Then in your Caddyfile:
sonarr.yourdomain.com {
tls {
dns cloudflare {env.CLOUDFLARE_API_TOKEN}
}
reverse_proxy 192.168.1.20:8989
}
Set the CLOUDFLARE_API_TOKEN environment variable in docker-compose.yml. Create the token in Cloudflare dashboard > My Profile > API Tokens > Create Token > Edit Zone DNS.
This is a more advanced setup. If you can use port 80, stick with the default automatic HTTPS.
Trusting Internal Certificates
When using tls internal, Caddy generates self-signed certificates. Your browser will show a security warning for each service. To fix this permanently:
Export Caddy's internal CA certificate:
docker exec caddy cat /data/caddy/pki/authorities/local/root.crt > caddy-root-ca.crt
Install it as a trusted CA:
# On Ubuntu/Debian:
sudo cp caddy-root-ca.crt /usr/local/share/ca-certificates/
sudo update-ca-certificates
# On macOS:
# Open Keychain Access > File > Import Items > select caddy-root-ca.crt
# Double-click it > Trust > Always Trust
# On Windows:
# Double-click the .crt > Install Certificate > Local Machine > Trusted Root CAs
# In Firefox (uses its own cert store):
# Settings > Privacy & Security > Certificates > View Certificates > Import
After installing the CA cert, all Caddy internal certificates are trusted automatically. No more browser warnings.
How to Undo Everything
To remove Caddy and go back to direct IP:port access:
1. Stop and remove the container:
cd ~/docker/caddy && docker compose down
2. Remove the volumes (deletes all stored certs):
docker volume rm caddy_caddy_data caddy_caddy_config
3. Remove the CA cert if you installed it:
sudo rm /usr/local/share/ca-certificates/caddy-root-ca.crt
sudo update-ca-certificates
4. Remove DNS records you created for the proxy domains
5. Remove /etc/hosts entries if you added any
Your services continue running on their original IP:port — the reverse proxy was just a front door.