1.8 KiB
Caddy + Tailscale Port 443 Conflict
On servers running both Caddy (public HTTPS) and Tailscale (tailnet HTTPS), both services want port 443. This conflict causes Caddy's systemd service to fail with:
listen tcp :443: bind: address already in use
Root Cause
Tailscale binds 100.x.x.x:443 (its specific Tailnet interface IP). Caddy by default binds :443 (all interfaces), which includes the Tailscale IP. The kernel rejects Caddy's bind because the port is already claimed on that specific address.
The public interface IP (e.g. 152.53.192.33:443) is completely unused — only Tailscale's tailnet IP is occupied.
The Fix
Add a global default_bind directive at the top of Caddyfile to restrict Caddy to the public IP only:
{
default_bind <PUBLIC_IP>
}
Where <PUBLIC_IP> is the server's public-facing interface IP (e.g. 152.53.192.33 for Core).
After the fix
| Service | IP Binding | Purpose |
|---|---|---|
| Caddy | <PUBLIC_IP>:443 |
Public HTTPS for all domains |
| Tailscale | <TAILNET_IP>:443 |
Tailnet HTTPS only |
| Both | No conflict — different IPs |
Verification
# Caddy should start cleanly
systemctl start caddy
systemctl is-active caddy # → "active"
# Both should be bound without conflict
ss -tlnp | grep ':443 '
# Expected: two listeners — one on public IP (Caddy), one on tailnet IP (tailscaled)
# Public-facing sites should respond
curl -sS -o /dev/null -w '%{http_code}' https://core.itpropartner.com/health
# → 200
When This Pattern Applies
Any server running both:
- Caddy (or any reverse proxy) wanting
:443for Let's Encrypt + public HTTPS - Tailscale (which binds
:443on its tailnet interface)
This is expected on all servers where Tailscale is used for private infrastructure access AND Caddy serves public web traffic.