From ea144d02980fde4e5b174d1a7b727b81dca15c7d Mon Sep 17 00:00:00 2001 From: Twooey Date: Thu, 27 Aug 2026 12:22:58 -0400 Subject: [PATCH] Fix staging HTTPS: force tls internal, require a hostname not a bare IP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- README.md | 20 ++++++++++++++++++++ docker/caddy/Caddyfile.staging | 24 +++++++++++++++++++++++- 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 477e5c3..160b154 100644 --- a/README.md +++ b/README.md @@ -46,10 +46,30 @@ drops into the app container. ``` cp .env.example .env.staging # fill in SITE_DOMAIN, SITE_URL, DB_* +# SITE_DOMAIN must be a HOSTNAME, not a bare IP — see below make deploy ENV=staging ``` +**SITE_DOMAIN needs to be a hostname, not the VM's IP directly.** TLS SNI +(how Caddy picks which certificate to present) isn't sent for literal IP +connections per spec, so HTTPS to a bare IP fails at the handshake itself +no matter what Caddy does. Pick any hostname (e.g. `bookstore.lan`), add it +to `/etc/hosts` (or the Windows equivalent) on whatever machine you're +browsing from, pointing at the VM's LAN IP, and use that hostname as both +`SITE_DOMAIN` and in `SITE_URL`. + +Caddyfile.staging forces `tls internal` — Caddy's own self-signed CA, +issued locally with no external network calls — rather than letting Caddy +guess whether the name looks "public" (its automatic heuristic only +recognizes bare IPs and `localhost` as obviously-private; anything else, +including a made-up LAN hostname, it assumes might be real and tries +Let's Encrypt, which then fails). Your browser will show an untrusted-cert +warning once; click through it, or trust Caddy's root cert to skip that: +``` +docker compose -p bookstore-staging exec caddy cat /data/caddy/pki/authorities/local/root.crt +``` + Staging is `noindex`'d (`blog_public=0` plus the `X-Robots-Tag` header in Caddyfile.staging) so search engines won't index it — there's no basic-auth wall on top of that, since this box is LAN-only and not reachable from diff --git a/docker/caddy/Caddyfile.staging b/docker/caddy/Caddyfile.staging index 132c060..1090d81 100644 --- a/docker/caddy/Caddyfile.staging +++ b/docker/caddy/Caddyfile.staging @@ -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.