Fix staging HTTPS: force tls internal, require a hostname not a bare IP

Two real, verified findings from actually testing the "let Caddy issue a
self-signed cert automatically" approach against a literal IP:

1. TLS SNI is not sent for literal IP connections (out of spec — SNI's
   HostName type explicitly excludes IPs). Confirmed via openssl s_client:
   the handshake fails with a TLS-layer internal_error, even though Caddy
   logs "certificate obtained successfully" — Caddy has no way to select a
   cert without SNI. No Caddyfile config fixes this; the address has to be
   a hostname.

2. Caddy's automatic-HTTPS heuristic only treats bare IPs and "localhost"
   as obviously-private. Any other name — including a made-up LAN hostname
   like "bookstore-staging.test" — it assumes might be real and tries
   Let's Encrypt, which fails the same way staging.example.com did.
   Fixed by forcing `tls internal` explicitly in Caddyfile.staging,
   removing the guesswork entirely.

Verified end-to-end with curl --resolve (proper SNI, no real DNS/hosts
change needed to test): direct HTTPS 200, HTTP->HTTPS redirect chain 200.

README now documents the /etc/hosts requirement and how to trust Caddy's
root cert to skip the one-time browser warning.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-27 12:22:58 -04:00
co-authored by Claude Sonnet 5
parent f65581bc50
commit ea144d0298
2 changed files with 43 additions and 1 deletions
+23 -1
View File
@@ -1,6 +1,28 @@
http://{$SITE_DOMAIN} {
{$SITE_DOMAIN} {
encode gzip
# `tls internal` forces Caddy's own self-signed CA unconditionally,
# instead of guessing from the domain name — its automatic-HTTPS
# heuristic only recognizes specific patterns (bare IPs, "localhost")
# as "obviously private"; anything else (including a private-network
# hostname like this one) it assumes might be a real public domain and
# tries Let's Encrypt, which then fails exactly like it did for
# staging.example.com. This directive skips that guesswork entirely.
#
# SITE_DOMAIN must be an actual HOSTNAME here, not a bare IP: TLS SNI
# (which Caddy needs to pick a certificate) isn't sent for literal IP
# connections per spec — confirmed this fails at the TLS handshake
# itself (curl: "TLS alert, internal error") even though Caddy reports
# the cert as obtained successfully. Point this hostname at the VM's
# IP via /etc/hosts (or your router/local DNS) on whatever machine
# you're browsing from.
tls internal
# Browser will show an untrusted-cert warning once (self-signed, not
# from a public CA) — click through it, or trust Caddy's root cert
# (docker compose exec caddy cat /data/caddy/pki/authorities/local/root.crt)
# in your OS/browser once to skip that entirely.
# Launch gate: "No staging URLs are publicly indexed." Belt-and-suspenders
# with wp_option blog_public=0, which deploy.sh sets on staging. No
# basic-auth wall — this box is LAN-only, not reachable from outside.