How to Set Up Caddy as a Reverse Proxy (With Automatic HTTPS)
Put Caddy in front of your self-hosted apps for free automatic HTTPS. Caddyfile examples for one app, many apps, static sites, auth and Docker Compose.
· 5 min read
Contents
- Why use a reverse proxy?
- Prerequisites
- Step 1: The Compose file
- Step 2: Your first Caddyfile
- Common Caddyfile recipes
- Several apps on subdomains
- Redirect www to the bare domain
- Serve a static website
- Path-based routing (frontend and API on one domain)
- Password-protect an app
- Proxy to an app running outside Docker
- Security headers
- Reloading after changes
- Troubleshooting
- Caddy vs nginx vs Traefik
- FAQ
- Is Caddy free?
- Can I use Caddy without a domain?
- Does Caddy work behind Cloudflare?
- Further reading
A reverse proxy sits in front of your apps, receives every request on ports 80 and 443, and forwards it to the right service. Caddy is the easiest one to run because it gets and renews HTTPS certificates automatically — no Certbot, no cron jobs, no manual renewals.
This guide shows how to run Caddy with Docker Compose and gives copy-paste Caddyfile examples for the most common setups.
Why use a reverse proxy?
Without one, each app listens on its own port (:3001, :8080, :9000…), traffic is unencrypted, and every port is exposed to the internet. With a reverse proxy:
- Each app gets a clean URL like
status.example.cominstead of203.0.113.10:3001. - All traffic is encrypted with HTTPS.
- Only ports 80 and 443 need to be open; the apps stay private.
- You get one place for logs, compression, headers and access control.
Prerequisites
- A Linux server with Docker and Docker Compose. See Docker Compose for beginners.
- A domain name, with a DNS A record pointing each hostname (for example
app.example.com) to your server's public IP. Check withnslookup app.example.com. - Ports 80 and 443 open in your firewall. With ufw:
sudo ufw allow 80,443/tcp. Caddy needs port 80 to complete the certificate challenge.
Step 1: The Compose file
Create a folder for Caddy and your apps:
mkdir -p ~/stack && cd ~/stack
compose.yaml:
services:
caddy:
image: caddy:2
restart: unless-stopped
ports:
- "80:80"
- "443:443"
- "443:443/udp" # HTTP/3
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
whoami:
image: traefik/whoami # tiny test app that echoes request details
restart: unless-stopped
volumes:
caddy_data:
caddy_config:
Two important details:
caddy_datamust be a persistent volume. It stores your certificates. If you lose it, Caddy requests new ones on every restart and can hit the certificate authority's rate limits.- The
whoamiservice has noports:section. Only Caddy can reach it, through the internal Docker network, by its service name.
Step 2: Your first Caddyfile
Create Caddyfile next to compose.yaml:
app.example.com {
reverse_proxy whoami:80
}
That is the entire configuration. Start everything:
docker compose up -d
docker compose logs -f caddy
Within a few seconds the logs should show Caddy obtaining a certificate. Open https://app.example.com and you will see the whoami output over HTTPS. Requests to http:// are redirected to https:// automatically.
Common Caddyfile recipes
Several apps on subdomains
status.example.com {
reverse_proxy uptime-kuma:3001
}
notes.example.com {
reverse_proxy notes-app:8080
}
api.example.com {
reverse_proxy api:8000
}
Each block gets its own certificate. Add each service to the same compose.yaml (or the same Docker network) so Caddy can reach it by name.
Redirect www to the bare domain
www.example.com {
redir https://example.com{uri} permanent
}
example.com {
reverse_proxy web:3000
}
Serve a static website
example.com {
root * /srv/site
encode zstd gzip
file_server
}
Mount the site folder into the container with - ./site:/srv/site:ro.
Path-based routing (frontend and API on one domain)
example.com {
handle /api/* {
reverse_proxy api:8000
}
handle {
reverse_proxy web:3000
}
}
handle blocks are mutually exclusive and checked in order of specificity, so /api/... goes to the API and everything else to the frontend. Use handle_path instead of handle if the backend expects the /api prefix to be stripped.
Password-protect an app
Generate a password hash:
docker compose exec caddy caddy hash-password --plaintext 'choose-a-strong-password'
Then use it:
admin.example.com {
basic_auth {
admin $2a$14$REPLACE_WITH_THE_HASH_YOU_GENERATED
}
reverse_proxy admin-panel:8080
}
Older Caddy versions spell the directive basicauth; both work in current releases.
Proxy to an app running outside Docker
If an app runs directly on the host (for example on port 5000), use the host gateway:
caddy:
extra_hosts:
- "host.docker.internal:host-gateway"
legacy.example.com {
reverse_proxy host.docker.internal:5000
}
Security headers
example.com {
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options "nosniff"
Referrer-Policy "strict-origin-when-cross-origin"
-Server
}
reverse_proxy web:3000
}
Reloading after changes
After editing the Caddyfile, reload without downtime:
docker compose exec -w /etc/caddy caddy caddy reload
Validate first if you are unsure:
docker compose exec caddy caddy validate --config /etc/caddy/Caddyfile
Format the file neatly with caddy fmt --overwrite run the same way.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Certificate errors in logs | DNS not pointing to the server yet | Check the A record with nslookup, wait for DNS to update |
timeout during connect in logs | Port 80 or 443 blocked | Open them in ufw and in your provider's cloud firewall |
502 Bad Gateway | Caddy cannot reach the app | Use the service name and the app's internal port; make sure both share a network |
| Redirect loop | App also forces HTTPS, or a CDN proxy in front uses plain HTTP | Let Caddy handle HTTPS; set the CDN to full/strict TLS |
App generates http:// links | App does not trust proxy headers | Configure the app to trust X-Forwarded-Proto from the proxy |
Caddy vs nginx vs Traefik
| Caddy | nginx | Traefik | |
|---|---|---|---|
| Automatic HTTPS | Built in | Needs Certbot or similar | Built in |
| Config style | Short Caddyfile | Verbose config blocks | Labels on containers or YAML |
| Learning curve | Lowest | Medium | Medium |
| Best for | Small to medium self-hosting setups | High-traffic sites, fine-grained tuning | Dynamic container setups |
For most self-hosters with a handful of apps, Caddy's tiny config and automatic certificates make it the simplest choice.
FAQ
Is Caddy free?
Yes. Caddy is open source under the Apache 2.0 license, and the certificates it obtains from Let's Encrypt or ZeroSSL are free.
Can I use Caddy without a domain?
For local or LAN use, yes — Caddy can issue certificates from its own local certificate authority for names like app.localhost. Browsers will only trust them on machines where that local CA is installed. For public HTTPS you need a real domain.
Does Caddy work behind Cloudflare?
Yes. If Cloudflare's proxy is enabled, set its SSL mode to Full (strict). Caddy can still obtain certificates, or you can use a DNS challenge plugin.
Further reading
Keep reading
Self-Hosting on a Raspberry Pi: What Works and How to Set It Up
What a Raspberry Pi can realistically self-host, the hardware that makes it reliable, a step-by-step Docker setup and how to reach it securely from anywhere.
Docker Compose for Beginners: Self-Host Your First App
Learn Docker Compose step by step: install it, write your first compose.yaml, persist data with volumes, connect services and keep everything updated.
How to Back Up and Restore Docker Volumes the Right Way
Back up Docker named volumes and bind mounts safely, dump databases consistently, automate it with cron, copy backups off-site and test restores.