4.3 KiB
Caddy Reverse Proxy for External-Facing Services
Pattern
When a Docker service needs a public HTTPS URL:
sign.itpropartner.com {
reverse_proxy 127.0.0.1:3000
}
Installation
apt-get install -y caddy
Key Rules
- Port 443 must be free — stop Tailscale Serve if it's using 443
- Service binds to 127.0.0.1 — never expose Docker port to the internet
- Caddy handles automatic Let's Encrypt certificates — no certbot needed
- Caddy must run as root or have
CAP_NET_BIND_SERVICE
Verification
curl -s -o /dev/null -w "HTTPS %{http_code}" https://yourdomain.com
Testing
caddy validate --config /etc/caddy/Caddyfile
systemctl restart caddy
systemctl status caddy
Multi-Domain Configuration (validated Jul 7, 2026)
When serving multiple subdomains from a single server:
# ── Signing ───────────────────────────────────────────────────────────
sign.itpropartner.com {
reverse_proxy 127.0.0.1:3000
}
# ── Core API & JSON data ─────────────────────────────────────────────
core.itpropartner.com {
header Access-Control-Allow-Origin "*"
@health path /health
handle @health {
respond "OK" 200
}
@vehicles path /vehicles.json
handle @vehicles {
root * /var/www/static
file_server
}
}
# ── App Portal ────────────────────────────────────────────────────────
app.itpropartner.com {
reverse_proxy 127.0.0.1:8081
}
Writing the Caddyfile
The write_file tool refuses to write to /etc/caddy/Caddyfile (sensitive system path). Use terminal with a heredoc or Python:
python3 -c "
content = '''your caddyfile content here'''
with open('/etc/caddy/Caddyfile', 'w') as f:
f.write(content)
"
Then:
caddy fmt --overwrite /etc/caddy/Caddyfile
systemctl restart caddy # full restart needed, reload may skip new hosts
Important: systemctl reload caddy only applies changes to existing hosts. New hosts (core.itpropartner.com, app.itpropartner.com) added to the Caddyfile require a full restart (systemctl stop caddy && systemctl start caddy) to be picked up. Otherwise only the previously-loaded hosts work.
Verification
Check which hosts Caddy is actually serving:
curl -s http://localhost:2019/config/ | python3 -c "
import sys,json
d=json.load(sys.stdin)
for s in d.get('apps',{}).get('http',{}).get('servers',{}).get('srv0',{}).get('routes',[]):
for h in s.get('match',[]):
if 'host' in h:
print('Host:', h['host'])
"
New host TLS provisioning delay
When a new domain is added to the Caddyfile, Let's Encrypt certificate provisioning runs on the first HTTPS request. The initial request may fail with tlsv1 alert internal error while the cert is being issued. After 10-30 seconds, retry succeeds. This is normal behavior.
Port 443 conflict: Caddy vs Tailscale Serve
Tailscale Serve claims port 443 for its internal HTTPS proxy. If Caddy needs port 443 to serve public domains (sign.itpropartner.com, core.itpropartner.com, app.itpropartner.com), run tailscale serve off to free port 443. This disables all Tailscale Serve routes (vaultwarden.tailc2f3b0.ts.net, app1.tailc2f3b0.ts.net).
After freeing port 443, Caddy's auto-https system requests certificates for all configured hosts and serves them on 443. HTTP to HTTPS redirects are automatic.
Deployed Services
| Service | Domain | Port | Caddyfile Entry |
|---|---|---|---|
| DocuSeal | sign.itpropartner.com | 127.0.0.1:3000 | reverse_proxy 127.0.0.1:3000 |
| Vehicle JSON | core.itpropartner.com | /var/www/static | file_server (static) |
| Health check | core.itpropartner.com | inline | respond "OK" |
| Portal mockups | app.itpropartner.com | 127.0.0.1:8081 | reverse_proxy 127.0.0.1:8081 |
Cloudflare Tunnel Alternative
If port 443 is occupied and can't be freed:
cloudflared tunnel login # opens browser URL
cloudflared tunnel create <name>
cloudflared tunnel route dns <name> <domain>
Requires the domain to be on Cloudflare's DNS.