Running three or four self-hosted apps on one server used to mean juggling ports in your head — `app.example.com:3000`, `wiki.example.com:8080` — and either li…
Running three or four self-hosted apps on one server used to mean juggling ports in your head — app.example.com:3000, wiki.example.com:8080 — and either living without TLS or wrestling Certbot into submission every 90 days. Caddy fixes both problems at once: it routes clean hostnames to the right backend and issues, renews, and installs Let's Encrypt certificates automatically, with zero manual cron jobs. If you're still hand-rolling nginx configs and remembering to renew certs, this is the upgrade that actually saves you time.
What you'll need
- A Linux server (Ubuntu 22.04/24.04 used here, but Caddy runs anywhere) with root or sudo access
- A domain you control, with DNS pointed at the server's public IP
- Ports 80 and 443 open on the server's firewall
- One or more apps already running locally on different ports (we'll use a Node app on
3000 and a static site on 8080 as examples)
- 15–20 minutes
Step 1: Install Caddy
Caddy has an official APT repo — don't grab it from the default Ubuntu repos, that version lags badly.
sudo apt install -y 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 apt update
sudo apt install caddy
Confirm it's running:
systemctl status caddy
Caddy starts immediately after install with a default config serving a placeholder page on port 80. That's expected — we're about to replace it.
Step 2: Point your DNS
Add an A record for each hostname you want to expose, pointing at your server's IP:
app.yourdomain.com A 203.0.113.10
wiki.yourdomain.com A 203.0.113.10
Give DNS a few minutes to propagate before moving on. Caddy's automatic HTTPS depends on being able to prove domain ownership over HTTP, so if DNS isn't resolving yet, certificate issuance will just fail quietly and retry.
Step 3: Write the Caddyfile
This is the entire reason people love Caddy — the config format is almost embarrassingly simple. Edit /etc/caddy/Caddyfile:
app.yourdomain.com {
reverse_proxy localhost:3000
}
wiki.yourdomain.com {
reverse_proxy localhost:8080
}
That's it. No listen 443 ssl, no certificate paths, no explicit HTTP-to-HTTPS redirect block. Caddy handles all of that the moment it sees a domain name in the config.
Step 4: Reload Caddy
sudo systemctl reload caddy
Or, if you want to validate the config before committing:
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
Watch the logs while it provisions certs:
sudo journalctl -u caddy -f
You should see ACME challenge activity and then something like certificate obtained successfully. Visit https://app.yourdomain.com — you'll get a valid, browser-trusted cert with no extra steps.
Step 5: Add basic hardening and headers
A bare reverse proxy works, but you generally want a few extras — forwarded headers for the backend to see real client IPs, and some standard security headers:
app.yourdomain.com {
reverse_proxy localhost:3000 {
header_up X-Real-IP {remote_host}
}
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options "nosniff"
X-Frame-Options "DENY"
Referrer-Policy "strict-origin-when-cross-origin"
}
}
If your backend app needs to know it's behind a proxy (Express, Django, etc.), make sure it trusts X-Forwarded-For / X-Real-IP — otherwise your access logs will show every request coming from 127.0.0.1, which is useless for debugging or abuse tracking later.
Step 6: Add basic auth or IP restriction for internal tools
For anything not meant to be fully public — an admin panel, a monitoring dashboard — Caddy can gate it without touching the app itself:
internal.yourdomain.com {
basicauth {
admin JDJhJDEwJHVWWm1pV2... # bcrypt hash, not plaintext
}
reverse_proxy localhost:9090
}
Generate the hash with:
caddy hash-password --plaintext 'your-password-here'
Never put the plaintext password directly in the Caddyfile — I've seen this in more than one "quick and dirty" setup that never got cleaned up.
What actually goes wrong
Certificate issuance hangs or fails silently. Nine times out of ten this is DNS — either the A record hasn't propagated, or port 80 is blocked by a firewall rule you forgot about (cloud provider security groups love to silently eat this). Run sudo ufw status and confirm 80/443 are open, then check with dig app.yourdomain.com from an external machine, not the server itself.
"Address already in use" on startup. Something else — usually Apache or nginx left over from a previous setup — is already bound to 80 or 443. sudo ss -tlnp | grep :80 will tell you who the culprit is.
Reverse proxy returns 502. The backend isn't actually listening where you think it is. Test directly on the server first: curl http://localhost:3000. If that fails, it's an app problem, not a Caddy problem — don't waste time debugging the proxy config for something that's broken upstream.
Caddyfile changes don't apply. reload re-reads the config without dropping connections, but if you made a syntax error, it'll fail and keep running the old config — check journalctl -u caddy for the actual parse error rather than assuming it took effect.
Rate limiting from Let's Encrypt. If you're testing repeatedly and hit too many certificates already issued for this exact set of identifiers, you've hit their weekly limit (5 per domain). Use Caddy's staging CA during testing to avoid burning real attempts:
{
acme_ca https://acme-staging-v02.api.letsencrypt.org/directory
}
Remove that global block once you're ready for a real cert.
Security and best-practice notes
- Keep Caddy itself updated —
sudo apt update && sudo apt upgrade caddy — since it's your TLS termination point and sits directly on the internet
- Don't expose internal services (databases, admin tools) without at least
basicauth or an IP allowlist block
- If you're running multiple apps behind Caddy, keep each one bound to
localhost only, not 0.0.0.0 — Caddy should be the only thing reachable externally
- Back up
/etc/caddy/Caddyfile and the /var/lib/caddy cert storage directory; losing the latter just means re-issuing certs, but it's an annoying inconvenience if you didn't plan for it
Wrapping up
Once you've got Caddy fronting a couple of apps, the natural next step is wiring it into a Docker Compose setup — Caddy has an official Docker image and plays nicely as the ingress layer for a multi-container stack, which is where this setup tends to end up once you're self-hosting more than two or three things.
Further reading:
Hashtags
#caddyserver #reverseproxy #letsencrypt #selfhosting #webserver #devops