# Caddy Reverse Proxy for External-Facing Services ## Pattern When a Docker service needs a public HTTPS URL: ```caddy sign.itpropartner.com { reverse_proxy 127.0.0.1:3000 } ``` ## Installation ```bash 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 ```bash curl -s -o /dev/null -w "HTTPS %{http_code}" https://yourdomain.com ``` ## Testing ```bash 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: ```caddy # ── 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: ```bash python3 -c " content = '''your caddyfile content here''' with open('/etc/caddy/Caddyfile', 'w') as f: f.write(content) " ``` Then: ```bash 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: ```bash 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: ```bash cloudflared tunnel login # opens browser URL cloudflared tunnel create cloudflared tunnel route dns ``` Requires the domain to be on Cloudflare's DNS.