Files

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.