On your-app-host, Tailscale Serve owns port 443 and Apache stays on port 80 only. This split gives the site private HTTPS over the tailnet (Tailscale provisions the cert) while Apache stays a simple plain-HTTP origin.
The hard rule
Never let Apache bind to 443 on this host. Enabling
mod_ssl/ an SSL vhost makes Apache grab 443, which collides with Tailscale Serve and breaks the site. Keep Apache on*:80exclusively.
If you see the site break after an Apache change, check that nothing started listening on 443:
sudo ss -ltnp | grep ':443' # should be tailscaled, NOT apache2
sudo apachectl -S | grep 443 # should print nothing
How the split works
client ──HTTPS:443──> Tailscale Serve ──HTTP──> Apache :80 (vhost) ──> app
(TLS here) (plain, localhost)
Tailscale Serve terminates TLS and reverse-proxies to the local Apache:
# serve local Apache (port 80) over HTTPS on the tailnet
tailscale serve --bg 80
tailscale serve status
Why this layout
- No cert management in Apache — Tailscale issues and renews the cert.
- Private by default — only tailnet members reach
:443; good for internal/admin surfaces. - Simpler origin — Apache config is just name-based vhosts on
:80(see Apache Name-Based Virtual Hosts on One Server).
Contrast with the public sites
Public thelabsource.com hosts take the opposite-but-compatible approach: Cloudflare terminates TLS and a tunnel reaches Apache on :80. Either way the principle holds — something else owns 443, Apache owns 80.
Access note:
your-app-hostis reached via anyour-jump-hostSSH jump host.