<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Caddy · XB Field Notes</title><link>https://hk.xbcatm.com/tags/caddy/</link><description>Illustrated field notes on HTTP, DNS, TLS, Caddy, SSH, releases, and recovery.</description><generator>Hugo</generator><language>en-us</language><atom:link href="https://hk.xbcatm.com/tags/caddy/index.xml" rel="self" type="application/rss+xml"/><item><title>Caddy automates HTTPS issuance and renewal</title><link>https://hk.xbcatm.com/posts/caddy-auto-https-handles-renewals/</link><pubDate>Thu, 21 Jan 2027 00:00:00 +0000</pubDate><guid>https://hk.xbcatm.com/posts/caddy-auto-https-handles-renewals/</guid><description>Caddy automates certificate management when names, validation paths, storage, and trust are configured correctly; renewal still needs operational monitoring.</description><content:encoded>&lt;p&gt;Caddy can obtain and renew managed certificates without an operator manually implementing ACME. That removes repetitive work, not the prerequisites for issuance or the need to notice failures. A valid certificate today proves neither that the next renewal can complete nor that every TLS hop uses the same certificate. Identify the names, validation route, storage, and termination boundary first.&lt;/p&gt;
&lt;h2 id="give-automatic-https-a-name"&gt;Give automatic HTTPS a name&lt;/h2&gt;
&lt;p&gt;Automatic HTTPS activates when Caddy knows a qualifying hostname or address through its configuration, subject to documented exceptions and overrides. A hostname in a Caddyfile site address is one common trigger. The file_server directive serves files; it is not, by itself, a request for a public certificate for an unspecified domain.&lt;/p&gt;
&lt;p&gt;The command-line distinction is concrete: bare &lt;code&gt;caddy file-server&lt;/code&gt; defaults to port 80. Supplying &lt;code&gt;--domain&lt;/code&gt; makes it attempt HTTPS for that hostname and changes the default listener to port 443. Neither command spelling proves that DNS, port forwarding, or certificate authorization is correct. Explicit HTTP configuration and manually loaded certificates also change automatic behavior.&lt;/p&gt;
&lt;p&gt;Local development has a different trust boundary. Caddy can use its local certificate authority for internal names. Clients must trust that authority through an appropriate installation process; a certificate working on one machine does not make it publicly trusted. Do not distribute a private CA&amp;rsquo;s signing key to solve a client trust problem.&lt;/p&gt;
&lt;h2 id="match-the-challenge-to-the-route"&gt;Match the challenge to the route&lt;/h2&gt;
&lt;p&gt;Caddy enables HTTP-01 and TLS-ALPN-01 by default. HTTP-01 validation enters through public port 80, while TLS-ALPN-01 uses port 443 and a special TLS handshake. Caddy can choose between available challenges. A failed HTTP challenge does not imply that DNS validation is the only possible alternative; inspect which methods and routes are actually available.&lt;/p&gt;
&lt;p&gt;A proxy or CDN can interfere with either path, but its presence does not automatically make issuance impossible. HTTP challenge traffic may be forwarded correctly even when the CDN terminates ordinary HTTPS. DNS-01 proves control through a TXT record and does not require the requesting server to accept inbound validation traffic. It needs a supported provider integration and appropriately scoped DNS credentials.&lt;/p&gt;
&lt;h2 id="when-it-breaks"&gt;When it breaks&lt;/h2&gt;
&lt;p&gt;Stale A or AAAA records, blocked ports, incorrect forwarding, or a changed CDN rule can break validation after the first issuance succeeded. Check both address families and the actual failed challenge in logs. Repeatedly deleting storage and retrying is a poor diagnostic: it removes reusable certificate state and can increase unnecessary issuance attempts.&lt;/p&gt;
&lt;p&gt;Persist Caddy&amp;rsquo;s data storage across replacement instances and protect it because it contains sensitive key material. Multiple instances need a storage arrangement with the coordination semantics Caddy expects. A directory merely bearing the same name on several machines is not shared storage. Test recovery of that configuration without exposing keys in logs or source control.&lt;/p&gt;
&lt;p&gt;Automation can fail while the existing certificate still works. Monitor renewal errors and the expiry of the certificate clients actually receive, leaving enough time for response. A successful origin renewal does not prove the CDN&amp;rsquo;s public certificate changed. Likewise, CDN TLS does not remove the need to secure and validate a separate HTTPS origin connection.&lt;/p&gt;
&lt;p&gt;Use a staging issuer for issuance experiments, and keep its untrusted test certificates out of production. DNS credential rotation needs a controlled validation check. Choose alert lead time around response requirements and certificate lifetime; a fixed thirty-day arithmetic example is not a specification of Caddy&amp;rsquo;s renewal schedule.&lt;/p&gt;
&lt;figure class="technical-diagram"&gt;
&lt;img loading="lazy" src="https://hk.xbcatm.com/visuals/posts-caddy-auto-https-handles-renewals.detail.en.svg" alt="Caddy prerequisites, persistent storage and served-certificate monitoring are separated from a thirty-day arithmetic alert model and its historical evidence limits." width="560" height="2352"&gt;
&lt;figcaption&gt;Caddy certificate lifecycle: mechanism and evidence boundaries.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;h2 id="check-it-quickly"&gt;Check it quickly&lt;/h2&gt;
&lt;p&gt;Save this as &lt;code&gt;expiry_alert.py&lt;/code&gt; and run &lt;code&gt;python3 expiry_alert.py&lt;/code&gt;. Use assertions enabled, without -O or PYTHONOPTIMIZE. It needs only the standard library and uses a fixed UTC observation time. The synthetic thirty-day threshold belongs to this operator alert, not to Caddy. No domain, certificate authority, network listener, key, or system trust setting is touched.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="nn"&gt;datetime&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timedelta&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timezone&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# An operator-chosen alert threshold, not Caddy&amp;#39;s renewal scheduler.&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;ALERT_BEFORE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;timedelta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;days&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;NOW&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2026&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tzinfo&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;timezone&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;utc&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;expiry_alert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;not_after&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;not_after&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tzinfo&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="kc"&gt;None&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;utcoffset&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="kc"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="ne"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;timezone-aware timestamps required&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;remaining&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;not_after&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;remaining&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;timedelta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;expired&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;remaining&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;ALERT_BEFORE&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;warning&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;ok&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;days&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;expected&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;fresh&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;80&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;ok&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;due&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;warning&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;boundary&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;warning&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;expired&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;expired&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;not_after&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;NOW&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;timedelta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;days&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;days&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;actual&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;expiry_alert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;not_after&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;NOW&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;actual&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;expected&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nb"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;: remaining=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;days&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;d alert=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;actual&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;expiry_alert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;NOW&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;NOW&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;expired&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;expiry_alert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;NOW&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tzinfo&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="kc"&gt;None&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;NOW&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="ne"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nb"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;naive timestamp rejected&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="ne"&gt;AssertionError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;missing timezone accepted&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;PASS: expiry alert boundaries; no certificate issuance tested&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The four printed cases are ok, warning, warning, and expired. Exactly thirty days is included in the warning interval; expiry itself is classified separately. The negative case supplies a naive expiry timestamp; the function also checks the observation time, but that separate branch is not exercised. These assertions verify the alert&amp;rsquo;s declared boundaries rather than relying on today&amp;rsquo;s date or a particular certificate lifetime.&lt;/p&gt;
&lt;p&gt;Retained exact records cover the unchanged arithmetic fixture; this source review did not rerun it. The exercise does not run Caddy, issue a certificate, or observe renewal. For deployment evidence, verify the served certificate and configured challenge route, then observe successful renewal using the intended persistent storage. Keep that operational evidence separate from this small, repeatable date-arithmetic check.&lt;/p&gt;
&lt;h2 id="further-reading"&gt;Further reading&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="https://hk.xbcatm.com/posts/tls-certificate-expiry-is-a-date-not-a-guess/"&gt;A TLS certificate expiry is a date you can read&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://hk.xbcatm.com/posts/check-https-beyond-a-lock/"&gt;Checking HTTPS beyond the lock icon&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="sources"&gt;Sources&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;a href="https://docs.python.org/3/reference/simple_stmts.html#the-assert-statement"&gt;Python assert statement&lt;/a&gt;, optimization omits assertions and any calls inside them, checked 2026-10-18.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;a href="https://caddyserver.com/docs/automatic-https"&gt;Caddy automatic HTTPS&lt;/a&gt;, activation, local trust, challenge selection, and storage, checked 2026-10-15.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;a href="https://caddyserver.com/docs/command-line#caddy-file-server"&gt;Caddy file-server command&lt;/a&gt;, default listener and the domain flag, checked 2026-10-15.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;a href="https://letsencrypt.org/docs/challenge-types/"&gt;Let&amp;rsquo;s Encrypt challenge types&lt;/a&gt;, validation ports, DNS automation, and scoped credentials, checked 2026-10-15.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;a href="https://letsencrypt.org/docs/rate-limits/"&gt;Let&amp;rsquo;s Encrypt rate limits&lt;/a&gt;, staging for experiments and issuance failures, checked 2026-10-15.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;</content:encoded></item><item><title>Ship a static site and prove the edge agrees</title><link>https://hk.xbcatm.com/tutorials/ship-a-static-release/</link><pubDate>Tue, 11 Aug 2026 00:00:00 +0000</pubDate><guid>https://hk.xbcatm.com/tutorials/ship-a-static-release/</guid><description>Build and stage a static artifact, switch a release pointer, and verify sampled public responses with explicit rollback and evidence limits.</description><content:encoded>&lt;p&gt;A release is complete when the required public-edge checks agree with the artifact you chose, not merely when upload exits zero. This tutorial connects &lt;strong&gt;build&lt;/strong&gt;, &lt;strong&gt;check&lt;/strong&gt;, &lt;strong&gt;publish&lt;/strong&gt;, and &lt;strong&gt;verify&lt;/strong&gt;, while distinguishing local checks from deployment changes. Publishing changes the server; only the final probes are read-only network observations.&lt;/p&gt;
&lt;p&gt;The commands assume Bash, GNU find/sort/sha256sum, rsync, Python 3, and the project&amp;rsquo;s pinned Hugo. Run build steps from the repository root. Before touching a live host, rehearse the &lt;a href="https://hk.xbcatm.com/decisions/adr-complete-directory-release/"&gt;directory-switch example&lt;/a&gt; in a disposable directory. Know the actual serve root, who owns deployment, the previous release target, and the rollback procedure. The &lt;code&gt;/srv&lt;/code&gt; layout below is an example requiring an already configured deployment, not something this repository automatically installs.&lt;/p&gt;
&lt;figure class="technical-diagram"&gt;&lt;img loading="lazy" src="https://hk.xbcatm.com/visuals/tutorial-release.detail.en.svg" alt="Build and validate one exact artifact, create its manifest after the last build, and verify a new empty stage. Preselection failure stops with the old release unchanged. Selection replaces the pointer. Afterwards, public responses are sampled against artifact files; failure leaves the new release selected until explicit rollback to the recorded previous target and repeat probes. Atomic namespace replacement does not make multiple requests coherent or guarantee power-loss durability. Disk checks, served bytes, TLS identity, expiry, DNS cache changes and independent backup each require their own evidence." width="720" height="4720"&gt;&lt;figcaption&gt;The pointer switch changes the failure boundary. Before it, stop with the old release; after it, roll back explicitly and verify again. Disk checks and edge samples establish different facts.&lt;/figcaption&gt;&lt;/figure&gt;
&lt;h2 id="1-build-one-artifact-from-recorded-inputs"&gt;1. Build one artifact from recorded inputs&lt;/h2&gt;
&lt;p&gt;Start from a clean source revision. Run the project build and record the installed generator version:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;set&lt;/span&gt; -eu
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;git status --short
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;git rev-parse HEAD
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;hugo version
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;make build
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;An empty short status supports a clean-tree claim; the command itself does not enforce one. Inspect any changes before calling the build a release of that commit. &lt;code&gt;make build&lt;/code&gt; writes &lt;code&gt;data/buildinfo.toml&lt;/code&gt; with the short commit and current UTC build time, then generates &lt;code&gt;public/&lt;/code&gt;. The rendered marker identifies the recorded inputs; it is not proof that a dirty checkout contained only that commit.&lt;/p&gt;
&lt;p&gt;Pinning Hugo and the vendored theme reduces drift, but the build-time field means ordinary builds at different times need not be byte-identical. Build the chosen release once, validate it, and transfer that exact artifact rather than rebuild independently on the server.&lt;/p&gt;
&lt;h2 id="2-check-the-complete-offline-contract"&gt;2. Check the complete offline contract&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;make check&lt;/code&gt; checks generated visuals, offline-pack freshness, internal links, script-free output, source links, and downloadable checksums for the current artifact. For the full repository gate, use:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;set&lt;/span&gt; -eu
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;make verify
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;make check-upstream
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The full gate also runs regression tests, bilingual-post checks, and review-ledger freshness, then rebuilds and checks output. Therefore create the release manifest only after the last build. Build success and a current review ledger do not establish technical correctness of every article or prove the public server uses the intended headers. Page counts and timings vary; retain actual output rather than comparing to a copied example count.&lt;/p&gt;
&lt;h2 id="3-name-the-artifact-bytes-in-a-manifest"&gt;3. Name the artifact bytes in a manifest&lt;/h2&gt;
&lt;p&gt;Before hashing, verify that &lt;code&gt;public/&lt;/code&gt; contains only directories and regular files, and stop all writers until staging is complete. The commands below do not create a snapshot or check for every special-file type. Keep the manifest outside &lt;code&gt;public/&lt;/code&gt; so it cannot hash itself. NUL-delimited filenames preserve spaces and other shell-sensitive characters:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;set&lt;/span&gt; -euo pipefail
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nv"&gt;manifest&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="k"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;pwd&lt;/span&gt;&lt;span class="k"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;/release.sha256&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="o"&gt;(&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nb"&gt;cd&lt;/span&gt; public
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; find . -type f -print0 &lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="nv"&gt;LC_ALL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;C sort -z &lt;span class="p"&gt;|&lt;/span&gt; xargs -0 sha256sum
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="o"&gt;)&lt;/span&gt; &amp;gt; &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$manifest&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;cd&lt;/span&gt; public &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; sha256sum -c &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$manifest&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;sha256sum &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$manifest&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;GNU checksum lines contain the digest before the filename. A successful &lt;code&gt;sha256sum -c&lt;/code&gt; verifies listed files, not that no extra files exist. A new empty release directory avoids stale destination files, but does not detect extra source files added after hashing. To establish complete regular-file inventory parity, recompute the staged manifest with the same path/sort/hash rules and compare the full lists before selection. Inspect symlinks separately: &lt;code&gt;find -type f&lt;/code&gt; does not include them, and serving an unintended symlink can expose files outside the intended root. Keep the manifest and its own digest with the release record; a checksum proves consistency against that manifest, not authenticity of an untrusted manifest.&lt;/p&gt;
&lt;h2 id="4-stage-and-select-a-complete-release"&gt;4. Stage and select a complete release&lt;/h2&gt;
&lt;p&gt;Assume Caddy already serves &lt;code&gt;/srv/www/example-site/current&lt;/code&gt;, which is a symlink into &lt;code&gt;releases/&lt;/code&gt;, with correct ownership and read permissions. The &lt;code&gt;releases&lt;/code&gt; parent must already exist. Serialize deployers and choose a unique release ID. Run this block as a standalone Bash script on the deployment machine, from the directory containing the validated &lt;code&gt;public/&lt;/code&gt; and &lt;code&gt;release.sha256&lt;/code&gt;. Check the artifact’s ownership and readable permissions against the deployment policy before running it. It establishes its own manifest path and fail-fast policy; a failed precondition, copy, or checksum must stop before pointer selection. It refuses symlinks inside the staged artifact and is a mutating deployment step.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;set&lt;/span&gt; -euo pipefail
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nv"&gt;base&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/srv/www/example-site
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nv"&gt;manifest&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="k"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;pwd&lt;/span&gt;&lt;span class="k"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;/release.sha256&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;test&lt;/span&gt; -f &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$manifest&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;test&lt;/span&gt; -d public
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nv"&gt;release_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;20261015T120000Z
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nv"&gt;release&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$base&lt;/span&gt;&lt;span class="s2"&gt;/releases/&lt;/span&gt;&lt;span class="nv"&gt;$release_id&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;test&lt;/span&gt; -L &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$base&lt;/span&gt;&lt;span class="s2"&gt;/current&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nv"&gt;previous&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;$(&lt;/span&gt;readlink &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$base&lt;/span&gt;&lt;span class="s2"&gt;/current&amp;#34;&lt;/span&gt;&lt;span class="k"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;mkdir &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$release&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt; &lt;span class="c1"&gt;# Fail if this release already exists.&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;rsync -a public/ &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$release&lt;/span&gt;&lt;span class="s2"&gt;/&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;cd&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$release&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; sha256sum -c &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$manifest&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nv"&gt;links&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;$(&lt;/span&gt;find &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$release&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt; -type l -print -quit&lt;span class="k"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; -n &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$links&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;Refusing symlinks in the staged artifact&amp;#34;&lt;/span&gt; &amp;gt;&lt;span class="p"&gt;&amp;amp;&lt;/span&gt;&lt;span class="m"&gt;2&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nb"&gt;exit&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# Ownership and readable permissions must match the deployment policy.&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;python3 - &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$base&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$release_id&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt; &lt;span class="s"&gt;&amp;lt;&amp;lt;&amp;#39;PYTHON&amp;#39;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s"&gt;from pathlib import Path
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s"&gt;import os, sys, uuid
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s"&gt;base = Path(sys.argv[1])
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s"&gt;release = base / &amp;#34;releases&amp;#34; / sys.argv[2]
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s"&gt;current = base / &amp;#34;current&amp;#34;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s"&gt;if not current.is_symlink() or not release.is_dir():
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s"&gt; raise SystemExit(&amp;#34;Expected current symlink and a complete release directory&amp;#34;)
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s"&gt;pending = base / (&amp;#34;.current-&amp;#34; + uuid.uuid4().hex)
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s"&gt;pending.symlink_to(os.path.relpath(release, base), target_is_directory=True)
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s"&gt;os.replace(pending, current)
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s"&gt;print(&amp;#34;selected:&amp;#34;, os.readlink(current))
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s"&gt;PYTHON&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;&amp;#39;Previous target: %s\n&amp;#39;&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$previous&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The symlink replacement uses one same-filesystem rename because the pending link and &lt;code&gt;current&lt;/code&gt; share a parent. An ordinary &lt;code&gt;mv new existing-directory&lt;/code&gt; does not exchange non-empty directories. Do not convert an existing real-directory serve root with this snippet; plan that migration separately. Validate the actual server&amp;rsquo;s root resolution and filesystem behavior in staging.&lt;/p&gt;
&lt;p&gt;The old target stays on disk, but that alone does not make an old asset URL reachable after switching. Retain fingerprinted asset bytes in the newly served namespace or a stable asset store. Multiple browser requests can span releases, and caches may keep old HTML. Atomic namespace replacement is not cross-request snapshot isolation or a power-loss durability guarantee.&lt;/p&gt;
&lt;h2 id="5-verify-delivered-bytes-and-policy"&gt;5. Verify delivered bytes and policy&lt;/h2&gt;
&lt;p&gt;After switching, compare a sampled public body with the corresponding artifact file, using a fixed representation selection:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;set&lt;/span&gt; -euo pipefail
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nv"&gt;url&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;https://www.xbcatm.com/
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nv"&gt;status&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;$(&lt;/span&gt;curl -sS --max-time &lt;span class="m"&gt;20&lt;/span&gt; -H &lt;span class="s1"&gt;&amp;#39;Accept-Encoding: identity&amp;#39;&lt;/span&gt; &lt;span class="se"&gt;\
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -D served.headers -o served.html -w &lt;span class="s1"&gt;&amp;#39;%{http_code}&amp;#39;&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$url&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="k"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;test&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$status&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="m"&gt;200&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;cmp public/index.html served.html
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;sh public/downloads/check-http-headers.sh &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$url&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Run this block from the same artifact directory used for staging. The header helper is the trusted copy inside that artifact; a source checkout is not required for this step. &lt;code&gt;cmp&lt;/code&gt; succeeding establishes exact parity for this homepage sample. An unexpected redirect, proxy-generated 200 error page, CDN transformation, or encoded response needs investigation. Inspect &lt;code&gt;served.headers&lt;/code&gt;, including &lt;code&gt;Content-Encoding&lt;/code&gt;, cache age, CSP, and content type. The header helper prints responses and final status; it does not enforce all your expectations.&lt;/p&gt;
&lt;p&gt;Repeat for Chinese content, a nested article, CSS, an SVG, a download, redirects, and the error route. Document the URL-to-file mapping, including directory indexes. The shown listed-file check establishes consistency for those files, not a complete staged inventory or what all public cache nodes deliver. A complete edge claim requires broader retrieval and representation-aware checks; a few probes remain samples.&lt;/p&gt;
&lt;p&gt;If a post-switch check fails, the new release is still selected until you act. Use a new temporary symlink targeting the recorded &lt;code&gt;previous&lt;/code&gt; value, atomically replace &lt;code&gt;current&lt;/code&gt; again, and repeat the probes. Do not merely move the new tree away, and do not delete evidence or the previous release while diagnosing.&lt;/p&gt;
&lt;h2 id="6-check-tls-identity-and-the-expiry-window-separately"&gt;6. Check TLS identity and the expiry window separately&lt;/h2&gt;
&lt;p&gt;From the repository root, the expiry helper is:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;sh static/downloads/check-tls-expiry.sh www.xbcatm.com &lt;span class="m"&gt;30&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;It extracts a certificate, prints dates, and checks the remaining expiry interval. It is not a full TLS identity test: its current implementation does not make hostname/trust verification failure fatal. Follow the &lt;a href="https://hk.xbcatm.com/tutorials/audit-a-public-edge/"&gt;public-edge trust tutorial&lt;/a&gt; for an explicit verified handshake and SAN check. Do not infer trust-chain or revocation success from “valid for at least 30 days.”&lt;/p&gt;
&lt;p&gt;A near-expiry certificate is a separate release concern. Preserve the actual &lt;code&gt;notAfter&lt;/code&gt;, trust result, and client environment rather than copying a historical issuer or date into the release log.&lt;/p&gt;
&lt;h2 id="7-include-the-dns-cache-window-when-dns-changes"&gt;7. Include the DNS cache window when DNS changes&lt;/h2&gt;
&lt;p&gt;A content-only release need not change DNS. If the host or edge changes, preserve service at the old destination while caches expire. Lowering TTL at the moment of the change does not shorten entries already cached with the old TTL. Resolver serve-stale behavior, negative caching, and client caches can complicate the window; TTL is not a universal deadline by which every client has moved. CAA changes affect issuance policy, not routing to the new content.&lt;/p&gt;
&lt;h2 id="8-keep-a-rehearsed-restore-path"&gt;8. Keep a rehearsed restore path&lt;/h2&gt;
&lt;p&gt;The previous directory is useful for fast rollback, but it is not an independent backup. A disk failure, accidental cleanup, or compromised deployment account may affect every local release. Retain source, build inputs, configuration, and an appropriate separate artifact backup; rehearse restoring them in isolation. One successful rehearsal is evidence about that recovery path at that time, not a permanent guarantee for later changes.&lt;/p&gt;
&lt;h2 id="9-record-the-outcome-precisely"&gt;9. Record the outcome precisely&lt;/h2&gt;
&lt;p&gt;Keep the source revision and clean-tree status, build metadata and tool versions, manifest digest, selected and previous targets, probe timestamps, HTTP/TLS observations, and any rollback. State whether verification covered samples, all mapped URLs, or multiple vantage points. If a gate failed, record the failure and recovery rather than “it worked.”&lt;/p&gt;
&lt;p&gt;Before selection, a failure can leave the old release untouched. After selection, recovery is an explicit operation with its own verification. This distinction makes the release path auditable without pretending every failure automatically restores the old tree.&lt;/p&gt;
&lt;h2 id="sources"&gt;Sources&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;a href="https://download.samba.org/pub/rsync/rsync.1"&gt;rsync manual&lt;/a&gt;, archive copying and source trailing-slash semantics, checked 2026-10-15.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;a href="https://www.rfc-editor.org/rfc/rfc8767.html"&gt;RFC 8767: Serving Stale Data&lt;/a&gt;, DNS serve-stale behavior beyond TTL expiry, checked 2026-10-15.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;a href="https://manpages.debian.org/trixie/coreutils/sha256sum.1.en.html"&gt;GNU sha256sum manual, Debian package&lt;/a&gt;, checksum output and verification flags, checked 2026-10-15.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;a href="https://docs.python.org/3/library/os.html#os.replace"&gt;Python &lt;code&gt;os.replace&lt;/code&gt;&lt;/a&gt;, atomic replacement and filesystem limits, checked 2026-10-15.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;a href="https://caddyserver.com/docs/caddyfile/directives/file_server"&gt;Caddy &lt;code&gt;file_server&lt;/code&gt;&lt;/a&gt;, root and symlink handling, checked 2026-10-15.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;a href="https://curl.se/docs/manpage.html"&gt;curl manual&lt;/a&gt;, status capture, headers, and content decoding, checked 2026-10-15.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;a href="https://docs.openssl.org/3.0/man1/openssl-s_client/"&gt;OpenSSL &lt;code&gt;s_client&lt;/code&gt;&lt;/a&gt;, explicit certificate verification, checked 2026-10-15.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;a href="https://reproducible-builds.org/docs/definition/"&gt;Reproducible Builds: definitions&lt;/a&gt;, artifact identity versus reproducibility, checked 2026-10-15.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;</content:encoded></item><item><title>Security headers should describe the actual page</title><link>https://hk.xbcatm.com/notes/security-headers-should-describe-the-actual-page/</link><pubDate>Thu, 23 Jul 2026 00:00:00 +0000</pubDate><guid>https://hk.xbcatm.com/notes/security-headers-should-describe-the-actual-page/</guid><description>A compact policy is safer when it matches the resources a static page really loads.</description><content:encoded>&lt;p&gt;Security headers are not a substitute for secure code, but they make the browser enforce useful boundaries. The policy should describe the page that is actually served, not a generic template copied from another project.&lt;/p&gt;
&lt;figure class="technical-diagram"&gt;&lt;img loading="lazy" src="https://hk.xbcatm.com/visuals/security-layers.detail.en.svg" alt="The actual HTML, local stylesheet and favicon determine CSP resource allowances. Content-Security-Policy enforces; Report-Only reveals violations and needs separate reporting setup. Deliver CSP as a header: frame-ancestors is ignored in meta and has no default-src fallback, form-action needs its own directive, and style-src self alone does not allow inline styles. HSTS is learned from valid HTTPS responses; includeSubDomains at www.example.test covers that host and descendants, not example.test or api.example.test. Check normal, missing and asset responses and verify enforcement in a real browser. No browser test is claimed." width="720" height="3460"&gt;&lt;figcaption&gt;Treat headers as a deployment contract: match actual resources, distinguish enforced and report-only policy, and verify exact host scope and browser behavior.&lt;/figcaption&gt;&lt;/figure&gt;
&lt;h2 id="start-from-the-resource-graph"&gt;Start from the resource graph&lt;/h2&gt;
&lt;p&gt;If a site serves HTML, a local stylesheet, a favicon, and no executable JavaScript, a narrow policy can be understandable:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Content-Security-Policy: default-src &amp;#39;self&amp;#39;; base-uri &amp;#39;self&amp;#39;; form-action &amp;#39;none&amp;#39;; frame-ancestors &amp;#39;none&amp;#39;; object-src &amp;#39;none&amp;#39;; script-src &amp;#39;none&amp;#39;; style-src &amp;#39;self&amp;#39;; img-src &amp;#39;self&amp;#39; data:
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The &lt;code&gt;script-src 'none'&lt;/code&gt; choice is only correct while the site does not require executable scripts. Adding search, comments, or an analytics tag changes the resource graph and should trigger a deliberate policy review.&lt;/p&gt;
&lt;h2 id="add-transport-and-framing-protections"&gt;Add transport and framing protections&lt;/h2&gt;
&lt;p&gt;For an HTTPS-only site, the following headers are common starting points:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Strict-Transport-Security: max-age=31536000; includeSubDomains
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;X-Content-Type-Options: nosniff
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;X-Frame-Options: DENY
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Referrer-Policy: strict-origin-when-cross-origin
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=(), usb=()
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Only enable HSTS with includeSubDomains when every subdomain is prepared to serve HTTPS. A header is a contract with future deployments, not merely a score in a scanner.&lt;/p&gt;
&lt;h2 id="check-behavior-not-just-presence"&gt;Check behavior, not just presence&lt;/h2&gt;
&lt;p&gt;Request a normal page, a missing page, and a static asset. Error responses should carry the same important security headers as successful responses. Also confirm that compression and caching do not change the content type or expose an internal path.&lt;/p&gt;
&lt;p&gt;The &lt;a href="https://hk.xbcatm.com/resources/"&gt;Caddy example&lt;/a&gt; in this site is a starting point, not a universal policy. Adapt the host names, certificate handling, and application routes to your own deployment.&lt;/p&gt;
&lt;p&gt;Deliver the CSP as a response header. &lt;code&gt;frame-ancestors&lt;/code&gt; is ignored in a meta policy and does not inherit &lt;code&gt;default-src&lt;/code&gt;; &lt;code&gt;form-action&lt;/code&gt; also needs its own directive. &lt;code&gt;style-src 'self'&lt;/code&gt; does not allow inline styles by itself, so test the generated theme, including dark mode. Keep &lt;code&gt;data:&lt;/code&gt; in &lt;code&gt;img-src&lt;/code&gt; only when the page actually needs data images.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;Content-Security-Policy-Report-Only&lt;/code&gt; can reveal violations during a rollout, but does not enforce the policy; a reporting endpoint needs separate configuration. Verify enforcement in a real browser as well as checking headers. HSTS is learned from valid HTTPS responses; with &lt;code&gt;includeSubDomains&lt;/code&gt; it also applies to descendants of the host that sets it: a policy on &lt;code&gt;www.example.test&lt;/code&gt; does not cover the parent &lt;code&gt;example.test&lt;/code&gt; or sibling &lt;code&gt;api.example.test&lt;/code&gt;. Gradually increase the lifetime after checking that exact scope.&lt;/p&gt;
&lt;h2 id="sources"&gt;Sources&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CSP"&gt;MDN: Content Security Policy guide&lt;/a&gt;, CSP directive names and syntax, checked 2026-07-23.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Strict-Transport-Security"&gt;MDN: &lt;code&gt;Strict-Transport-Security&lt;/code&gt;&lt;/a&gt;, HSTS directives and deployment cautions, checked 2026-07-23.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://caddyserver.com/docs/caddyfile/directives/header"&gt;Caddy &lt;code&gt;header&lt;/code&gt; directive&lt;/a&gt;, response header manipulation directives, checked 2026-07-23.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.w3.org/TR/CSP3/"&gt;W3C Content Security Policy Level 3&lt;/a&gt;, response-header delivery, directive fallbacks, framing, and report-only policies; current working draft, checked 2026-10-15.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.rfc-editor.org/rfc/rfc6797.html"&gt;RFC 6797 — HTTP Strict Transport Security&lt;/a&gt;, valid HTTPS delivery and host/descendant matching in sections 8.1–8.3, checked 2026-10-15.&lt;/li&gt;
&lt;/ul&gt;</content:encoded></item><item><title>Compression changes the selected HTTP representation</title><link>https://hk.xbcatm.com/notes/compression-changes-the-selected-http-representation/</link><pubDate>Mon, 29 Jun 2026 00:00:00 +0000</pubDate><guid>https://hk.xbcatm.com/notes/compression-changes-the-selected-http-representation/</guid><description>Treat content encoding, validators, and Vary as one observable response contract.</description><content:encoded>&lt;p&gt;Compression saves transfer bytes, but it also creates another representation of a resource. A client announces acceptable content codings with &lt;code&gt;Accept-Encoding&lt;/code&gt;; the server describes the applied coding with &lt;code&gt;Content-Encoding&lt;/code&gt;.&lt;/p&gt;
&lt;figure class="technical-diagram"&gt;&lt;img loading="lazy" src="https://hk.xbcatm.com/visuals/compression.detail.en.svg" alt="A negotiation fork selects identity bytes A or encoded bytes B, requiring different strong tags. Vary controls matching; weak tags have a qualified alternative. A separate pipeline keeps encoded Content-Length in saved response headers while curl decodes the body before an artifact-checksum comparison." width="720" height="2320"&gt;&lt;figcaption&gt;Compression selects representation bytes, so negotiation, cache matching, and validators must agree. Distinct coded bytes need distinct strong tags. Compare the decoded download with the artifact; an encoded Content-Length need not equal the decoded file size.&lt;/figcaption&gt;&lt;/figure&gt;
&lt;h2 id="make-negotiation-visible"&gt;Make negotiation visible&lt;/h2&gt;
&lt;p&gt;When a response can change according to &lt;code&gt;Accept-Encoding&lt;/code&gt;, include that request field in &lt;code&gt;Vary&lt;/code&gt;. This tells shared caches that a compressed response and an identity response are not interchangeable cache entries.&lt;/p&gt;
&lt;p&gt;Compare the two paths instead of checking only a browser request:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;curl -sS -D - -o /dev/null &lt;span class="se"&gt;\
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -H &lt;span class="s1"&gt;&amp;#39;Accept-Encoding: identity&amp;#39;&lt;/span&gt; https://www.example.test/
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;curl -sS --compressed -D - -o /dev/null &lt;span class="se"&gt;\
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; https://www.example.test/
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Inspect &lt;code&gt;Content-Encoding&lt;/code&gt;, &lt;code&gt;Vary&lt;/code&gt;, &lt;code&gt;Content-Length&lt;/code&gt; when present, and the validator. Strong entity tags must differ when content coding changes the representation bytes. A shared weak tag is possible for semantically equivalent variants, but it cannot support strong comparison. Check conditionals against the same negotiated representation.&lt;/p&gt;
&lt;h2 id="compress-appropriate-content"&gt;Compress appropriate content&lt;/h2&gt;
&lt;p&gt;Text formats such as HTML, CSS, JSON, XML, and SVG usually benefit from compression. Formats that are already compressed might not. Caddy&amp;rsquo;s &lt;code&gt;encode&lt;/code&gt; directive negotiates supported encodings and normally avoids encoding a response without an appropriate content type or minimum size.&lt;/p&gt;
&lt;p&gt;Do not add compression solely to satisfy a score. Measure a representative response, confirm that the edge emits the intended headers, and verify that downloadable files retain the correct content type and checksum after transfer decoding.&lt;/p&gt;
&lt;p&gt;With &lt;code&gt;--compressed&lt;/code&gt;, curl decodes the body automatically but leaves saved response headers unchanged. A reported &lt;code&gt;Content-Length&lt;/code&gt;, when present, describes encoded content, so it need not equal the size of the decoded download. Compare a checksum with the artifact only after content decoding; HTTP transfer framing is a separate layer. The option also does not guarantee compression: inspect the actual response, especially for small or already-compressed files.&lt;/p&gt;
&lt;h2 id="sources"&gt;Sources&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="https://www.rfc-editor.org/rfc/rfc9110.html"&gt;RFC 9110 — HTTP Semantics&lt;/a&gt;, content codings and &lt;code&gt;Vary&lt;/code&gt;, checked 2026-10-15.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://caddyserver.com/docs/caddyfile/directives/encode"&gt;Caddy &lt;code&gt;encode&lt;/code&gt; directive&lt;/a&gt;, content encoding selection and negotiation, checked 2026-10-15.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://curl.se/docs/manpage.html"&gt;curl manual&lt;/a&gt;, &lt;code&gt;--compressed&lt;/code&gt;, checked 2026-10-15.&lt;/li&gt;
&lt;/ul&gt;</content:encoded></item><item><title>Certificate expiry is a date, not a dashboard color</title><link>https://hk.xbcatm.com/notes/certificate-expiry-is-a-date-not-a-dashboard-color/</link><pubDate>Wed, 10 Jun 2026 00:00:00 +0000</pubDate><guid>https://hk.xbcatm.com/notes/certificate-expiry-is-a-date-not-a-dashboard-color/</guid><description>Check the certificate served for the real hostname and turn the remaining days into an actionable threshold.</description><content:encoded>&lt;p&gt;An HTTPS process can be healthy while the public certificate is close to expiry, issued for the wrong name, or served differently on another edge. A useful check asks the public endpoint for the certificate it would give to a real client.&lt;/p&gt;
&lt;figure class="technical-diagram"&gt;&lt;img loading="lazy" src="https://hk.xbcatm.com/visuals/tls-expiry.detail.en.svg" alt="A public hostname is sent as SNI to select a certificate. Observed subject, issuer, fingerprint, notBefore, and notAfter are evidence, not a validation result. Remaining time is compared with a service-specific threshold smaller than the normal certificate lifetime. Separate checks cover hostname, chain and trust store, notBefore, and revocation, with visible diagnostics. A pipeline can print a certificate while hiding verification errors, so its final exit code cannot establish TLS health." width="720" height="3050"&gt;&lt;figcaption&gt;Inspect the certificate a real client sees, then verify separately. Expiry alerts need a service-specific response threshold, handshake checks, renewal-failure alerts, and sampling of relevant edges and address families.&lt;/figcaption&gt;&lt;/figure&gt;
&lt;h2 id="include-the-hostname"&gt;Include the hostname&lt;/h2&gt;
&lt;p&gt;TLS virtual hosting depends on the server name. Pass the hostname as SNI and inspect the peer certificate:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nv"&gt;host&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;www.example.test
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;openssl s_client -connect &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$host&lt;/span&gt;&lt;span class="s2"&gt;:443&amp;#34;&lt;/span&gt; -servername &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$host&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt; &amp;lt;/dev/null 2&amp;gt;/dev/null &lt;span class="se"&gt;\
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;|&lt;/span&gt; openssl x509 -noout -subject -issuer -dates -fingerprint -sha256
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The command is read-only. It does not renew anything and it does not prove that every intermediate certificate is accepted by every client. It does give you the not-before, not-after, subject, issuer, and fingerprint of the certificate presented for that hostname.&lt;/p&gt;
&lt;h2 id="alert-on-a-threshold"&gt;Alert on a threshold&lt;/h2&gt;
&lt;p&gt;Choose a threshold that leaves time for a failed renewal, a DNS problem, and a human response. The exact number belongs to the service&amp;rsquo;s recovery target; a universal “30 days” rule is not a substitute for that decision. The companion &lt;a href="https://hk.xbcatm.com/resources/"&gt;TLS expiry check&lt;/a&gt; prints the expiry and exits non-zero when the remaining interval is below the threshold you pass.&lt;/p&gt;
&lt;p&gt;Run the check from outside the host when possible. A local process may see a different listener, certificate file, or proxy path than a visitor on the public network.&lt;/p&gt;
&lt;h2 id="separate-observation-from-verification"&gt;Separate observation from verification&lt;/h2&gt;
&lt;p&gt;SNI selects a virtual host; it does not verify that the certificate matches that name. This pipeline prints a certificate even when &lt;code&gt;s_client&lt;/code&gt; reports verification problems, and stderr is suppressed. Use a separate hostname-and-chain verification check with visible diagnostics and a known trust store. Do not turn this inspection pipeline’s final exit code into a TLS-health verdict.&lt;/p&gt;
&lt;p&gt;The companion helper tests &lt;code&gt;notAfter&lt;/code&gt; with OpenSSL &lt;code&gt;-checkend&lt;/code&gt;; it does not establish hostname validity, trust, revocation status, or that &lt;code&gt;notBefore&lt;/code&gt; has arrived. Pair expiry alerts with handshake checks and renewal-failure alerts. Choose a threshold smaller than the certificate’s normal lifetime, especially for short-lived certificates, and sample each relevant edge/address family.&lt;/p&gt;
&lt;h2 id="sources"&gt;Sources&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="https://caddyserver.com/docs/automatic-https"&gt;Caddy Automatic HTTPS&lt;/a&gt;, automatic TLS certificate management, checked 2026-10-15.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.openssl.org/3.0/man1/openssl-s_client/"&gt;OpenSSL &lt;code&gt;s_client&lt;/code&gt; documentation&lt;/a&gt;, SNI and certificate inspection options, checked 2026-10-15.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.openssl.org/3.0/man1/openssl-x509/"&gt;OpenSSL &lt;code&gt;x509&lt;/code&gt; documentation&lt;/a&gt;, certificate parsing, dates, and fingerprints, checked 2026-10-15.&lt;/li&gt;
&lt;/ul&gt;</content:encoded></item><item><title>A static server should have one obvious root</title><link>https://hk.xbcatm.com/notes/a-static-server-should-have-one-obvious-root/</link><pubDate>Thu, 04 Jun 2026 00:00:00 +0000</pubDate><guid>https://hk.xbcatm.com/notes/a-static-server-should-have-one-obvious-root/</guid><description>Keep the Caddy file server, generated artifact, and rollback path aligned.</description><content:encoded>&lt;p&gt;The most useful property of a static deployment is that an operator can answer “which directory served this response?” without reading application code. Caddy&amp;rsquo;s &lt;code&gt;root&lt;/code&gt; and &lt;code&gt;file_server&lt;/code&gt; directives provide that boundary directly.&lt;/p&gt;
&lt;figure class="technical-diagram"&gt;&lt;img loading="lazy" src="https://hk.xbcatm.com/visuals/caddy-root.detail.en.svg" alt="A complete directory is verified before becoming the Caddy root, while the prior known-good directory is retained through smoke tests. A dashed publication tree contains HTML, text and assets. An outward symlink can expose an outside target readable by the service user. Private files remain outside the tree; separate edge checks cover status, content type, redirects, certificate names and missing-route behavior." width="720" height="3010"&gt;&lt;figcaption&gt;Make the serving directory explicit. Audit symlinks and read/traverse permissions, then verify the actual response at the edge.&lt;/figcaption&gt;&lt;/figure&gt;
&lt;h2 id="make-the-root-explicit"&gt;Make the root explicit&lt;/h2&gt;
&lt;p&gt;A minimal site block can be kept close to the artifact layout:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;www.example.test {
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; root * /srv/example-site
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; encode zstd gzip
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; header {
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -Server
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; X-Content-Type-Options nosniff
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; X-Frame-Options DENY
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; Referrer-Policy strict-origin-when-cross-origin
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; }
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; file_server
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;}
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The hostname and path are placeholders; &lt;code&gt;www.example.test&lt;/code&gt; is not a publicly issuable production name. Use an owned, resolvable hostname for production and plan local TLS trust separately for a test environment. The release command should publish a complete directory, verify it, and only then make that directory the configured root. Keep the prior known-good directory until the new one has passed smoke tests.&lt;/p&gt;
&lt;h2 id="check-the-edge-not-only-the-file-tree"&gt;Check the edge, not only the file tree&lt;/h2&gt;
&lt;p&gt;Request an HTML page, a downloadable text file, and a missing path. Compare content types and status codes with the local artifact. A 404 page that returns &lt;code&gt;200&lt;/code&gt; is a deployment bug even if the page looks polished.&lt;/p&gt;
&lt;p&gt;When Caddy manages HTTPS, certificate provisioning and HTTP-to-HTTPS redirects are part of the edge behavior. Test the redirect from the bare HTTP listener and the certificate name from the public hostname. Do not infer success from a healthy process alone.&lt;/p&gt;
&lt;h2 id="a-root-is-a-publication-boundary-not-a-sandbox"&gt;A root is a publication boundary, not a sandbox&lt;/h2&gt;
&lt;p&gt;Keep source control metadata, backups, credentials, and private configuration outside the published tree. Caddy follows symlinks inside the root, so a link can expose files outside it. Check both the artifact contents and the serving user’s read/traverse permissions. Also, merely placing &lt;code&gt;404.html&lt;/code&gt; in the tree does not configure a custom error handler; verify the missing-route status and error-page behavior you intend.&lt;/p&gt;
&lt;h2 id="sources"&gt;Sources&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="https://caddyserver.com/docs/caddyfile/directives/file_server"&gt;Caddy &lt;code&gt;file_server&lt;/code&gt; directive&lt;/a&gt;, file serving, root, and index defaults, checked 2026-10-15.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://caddyserver.com/docs/automatic-https"&gt;Caddy Automatic HTTPS&lt;/a&gt;, automatic TLS and issuance behavior, checked 2026-10-15.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://hk.xbcatm.com/resources/"&gt;Static-site release checklist&lt;/a&gt;, local companion resource.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://caddyserver.com/docs/caddyfile/directives/header"&gt;Caddy &lt;code&gt;header&lt;/code&gt; directive&lt;/a&gt;, response header operations, checked 2026-10-15.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.rfc-editor.org/rfc/rfc7034.html"&gt;RFC 7034 — X-Frame-Options&lt;/a&gt;, the DENY framing policy, checked 2026-10-15.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Frame-Options"&gt;MDN: &lt;code&gt;X-Frame-Options&lt;/code&gt;&lt;/a&gt;, framing options, checked 2026-10-15.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.rfc-editor.org/rfc/rfc6761.html"&gt;RFC 6761 — Special-Use Domain Names&lt;/a&gt;, reserved &lt;code&gt;.test&lt;/code&gt; names, checked 2026-10-15.&lt;/li&gt;
&lt;/ul&gt;</content:encoded></item></channel></rss>