# XB Field Notes — offline reading bundle

Generated from the site's note Markdown sources for offline reading.
Each section keeps its title and Sources section for traceability.

---


========================================================================


## atomic-static-site-switch.md

---
title: Publish a static site by switching complete directories
date: 2026-08-23
description: Stage a complete artifact beside the live tree and replace the directory name only after verification.
summary: Visitors should see the old release or the new release, not a file-by-file mixture of both.
tags:
  - release
  - static-web
  - deployment
series:
  - Release and recovery
ShowToc: true
cover:
  image: /visuals/atomic-switch.svg
  alt: Two complete release directories with the live pointer switching from the old tree to the new tree.
series_weight: 4
---

Copying new files directly over a live static directory creates a period where visitors can receive HTML from one release and assets from another. A safer pattern stages one complete tree beside the live tree, verifies it, then changes directory names.

## Keep all release directories on one filesystem

Name replacement is strongest when the live tree, staged tree, and rollback tree share the same filesystem and parent directory. Check first:

~~~sh
df -P /srv/www
~~~

Create a uniquely named stage directory under that parent. Do not reuse an unresolved variable or broad wildcard as a deletion target.

~~~sh
stage=$(mktemp -d /srv/www/site.new.XXXXXX)
~~~

## Populate and verify the stage

Copy the generated artifact into the empty directory, then check representative files and the expected file count:

~~~sh
tar -C public -cf - . | tar -C "$stage" -xf -

test -s "$stage/index.html"
test -s "$stage/404.html"
find "$stage" -type f | wc -l
~~~

Run link and checksum checks against the staged path when the tooling supports a directory argument. A stage that has not passed verification must never acquire the live name.

## Switch names with an explicit rollback path

Resolve every path before the first move:

~~~sh
live=/srv/www/site
stamp=$(date -u +%Y%m%dT%H%M%SZ)
rollback=/srv/www/site.old-$stamp

test -d "$live"
test -d "$stage"
test ! -e "$rollback"

mv "$live" "$rollback"
if mv "$stage" "$live"; then
  test -s "$live/index.html"
else
  mv "$rollback" "$live"
  exit 1
fi
~~~

This sequence leaves a short interval between the two moves. For a single static-server process that resolves the root on each request, the interval is usually small but still observable. Platform-specific rename or symlink patterns can tighten the switch; test the exact filesystem and server behavior before relying on stronger claims.

## Verify the public edge

After the switch, request the homepage, one deep route, one fingerprinted asset, and a missing path. Compare public bytes with the staged artifact. Keep the rollback directory until that check and the observation window complete.

## Sources

- [POSIX `rename()`](https://pubs.opengroup.org/onlinepubs/9799919799/functions/rename.html), name replacement semantics, checked 2026-08-23.
- [Linux `rename(2)`](https://man7.org/linux/man-pages/man2/rename.2.html), filesystem and replacement behavior, checked 2026-08-23.
- [GNU tar manual](https://www.gnu.org/software/tar/manual/tar.html), archive creation and extraction, checked 2026-08-23.

========================================================================


## backup-restore-rehearsal.md

---
title: Backups are only useful after a restore rehearsal
date: 2026-08-22
description: A backup plan should prove that the important files can be found, verified, and restored.
summary: Choose a small recovery unit, verify it, and practice restoring it somewhere disposable.
tags:
  - backups
  - recovery
  - operations
series:
  - Release and recovery
ShowToc: true
cover:
  image: /visuals/backup-restore.svg
  alt: Source, archive, and restore rehearsal connected in a recovery loop.
series_weight: 11
---

“We have backups” is not yet a recovery capability. A useful backup has a known scope, a known freshness, a verification step, and a restore procedure that someone has actually run.

## Define the recovery unit

For a static site, the source repository and the generated artifact have different purposes. The repository is the authoritative input; the artifact is the quickest way to restore the last published version. Keep both distinctions clear.

At minimum, record:

- the source commit or archive identifier;
- the generator and theme versions;
- the list of files included in the artifact;
- the checksum of important downloads;
- the retention period and storage location.

Do not include credentials, private keys, cookies, or access tokens in a website backup. Those belong in a separate secret-management process.

## Verify before storing

An archive that cannot be opened is not a backup. A minimal verification loop looks like this:

~~~sh
tar -czf site-2026-08-22.tar.gz public/
tar -tzf site-2026-08-22.tar.gz >/dev/null
sha256sum site-2026-08-22.tar.gz > site-2026-08-22.tar.gz.sha256
~~~

The checksum verifies the file that was stored; it does not prove that every application-level assumption is correct. That is why a restore rehearsal is still required.

## Restore somewhere disposable

Extract the archive to a temporary directory or an isolated host, then check the entry points and a representative resource. Do not test a restore by overwriting the only live copy.

~~~sh
restore_dir=$(mktemp -d)
tar -xzf site-2026-08-22.tar.gz -C "$restore_dir"
test -s "$restore_dir/public/index.html"
test -s "$restore_dir/public/404.html"
~~~

The command is intentionally boring. A rehearsal should reveal missing files, wrong permissions, or undocumented dependencies before an incident does.

## Measure freshness honestly

A backup schedule is meaningful only relative to the amount of work that can be lost. If the source changes once a month, daily snapshots may be unnecessary. If a resource is updated every hour, a monthly archive is not enough.

Write down the target recovery point and recovery time, then choose storage and checks that can meet those targets. Avoid claiming “continuous protection” when the last verified restore is old.

## Sources

- [GNU tar manual](https://www.gnu.org/software/tar/manual/tar.html), archive creation and verification behavior, checked 2026-08-22.
- [Static-site release checklist](/resources/), local companion resource.

========================================================================


## caddy-static-files.md

---
title: A static server should have one obvious root
date: 2026-08-22
description: Keep the Caddy file server, generated artifact, and rollback path aligned.
summary: One root, one file_server, and a small header policy make a static deployment easy to inspect.
tags:
  - caddy
  - static-web
  - deployment
series:
  - Release and recovery
ShowToc: true
cover:
  image: /visuals/caddy-root.svg
  alt: A static file tree connected to a single public web endpoint.
series_weight: 2
---

The most useful property of a static deployment is that an operator can answer “which directory served this response?” without reading application code. Caddy's `root` and `file_server` directives provide that boundary directly.

## Make the root explicit

A minimal site block can be kept close to the artifact layout:

~~~text
www.example.test {
    root * /srv/example-site
    encode zstd gzip

    header {
        -Server
        X-Content-Type-Options nosniff
        X-Frame-Options DENY
        Referrer-Policy strict-origin-when-cross-origin
    }

    file_server
}
~~~

The path is an example, not a value to paste blindly. 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.

## Check the edge, not only the file tree

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 `200` is a deployment bug even if the page looks polished.

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.

## Sources

- [Caddy `file_server` directive](https://caddyserver.com/docs/caddyfile/directives/file_server), file serving, root, and index defaults, checked 2026-08-22.
- [Caddy Automatic HTTPS](https://caddyserver.com/docs/automatic-https), automatic TLS and issuance behavior, checked 2026-08-22.
- [Static-site release checklist](/resources/), local companion resource.

========================================================================


## certificate-expiry-check.md

---
title: Certificate expiry is a date, not a dashboard color
date: 2026-08-22
description: Check the certificate served for the real hostname and turn the remaining days into an actionable threshold.
summary: Use SNI, inspect the public certificate, and alert before renewal becomes an incident.
tags:
  - tls
  - caddy
  - monitoring
series:
  - Web security
ShowToc: true
cover:
  image: /visuals/tls-expiry.svg
  alt: A certificate shield with a validity and expiry gauge.
series_weight: 1
---

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.

## Include the hostname

TLS virtual hosting depends on the server name. Pass the hostname as SNI and inspect the peer certificate:

~~~sh
host=www.example.test
openssl s_client -connect "$host:443" -servername "$host" </dev/null 2>/dev/null \
  | openssl x509 -noout -subject -issuer -dates -fingerprint -sha256
~~~

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.

## Alert on a threshold

Choose a threshold that leaves time for a failed renewal, a DNS problem, and a human response. The exact number belongs to the service's recovery target; a universal “30 days” rule is not a substitute for that decision. The companion [TLS expiry check](/resources/) prints the expiry and exits non-zero when the remaining interval is below the threshold you pass.

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.

## Sources

- [Caddy Automatic HTTPS](https://caddyserver.com/docs/automatic-https), automatic TLS certificate management, checked 2026-08-22.
- [OpenSSL `s_client` documentation](https://docs.openssl.org/3.0/man1/openssl-s_client/), SNI and certificate inspection options, checked 2026-08-22.
- [OpenSSL `x509` documentation](https://docs.openssl.org/3.0/man1/openssl-x509/), certificate parsing, dates, and fingerprints, checked 2026-08-22.

========================================================================


## content-type-and-nosniff.md

---
title: Content-Type should describe the bytes you actually send
date: 2026-08-23
description: Check media types at the public edge and use nosniff to make mismatches fail visibly.
summary: A filename is only a hint; the response header is the browser-facing contract for the representation.
tags:
  - http
  - web-security
  - static-web
series:
  - HTTP foundations
ShowToc: true
cover:
  image: /visuals/content-type.svg
  alt: A file representation passing through a media-type label before reaching a browser.
series_weight: 1
---

A static server can return the correct bytes with the wrong `Content-Type`. The page may still look acceptable in one browser, while a download, stylesheet, feed reader, or security policy treats the same response differently. The public response header is the contract; the filename is only an input to server configuration.

## Inspect representative formats

Check one HTML document, stylesheet, SVG, feed, and downloadable script. Include headers but discard the body:

~~~sh
for path in / /assets/site.css /favicon.svg /index.xml /downloads/check-http-headers.sh; do
  curl -sS -D - -o /dev/null "https://www.example.test$path" \
    | awk -v path="$path" '
        BEGIN { print "== " path }
        /^HTTP\// || tolower($1) == "content-type:" || tolower($1) == "x-content-type-options:" { print }
      '
done
~~~

Expected media types depend on the resource, but they should be specific and stable. HTML should normally include a character set. CSS and SVG should not be served as generic binary data. A shell helper offered for download should not be advertised as HTML.

## Keep nosniff consistent

`X-Content-Type-Options: nosniff` asks supporting browsers to respect the declared type for script and style destinations instead of guessing. It is useful because a mismatch becomes an observable failure rather than browser-dependent interpretation.

The header does not repair a wrong type. Enable it only together with a response inventory that proves the declared values are correct:

~~~text
X-Content-Type-Options: nosniff
~~~

## Check the server mapping

Static servers usually derive types from extensions. If a new extension is served as `application/octet-stream`, decide whether that is intentional before adding a global override. A narrow mapping is easier to review than a rule that changes every response.

Repeat the checks at the public hostname. A CDN or reverse proxy can replace a correct origin header, and a friendly error page can return HTML for a path whose suffix looks like CSS or JavaScript.

## Sources

- [RFC 9110 — HTTP Semantics](https://www.rfc-editor.org/rfc/rfc9110.html), representation metadata and `Content-Type`, checked 2026-08-23.
- [MDN: `Content-Type`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Type), media types and charset examples, checked 2026-08-23.
- [MDN: `X-Content-Type-Options`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/X-Content-Type-Options), `nosniff` behavior, checked 2026-08-23.

========================================================================


## cookie-scope.md

---
title: Cookie scope should be narrower than the application boundary
date: 2026-08-23
description: Review Domain, Path, Secure, HttpOnly, and SameSite as one browser storage contract.
summary: A cookie sent to more hosts or paths than necessary enlarges the impact of every mistake at those boundaries.
tags:
  - http
  - web-security
  - privacy
series:
  - Web security
ShowToc: true
cover:
  image: /visuals/cookie-scope.svg
  alt: A browser cookie constrained by host, path, transport, and same-site boundaries.
series_weight: 5
---

Cookies are attached to requests according to browser rules, not according to the server component that originally set them. A broad `Domain` or `Path` can expose state to unrelated routes or subdomains, while missing transport and script restrictions enlarge the damage from other failures.

## Prefer a host-only cookie

When `Domain` is omitted, the cookie is scoped to the host that set it. Adding `Domain=example.test` allows it to be sent to eligible subdomains too. Use the broader form only when multiple hosts genuinely share the same application and trust boundary.

Inspect the response without printing real cookie values into logs:

~~~sh
curl -sS -D - -o /dev/null https://app.example.test/login \
  | awk '
      tolower($1) == "set-cookie:" {
        sub(/Set-Cookie: [^;]*/, "Set-Cookie: [value redacted]")
        print
      }
    '
~~~

## Review the attributes together

- `Secure` restricts transmission to secure contexts.
- `HttpOnly` prevents JavaScript access through `document.cookie`; it does not stop the browser from sending the cookie.
- `SameSite` influences cross-site requests and must match the application's navigation or embedding needs.
- `Path` controls when a cookie is sent, but it is not an authorization boundary between applications on the same host.

A cookie using `SameSite=None` also needs `Secure` in modern browsers. Avoid copying that pair into applications that do not require cross-site use.

## Use prefixes when their constraints fit

Cookie name prefixes such as `__Secure-` and `__Host-` let supporting browsers enforce additional requirements. A `__Host-` cookie must use `Secure`, omit `Domain`, and use `Path=/`, which makes the host-only intention explicit.

## Test deletion with the same scope

Deleting a cookie requires an expiry directive that matches the attributes used to store it, particularly name, domain, and path. A logout that clears only one variant can leave another cookie active.

## Keep static sites cookie-free

A genuinely static documentation site normally has no reason to set application cookies. Verify that the public response contains no `Set-Cookie` header rather than adding a consent surface for storage the site does not need.

## Sources

- [RFC 6265 — HTTP State Management Mechanism](https://www.rfc-editor.org/rfc/rfc6265.html), cookie storage and scope, checked 2026-08-23.
- [MDN: `Set-Cookie`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Set-Cookie), attributes and cookie prefixes, checked 2026-08-23.

========================================================================


## dns-caa-records.md

---
title: CAA records should authorize the issuer you actually use
date: 2026-08-23
description: Inspect inherited CAA policy before restricting which certificate authorities may issue for a domain.
summary: CAA narrows certificate issuance policy, but a mistaken record can block renewal as effectively as an expired certificate.
tags:
  - dns
  - tls
  - web-security
series:
  - Web security
ShowToc: true
cover:
  image: /visuals/caa-policy.svg
  alt: A DNS policy record authorizing one certificate authority while rejecting another.
series_weight: 6
---

Certification Authority Authorization records let a domain holder state which certificate authorities may issue certificates for the domain. They are useful only when the record matches the real renewal path and the operator understands how policy can be inherited from parent names.

## Inspect the current policy

Query the exact hostname and its parent names:

~~~sh
for name in www.example.test example.test test; do
  printf '== %s\n' "$name"
  dig +noall +answer CAA "$name"
done
~~~

An empty answer at the leaf does not necessarily mean there is no applicable policy. CAA processing can continue upward according to the DNS rules. Use an authoritative answer when diagnosing a change and compare it with at least one recursive resolver.

## Match the account's issuer identifier

A record uses tags such as `issue` or `issuewild` and an issuer-domain value:

~~~text
example.test. 3600 IN CAA 0 issue "letsencrypt.org"
~~~

The value is an authorization identifier, not the display name from a certificate. Confirm it in the certificate authority's current documentation. Wildcard authorization may require a separate `issuewild` policy.

## Stage the DNS change before renewal

Add or tighten CAA while the current certificate is healthy. Wait through the previous TTL, query authoritative and recursive answers, and run a staging or renewal test supported by the ACME client. Keep a rollback record ready.

Do not add a restrictive record during an expiry incident unless the issuer path has already been verified. A syntactically correct but wrong policy can turn automatic renewal into a hard failure.

## Observe issuance failures

CAA checks happen at issuance time, not on every HTTPS request. Certificate monitoring must therefore cover both current expiry and renewal attempts. Log the effective DNS answer with the ACME failure instead of recording only a generic authorization error.

## Sources

- [RFC 8659 — DNS Certification Authority Authorization](https://www.rfc-editor.org/rfc/rfc8659.html), CAA lookup and property semantics, checked 2026-08-23.
- [Let's Encrypt: CAA](https://letsencrypt.org/docs/caa/), issuer identifiers and operational guidance, checked 2026-08-23.

========================================================================


## dns-change-window.md

---
title: DNS changes need a cache-aware rollback window
date: 2026-08-22
description: Lowering a TTL just before a change does not remove answers already cached with the old value.
summary: Prepare the TTL ahead of time, observe authoritative and recursive answers, and keep both endpoints valid through the cache window.
tags:
  - dns
  - deployment
  - operations
series:
  - Release and recovery
ShowToc: true
cover:
  image: /visuals/dns-ttl.svg
  alt: DNS nodes surrounding a time-to-live cache dial.
series_weight: 8
---

Changing an address record is easy; predicting when every resolver stops using the old answer is not. The time-to-live value travels with a DNS response and controls how long a cache may reuse it. Lowering the TTL at the moment of a migration does not rewrite answers that were cached earlier.

## Lower the TTL before the event

If an existing record has a one-day TTL, lower it at least one old TTL before the planned switch. That gives caches time to fetch the shorter value while the old endpoint is still authoritative. The exact interval belongs in the change plan rather than in an improvised command during the migration.

Record the answer from the authoritative server and from at least one recursive resolver:

~~~sh
dig +noall +answer www.example.test A
dig @1.1.1.1 +noall +answer www.example.test A
~~~

The remaining TTL shown by a recursive resolver is evidence of its current cache state. Different resolvers can legitimately show different remaining values.

## Keep both sides valid

During the switch, keep the old and new endpoints capable of serving the intended hostname. TLS names, redirects, and application configuration must agree on both sides. Removing the old endpoint immediately after the authoritative record changes creates an avoidable outage for clients that still hold the prior answer.

Negative answers are cached too. Creating a name that recently returned `NXDOMAIN` can take time to become visible because the negative response may remain in recursive caches according to the zone's authority data.

## Raise the TTL after observation

Once authoritative and representative recursive answers point to the new endpoint and the old cache window has elapsed, restore the normal TTL. Keep the change record with the old value, new value, authoritative answer, and rollback condition.

## Sources

- [RFC 1034 — Domain Names: Concepts and Facilities](https://www.rfc-editor.org/rfc/rfc1034.html), caching model, checked 2026-08-22.
- [RFC 1035 — Domain Names: Implementation and Specification](https://www.rfc-editor.org/rfc/rfc1035.html), TTL field, checked 2026-08-22.
- [RFC 2308 — Negative Caching of DNS Queries](https://www.rfc-editor.org/rfc/rfc2308.html), negative caching model and retained error TTL, checked 2026-08-22.

========================================================================


## head-request-parity.md

---
title: HEAD should describe the same representation as GET
date: 2026-08-23
description: Compare HEAD and GET metadata without confusing a missing body with a different resource.
summary: HEAD is a metadata view of GET; status and representation headers should tell the same story.
tags:
  - http
  - monitoring
  - testing
series:
  - HTTP foundations
ShowToc: true
cover:
  image: /visuals/head-parity.svg
  alt: GET and HEAD requests aligned to the same response metadata while only GET carries a body.
series_weight: 2
---

The `HEAD` method asks for the response a server would send to `GET`, without transferring the response content. It is useful for checks that need status, validators, type, or length, but only when the metadata remains aligned with the real `GET` path.

## Compare a normalized header set

Dynamic headers such as `Date` will differ between requests. Compare the fields that describe the selected representation:

~~~sh
url=https://www.example.test/downloads/manual.pdf

curl -sS -I "$url" > head.headers
curl -sS -D get.headers -o /dev/null "$url"

for file in head.headers get.headers; do
  printf '== %s\n' "$file"
  awk '
    /^HTTP\// ||
    tolower($1) ~ /^(content-type:|content-length:|content-encoding:|cache-control:|etag:|last-modified:|vary:)$/ { print }
  ' "$file"
done
~~~

The two requests should select the same status and representation metadata. Compression negotiation also belongs in the comparison: send the same `Accept-Encoding` value to both requests.

## Do not use HEAD as the only release check

A server can synthesize plausible headers while the file is missing, unreadable, or truncated. Keep one small `GET` in the smoke test and verify its body or checksum. `HEAD` reduces transfer cost; it does not prove the payload.

## Check redirects explicitly

Without `--location`, `curl -I` reports the first response. That is useful when testing redirect policy. With `--location`, it reports the final chain, which is useful when testing the destination. Record which question the command answers:

~~~sh
curl -sS -I --max-redirs 0 https://old.example.test/manual
curl -sS -I --location https://old.example.test/manual
~~~

## Treat method-specific failures as configuration defects

A static page that returns `200` to `GET` and `405` or `404` to `HEAD` usually exposes a proxy or routing inconsistency. Fix the method handling instead of teaching monitors to ignore it, unless the endpoint contract deliberately disallows `HEAD`.

## Sources

- [RFC 9110 — HTTP Semantics](https://www.rfc-editor.org/rfc/rfc9110.html#name-head), HEAD method semantics, checked 2026-08-23.
- [curl manual](https://curl.se/docs/manpage.html), `--head`, `--include`, and redirect options, checked 2026-08-23.

========================================================================


## hsts-rollout.md

---
title: HSTS should be rolled out with a recovery plan
date: 2026-08-23
description: Increase max-age only after HTTPS, redirects, and every covered subdomain are ready.
summary: HSTS is cached browser policy, so a configuration mistake can outlive the server change that created it.
tags:
  - tls
  - web-security
  - deployment
series:
  - Web security
ShowToc: true
cover:
  image: /visuals/hsts-rollout.svg
  alt: An HSTS policy increasing through staged max-age steps before covering subdomains.
series_weight: 2
---

HTTP Strict Transport Security tells a supporting browser to use HTTPS for a host during a cached period. That persistence is the feature and the risk: removing the header does not immediately erase policy already stored by clients.

## Verify the HTTPS path first

Before sending HSTS, confirm that the canonical hostname serves a valid certificate and that HTTP redirects to the intended HTTPS URL without depending on user input:

~~~sh
curl -sS -D - -o /dev/null --max-redirs 0 http://www.example.test/
curl -sS -D - -o /dev/null https://www.example.test/
~~~

Check representative deep routes and error pages. HSTS cannot repair a certificate error because the browser requires a valid HTTPS connection before accepting the policy.

## Start with a short max-age

A staged rollout limits the recovery window while configuration is new:

~~~text
Strict-Transport-Security: max-age=300
~~~

After observing the site and renewal path, increase the duration deliberately. Record the date, chosen value, and rollback conditions. A final policy might use a year, but the number is a commitment rather than a scanner score.

## Treat includeSubDomains as a separate decision

`includeSubDomains` extends policy to every subdomain. Inventory names first, including old services, delegated zones, and hostnames used only on internal networks. One HTTP-only descendant is enough to make the option disruptive for clients that received the parent policy.

## Preload is not ordinary header configuration

Browser preload programs can distribute policy outside the normal response cycle. Their requirements and removal delays are stricter than setting a header. Do not request preload merely because a scanner recommends it; confirm long-term ownership of the domain and all covered names.

## Verify the public response

~~~sh
curl -sS -D - -o /dev/null https://www.example.test/ \
  | awk 'tolower($1) == "strict-transport-security:" { print }'
~~~

Test the canonical public edge, not only an origin port. Proxies may add, remove, or duplicate the header.

## Sources

- [RFC 6797 — HTTP Strict Transport Security](https://www.rfc-editor.org/rfc/rfc6797.html), policy processing and scope, checked 2026-08-23.
- [MDN: `Strict-Transport-Security`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Strict-Transport-Security), directives and deployment cautions, checked 2026-08-23.

========================================================================


## http-cache-headers.md

---
title: Cache headers are a contract with two clocks
date: 2026-08-22
description: Choose freshness and revalidation rules from how often a representation can change.
summary: Separate browser freshness from origin validation, then verify the response headers that implement both.
tags:
  - http
  - caching
  - web-performance
series:
  - HTTP foundations
ShowToc: true
cover:
  image: /visuals/cache-clocks.svg
  alt: A cache and origin exchanging validators beneath a freshness clock.
series_weight: 5
---

Caching is not a single switch. A response has a freshness decision made by a cache and a validation conversation with the origin. Mixing those two ideas produces either stale content or needless requests.

## Pick the change model first

For an HTML document that may change after publication, `Cache-Control: no-cache` is usually a more accurate starting point than `no-store`. `no-cache` permits storage but requires a cache to validate before reuse; `no-store` asks caches not to store the response at all. The distinction matters when you want conditional requests and a cheap `304 Not Modified` response.

For a fingerprinted asset such as `app.4f2c.js`, the URL changes when the bytes change. A long freshness lifetime can then be reasonable:

~~~text
Cache-Control: public, max-age=31536000, immutable
~~~

That policy is unsafe for a stable URL whose contents can be replaced in place. If the URL is stable, prefer a shorter lifetime and a validator such as an entity tag (`ETag`) or a meaningful `Last-Modified` value.

## Inspect both paths

Make one ordinary request and one conditional request. The exact server flags vary, but the evidence you want is stable:

~~~sh
curl -sS -D - -o /dev/null https://www.example.test/
curl -sS -D - -o /dev/null \
  -H 'If-None-Match: "copy-a-real-etag-from-the-first-response"' \
  https://www.example.test/
~~~

Do not paste a made-up validator into a runbook. Copy the value from the first response or use a test fixture. A successful revalidation should return `304` only when the server can prove that the selected representation has not changed.

## Keep intermediaries visible

`Age`, `Vary`, and the cache-control directives explain why two clients can receive different results. If a response varies by `Accept-Encoding`, language, or another request field, that dimension belongs in `Vary`; otherwise a shared cache can reuse the wrong representation.

The useful release check is not “the scanner found a cache header”. It is “the header matches the replacement policy, and a conditional request behaves as documented”.

## Sources

- [RFC 9111 — HTTP Caching](https://www.rfc-editor.org/rfc/rfc9111.html), sections 4–5, checked 2026-08-22.
- [RFC 9110 — HTTP Semantics](https://www.rfc-editor.org/rfc/rfc9110.html), sections 8 and 13, checked 2026-08-22.
- [MDN: `Cache-Control`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Cache-Control), directive semantics and defaults, checked 2026-08-22.

========================================================================


## http-compression-negotiation.md

---
title: Compression changes the selected HTTP representation
date: 2026-08-22
description: Treat content encoding, validators, and Vary as one observable response contract.
summary: Test compressed and uncompressed requests separately so caches and validators describe the bytes actually served.
tags:
  - http
  - caddy
  - web-performance
series:
  - HTTP foundations
ShowToc: true
cover:
  image: /visuals/compression.svg
  alt: A large HTTP representation compressed into a smaller transfer.
series_weight: 3
---

Compression saves transfer bytes, but it also creates another representation of a resource. A client announces acceptable content codings with `Accept-Encoding`; the server describes the applied coding with `Content-Encoding`.

## Make negotiation visible

When a response can change according to `Accept-Encoding`, include that request field in `Vary`. This tells shared caches that a compressed response and an identity response are not interchangeable cache entries.

Compare the two paths instead of checking only a browser request:

~~~sh
curl -sS -D - -o /dev/null \
  -H 'Accept-Encoding: identity' https://www.example.test/
curl -sS --compressed -D - -o /dev/null \
  https://www.example.test/
~~~

Inspect `Content-Encoding`, `Vary`, `Content-Length` when present, and the validator. A server can use different entity tags for differently encoded representations; what matters is that conditional requests validate the same selected representation consistently.

## Compress appropriate content

Text formats such as HTML, CSS, JSON, XML, and SVG usually benefit from compression. Formats that are already compressed might not. Caddy's `encode` directive negotiates supported encodings and normally avoids encoding a response without an appropriate content type or minimum size.

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.

## Sources

- [RFC 9110 — HTTP Semantics](https://www.rfc-editor.org/rfc/rfc9110.html), content codings and `Vary`, checked 2026-08-22.
- [Caddy `encode` directive](https://caddyserver.com/docs/caddyfile/directives/encode), content encoding selection and negotiation, checked 2026-08-22.
- [curl manual](https://curl.se/docs/manpage.html), `--compressed`, checked 2026-08-22.

========================================================================


## http-range-requests.md

---
title: Range requests should prove which bytes were selected
date: 2026-08-23
description: Verify partial responses, Content-Range, and the behavior of unsatisfiable byte ranges.
summary: A 206 response is useful only when its boundaries and total size describe the bytes that were returned.
tags:
  - http
  - downloads
  - testing
series:
  - HTTP foundations
ShowToc: true
cover:
  image: /visuals/range-request.svg
  alt: A long byte sequence with one bounded segment selected for a partial response.
series_weight: 4
---

Range requests let a client ask for part of a representation. Media players, resumable download tools, and diagnostic clients use them, but a server that advertises or returns ranges incorrectly can produce silent corruption instead of a clean failure.

## Establish the complete size

Fetch headers for the complete representation and record its length and validator:

~~~sh
url=https://www.example.test/downloads/archive.tar.gz
curl -sS -D full.headers -o /dev/null "$url"
awk 'tolower($1) ~ /^(content-length:|etag:|last-modified:|accept-ranges:)$/ { print }' full.headers
~~~

`Accept-Ranges: bytes` is an explicit signal, but absence of the header does not by itself prove that range requests are unsupported. Exercise the request.

## Request a known interval

Ask for the first 16 bytes and keep both headers and body:

~~~sh
curl -sS -D range.headers -o range.body \
  -H 'Range: bytes=0-15' "$url"

wc -c range.body
awk 'tolower($1) ~ /^(content-range:|content-length:|etag:)$/ || /^HTTP\// { print }' range.headers
~~~

A successful byte-range response uses `206 Partial Content`. `Content-Range` should identify `0-15` and the complete representation length, while the body should contain exactly 16 bytes.

## Test an impossible range

Request bytes beyond the known end:

~~~sh
curl -sS -D - -o /dev/null \
  -H 'Range: bytes=999999999-' "$url"
~~~

For an unsatisfiable byte range, `416 Range Not Satisfiable` and a `Content-Range` value such as `bytes */12345` make the actual length visible. A `200` response may be valid when the server ignores the range, but it must return the complete representation rather than a mislabeled fragment.

## Keep validators in the story

Resumed downloads can use `If-Range` with a strong validator or date. If the representation changed, the safe response is the full current representation, not bytes spliced from two versions. Test this behavior before calling a download resumable.

## Sources

- [RFC 9110 — HTTP Semantics](https://www.rfc-editor.org/rfc/rfc9110.html#name-range-requests), range units, partial responses, and `Content-Range`, checked 2026-08-23.
- [curl manual](https://curl.se/docs/manpage.html), `--range` and header options, checked 2026-08-23.

========================================================================


## postmortem-leave-a-fact-record.md

---
title: A postmortem should leave a fact record
date: 2026-08-24
description: Structure an incident write-up around captured output so the review thread stays on evidence instead of memory.
summary: A postmortem is a chronological map from detected symptom to verified fix, backed by the commands that produced each fact.
tags:
  - postmortem
  - operations
  - incident
series:
  - Release and recovery
series_weight: 13
ShowToc: true
cover:
  image: /visuals/postmortem.svg
  alt: A four-phase incident timeline from detect to verify.
---

A postmortem earns trust when its claims can be re-checked. That means every phase names the command that produced the fact, not a paraphrase of what someone remembers. The four phases below keep a write-up reviewable at any later time.

## Detect: the symptom, with evidence

Start with the smallest verifiable symptom and the command that showed it. A capture is better than a prose description:

~~~text
$ check-http-headers.sh https://www.xbcatm.com/
HTTP/2 200
content-security-policy: ... script-src 'none' ...
last-modified: Mon, 24 Aug 2026 11:20:54 GMT
~~~

If the symptom is that a header changed, the output showing the previous policy and the new one belongs side by side. If it is a failure, include the exit status and the error line.

## Scope: how wide, and how you know

State the blast radius with evidence, not adjectives. Fix the boundary you can inspect:

- Which hosts or paths reply with the same symptom? One `check-http-headers.sh` per candidate host answers this.
- Does the cache differ from origin? Compare `Vary` and validators.
- Which rollback window applies? Use the TTL that was in force before the change.

## Fix: the change and its intended effect

Write the corrective action so it can be replayed. Prefer the smallest change that restores the contract, and name what the post-fix run should show:

~~~sh
# new policy line expected in the response:
script-src 'none'
~~~

## Verify: the post-incident run

Repeat the detection command after the change and show that the observed output now matches the intended contract. A postmortem without this final section is a story; with it, it is a record that approves its own future re-check.

## Sources

- [RFC 9110 — HTTP Semantics](https://www.rfc-editor.org/rfc/rfc9110.html), response metadata and conditional requests, checked 2026-08-24.
- [artifact HTTP header check](/resources/), read-only inspection used throughout this record.

========================================================================


## read-only-release-smoke-tests.md

---
title: A release is not complete until the public edge agrees
date: 2026-08-22
description: Compare the generated artifact with the responses a visitor can actually receive.
summary: Build once, inspect the output, then exercise representative public routes before replacing the live directory.
tags:
  - release
  - testing
  - static-web
series:
  - Release and recovery
ShowToc: true
cover:
  image: /visuals/release-edge.svg
  alt: Build, check, publish, and observe stages leading to the public edge.
series_weight: 7
---

Static publishing removes application runtime complexity, but it does not remove the boundary between a generated directory and a public response. A release check should cover both.

![A small static release path](/diagrams/release-path.svg)

## Start from a clean artifact

Build with the pinned generator into a clean destination. Record the source commit, generator version, output file list, and checksums of files that users download. A clean build catches stale files that a copy-over deployment can accidentally preserve.

~~~sh
make verify
find public -type f -print | sort
~~~

The file list is evidence, not decoration. If a route disappears, the diff should explain whether the source was removed or the generator failed to emit it.

## Exercise three response classes

Request one HTML page, one static resource, and one missing path. Inspect status, content type, cache policy, and security headers:

~~~sh
curl -fsS -D - -o /dev/null https://www.example.test/
curl -fsS -D - -o /dev/null https://www.example.test/downloads/check-http-headers.sh
curl -sS -D - -o /dev/null https://www.example.test/does-not-exist
~~~

The third command intentionally does not use `-f`: a 404 is the expected result and should still be captured. Compare the public body with the generated `404.html` and verify that the status remains 404.

## Keep rollback simple

Do not delete the previous directory until the new one has passed the checks. A rollback should select a known-good directory, not reconstruct one from memory during an incident. The [release checklist](/resources/) and [HTTP header check](/resources/) are deliberately small enough to run from a clean shell.

## Sources

- [RFC 9110 — HTTP Semantics](https://www.rfc-editor.org/rfc/rfc9110.html), status and representation semantics, checked 2026-08-22.
- [curl manual](https://curl.se/docs/manpage.html), response inspection options, checked 2026-08-22.
- [Static-site release checklist](/resources/), local companion resource.

========================================================================


## read-the-public-edge-in-two-commands.md

---
title: Read the public edge in two commands
date: 2026-08-24
description: Run read-only probes against a live site and let the response headers and certificate dates confirm what you think is deployed.
summary: Two small read-only helpers turn the public endpoint into evidence you can inspect before and after a release.
tags:
  - debugging
  - operations
  - https
series:
  - Release and recovery
series_weight: 6
ShowToc: true
cover:
  image: /visuals/field-output.svg
  alt: A read-only probe printing real response headers and certificate dates.
---

A deployment is complete when the public edge agrees with the files you chose, not when the last upload command exits zero. Two read-only helpers from the [operator kit](/resources/) make that agreement visible from any machine that can reach the host.

## Inspect the response headers

`check-http-headers.sh` fetches one URL and prints the response line, the headers, and the final URL after redirects:

~~~sh
check-http-headers.sh https://www.xbcatm.com/
~~~

A recent read against this site produced:

~~~text
HTTP/2 200
accept-ranges: bytes
content-security-policy: default-src 'self'; base-uri 'self'; form-action 'none'; frame-ancestors 'none'; object-src 'none'; script-src 'none'; style-src 'self'; img-src 'self' data:
content-type: text/html; charset=utf-8
last-modified: Mon, 24 Aug 2026 11:20:54 GMT
permissions-policy: accelerometer=(), camera=(), geolocation=(), gyroscope=(), microphone=(), payment=(), usb=()
referrer-policy: strict-origin-when-cross-origin
strict-transport-security: max-age=31536000; includeSubDomains
x-content-type-options: nosniff
x-frame-options: DENY

HTTP_STATUS 200
FINAL_URL https://www.xbcatm.com/
~~~

Two lines matter immediately: a `200` status with a `FINAL_URL` that matches the host you intended, and a `Content-Security-Policy` that still says `script-src 'none'`. If any of those differ from the policy you believe is live, the edge is not the tree you staged.

## Inspect the certificate

`check-tls-expiry.sh HOST [DAYS]` reads the public certificate for the hostname and enforces a minimum validity window:

```sh
check-tls-expiry.sh www.xbcatm.com 30
```

A recent read printed:

```text
subject=CN=www.xbcatm.com
issuer=C=US, O=Let's Encrypt, CN=YE1
notBefore=Aug 21 08:37:13 2026 GMT
notAfter=Nov 19 08:37:12 2026 GMT

certificate remains valid for at least 30 day(s)
```

Notice the subject is the exact hostname, not a wildcard from an adjacent site. `notAfter` is a concrete date; if it is closer than the window you passed, the helper exits non-zero and your release check can treat that as a failing condition.

## Use both around a release

Once before and once after you swap directories:

1. Before: record `last-modified`, the CSP, and the certificate dates as your baseline.
2. Deploy the staged tree.
3. After: run the same two commands and compare `last-modified` and the checksums.

The two outputs together are a small but complete contract: the certificate is current, the policy is intact, and the bytes changing at the edge match what you published. That is the evidence a release claim is allowed to be.

## Sources

- [Caddy Automatic HTTPS](https://caddyserver.com/docs/automatic-https), automatic TLS certificate management, checked 2026-08-24.
- [MDN: Content Security Policy guide](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CSP), CSP directive names and syntax, checked 2026-08-24.
- [OpenSSL `s_client` documentation](https://docs.openssl.org/3.0/man1/openssl-s_client/), SNI and certificate inspection options, checked 2026-08-24.

========================================================================


## redirects-and-status-codes.md

---
title: Redirects and error codes should preserve the story
date: 2026-08-22
description: Use permanent redirects and absence codes to describe what happened, not to hide a broken route.
summary: Check the method, Location header, and cache behavior before choosing a redirect or an error status.
tags:
  - http
  - operations
  - debugging
series:
  - HTTP foundations
ShowToc: true
cover:
  image: /visuals/redirect-map.svg
  alt: An old URI permanently redirected to a new URI with status 308.
series_weight: 6
---

An HTTP status is part of the interface. A browser may render a friendly page, but a monitor, crawler, cache, and API client also need the status code and headers to tell the same story.

## Permanent moves are not all interchangeable

`308 Permanent Redirect` communicates that the resource has a new permanent URI while preserving the request method. That is important for a client that sends `POST`, `PUT`, or another non-`GET` method. A redirect from an old page to a new page is easy to test with `GET`; a migration of an endpoint must also be tested with the method that real callers use.

~~~sh
curl -sS -D - -o /dev/null --max-redirs 0 \
  https://old.example.test/api/item
~~~

Check that `Location` is absolute or correctly rooted, that the target is the intended host, and that the response is not silently downgraded to a temporary redirect. A `308` is cacheable by default, so change it only with a deliberate migration plan.

## Distinguish missing from removed

Use `404 Not Found` when the server cannot find a current representation and the absence may be temporary or unknown. `410 Gone` is stronger: it says the resource was intentionally removed and is unlikely to return. Both should have a body that helps a human, but neither should return `200` just because a custom error page rendered successfully.

The check belongs at the edge:

~~~sh
curl -sS -o /tmp/missing.html -w '%{http_code} %{url_effective}\n' \
  https://www.example.test/a-route-that-does-not-exist
~~~

Keep the response headers on the error path. Security headers, a useful content type, and a correct status are more valuable than a decorative error animation.

## Sources

- [RFC 9110 — HTTP Semantics](https://www.rfc-editor.org/rfc/rfc9110.html), sections 15 and 13, checked 2026-08-22.
- [RFC 7538 — 308 Permanent Redirect](https://www.rfc-editor.org/rfc/rfc7538.html), section 3, checked 2026-08-22.
- [curl manual](https://curl.se/docs/manpage.html), status and redirect inspection flags, checked 2026-08-22.

========================================================================


## release-manifest-checksums.md

---
title: A release manifest should name every published byte
date: 2026-08-23
description: Build a deterministic file manifest and compare it before and after transport.
summary: One aggregate digest is useful, but a sorted per-file manifest tells you exactly what changed or went missing.
tags:
  - release
  - checksums
  - testing
series:
  - Release and recovery
ShowToc: true
cover:
  image: /visuals/release-manifest.svg
  alt: A sorted file manifest connected to individual checksums and one aggregate release digest.
series_weight: 5
---

A static artifact is a directory, not one executable. Recording only the source commit or only the homepage checksum leaves gaps: a missing image, stale alias, or changed download can survive unnoticed. A release manifest should enumerate every regular file.

## Generate a stable list

Run the command from the artifact root so paths remain relative. Sort by path under a fixed locale:

~~~sh
cd public
find . -type f -exec sha256sum {} + | LC_ALL=C sort -k2 > ../release.sha256
~~~

The manifest is evidence about the artifact, so keep it outside the directory being measured unless the manifest itself is intentionally part of the release.

## Verify after transport

On the destination, verify individual files:

~~~sh
cd /srv/www/site.new
sha256sum -c /path/to/release.sha256
~~~

This catches changed bytes and missing paths. It does not report unexpected extra files, so compare the destination file list too:

~~~sh
find . -type f | LC_ALL=C sort > destination.files
awk '{print $2}' /path/to/release.sha256 | LC_ALL=C sort > expected.files
diff -u expected.files destination.files
~~~

## Add an aggregate digest for release records

Hash the manifest itself to produce a short identifier for logs and deployment records:

~~~sh
sha256sum release.sha256
~~~

The aggregate digest is only meaningful when the manifest generation rules are fixed: relative path form, sorting locale, hash algorithm, and whether generated metadata is included all belong in the procedure.

## Do not confuse integrity with authenticity

SHA-256 proves that two byte sequences match. If an attacker can replace both the artifact and the manifest, the check does not establish who produced them. Artifact signing and trusted distribution are separate controls.

## Sources

- [GNU Coreutils: sha2 utilities](https://www.gnu.org/software/coreutils/manual/html_node/sha2-utilities.html), checksum generation and checking, checked 2026-08-23.
- [sha256sum(1)](https://man7.org/linux/man-pages/man1/sha256sum.1.html), command behavior and output format, checked 2026-08-23.

========================================================================


## reliable-defaults.md

---
title: Reliable defaults beat clever recovery
date: 2026-08-22
description: A predictable first path prevents more incidents than a complicated fallback can repair.
summary: Keep the common path explicit, observable, and safe to repeat before adding recovery branches.
tags:
  - reliability
  - operations
  - design
series:
  - Release and recovery
ShowToc: true
cover:
  image: /visuals/reliable-path.svg
  alt: A strong default path with quieter fallback and recovery branches.
series_weight: 12
---

Small services often accumulate recovery logic before their normal path is fully understood. Every retry, alternate endpoint, and compatibility branch adds another state that must be observed and tested.

## Make the common path boring

A useful default is explicit, easy to inspect, and safe to repeat. For a static site, that can be as simple as:

1. Build from a pinned toolchain.
2. Publish one complete directory.
3. Check the resulting HTML and assets.
4. Keep the previous directory available for rollback.

The sequence is less impressive than a multi-stage deployment system, but its state is visible. When something fails, the operator can answer whether the problem is in the source, the build, the copy, or the web server.

## Retry only a bounded operation

Retries are useful when the operation is known to be transient and repeating it cannot create a second side effect. A download from an origin may be safe to retry; a database migration may not be. Treating every error as retryable hides the boundary that needs attention.

When a retry is appropriate, record the attempt count and the final reason. A log entry such as "publish failed: checksum mismatch" is more actionable than a sequence of silent retries followed by a generic timeout.

## Prefer one authoritative state

Configuration, runtime state, and documentation should agree about which directory is live and which command produces it. If a release can be copied to three different paths depending on who runs it, recovery becomes guesswork.

For a static deployment, a short release record is enough:

~~~text
release: 2026-08-22
source: git commit <commit-id>
builder: Hugo 0.165.0
output: public/
verification: links + headers + checksums
~~~

The placeholder commit ID is deliberately resolved at release time instead of being invented in documentation.

## Recovery should restore a known state

Recovery is easiest when it returns the service to the last known-good directory, not when it chooses among several partially built alternatives. Keep the previous output until the new output has passed its checks, then remove old copies according to a retention policy.

The goal is not to eliminate failure. It is to make the failure small enough to explain and the recovery small enough to trust.

## Sources

- [RFC 9110 — HTTP Semantics](https://www.rfc-editor.org/rfc/rfc9110.html), section 9.2.2 on idempotent methods and retries, checked 2026-08-22.
- [Static-site release checklist](/resources/), local companion resource.

========================================================================


## robots-and-sitemaps.md

---
title: robots.txt and sitemaps answer different questions
date: 2026-08-22
description: Keep crawler preferences, URL discovery, and access control separate.
summary: robots.txt is a crawler-facing preference; a sitemap is a URL inventory; neither is an authorization layer.
tags:
  - web
  - discovery
  - operations
series:
  - HTTP foundations
ShowToc: true
cover:
  image: /visuals/discovery-map.svg
  alt: A crawler icon beside a branching sitemap structure.
series_weight: 7
---

Two small files are often treated as if they were a security boundary. They are not. A `robots.txt` file describes crawler preferences, while a sitemap gives search systems a list of URLs that belong to the site.

## What robots.txt can and cannot do

The rules are published at the origin's `/robots.txt` path and are read by cooperating crawlers. They do not protect a private directory, remove a URL from the network, or replace authentication. A route that must not be public needs server authorization or a network boundary.

Keep the file boring and test its actual location:

~~~text
User-agent: *
Disallow:
Sitemap: https://www.example.test/sitemap.xml
~~~

The file should be UTF-8. If a deployment redirects it, verify the crawler behavior you intend rather than assuming every client follows the same chain.

## What a sitemap adds

A sitemap is an inventory, not a permission list. Use absolute URLs from one host, encode XML values correctly, and include only canonical pages that you are prepared to publish. Do not list a URL merely because it exists in a build directory.

The protocol permits up to 50,000 URLs and 50 MB per sitemap file. A small site does not need an index file; one ordinary sitemap is easier to inspect. Validate it as XML and request it through the same HTTPS edge as a normal page.

~~~sh
curl -fsS https://www.example.test/robots.txt
curl -fsS https://www.example.test/sitemap.xml | xmllint --noout -
~~~

If `xmllint` is not installed, parse the XML in the same CI environment that publishes it. The important part is that the check runs before the file is replaced at the edge.

## Sources

- [RFC 9309 — Robots Exclusion Protocol](https://www.rfc-editor.org/rfc/rfc9309.html), robots rules, file location, and user-agent matching, checked 2026-08-22.
- [Sitemaps Protocol](https://www.sitemaps.org/protocol.html), sitemap XML structure and discovery, checked 2026-08-22.

========================================================================


## security-headers-for-a-static-site.md

---
title: Security headers should describe the actual page
date: 2026-08-22
description: A compact policy is safer when it matches the resources a static page really loads.
summary: Start with a narrow policy, then expand it only when a documented feature needs another resource.
tags:
  - web-security
  - caddy
  - static-web
series:
  - Web security
ShowToc: true
cover:
  image: /visuals/security-layers.svg
  alt: Transport, browser policy, and resource graph shown as security layers.
series_weight: 3
---

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.

## Start from the resource graph

If a site serves HTML, a local stylesheet, a favicon, and no executable JavaScript, a narrow policy can be understandable:

~~~text
Content-Security-Policy: default-src 'self'; base-uri 'self'; form-action 'none'; frame-ancestors 'none'; object-src 'none'; script-src 'none'; style-src 'self'; img-src 'self' data:
~~~

The script-src 'none' 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.

## Add transport and framing protections

For an HTTPS-only site, the following headers are common starting points:

~~~text
Strict-Transport-Security: max-age=31536000; includeSubDomains
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=(), usb=()
~~~

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.

## Check behavior, not just presence

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.

The [Caddy example](/resources/) in this site is a starting point, not a universal policy. Adapt the host names, certificate handling, and application routes to your own deployment.

## Sources

- [MDN: Content Security Policy guide](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CSP), CSP directive names and syntax, checked 2026-08-22.
- [MDN: `Strict-Transport-Security`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Strict-Transport-Security), HSTS directives and deployment cautions, checked 2026-08-22.
- [Caddy `header` directive](https://caddyserver.com/docs/caddyfile/directives/header), response header manipulation directives, checked 2026-08-22.

========================================================================


## small-systems.md

---
title: The advantage of small systems
date: 2026-08-22
description: A system is easier to trust when its important behavior fits inside a single mental model.
summary: Small does not mean unfinished; it means each boundary has a clear job and a visible owner.
tags:
  - architecture
  - operations
  - simplicity
series:
  - Release and recovery
ShowToc: true
cover:
  image: /visuals/small-system.svg
  alt: A compact service core connected to DNS, TLS, files, and logs.
series_weight: 1
---

Small does not mean unfinished. It means each component has a clear job, dependencies are deliberate, and the path from request to result remains visible.

## Visibility is a feature

When operators can name every boundary, they can also identify which evidence is missing. Logs become more useful, alerts become more specific, and routine maintenance becomes less risky.

For a static website the important boundaries are usually easy to list:

- DNS points the hostname to the intended edge.
- TLS terminates at the intended proxy.
- The proxy serves one known output directory.
- The output contains only the files the site needs.
- A 404 response is handled by the site, not by an accidental upstream.

Writing this list down is more valuable than adding a dashboard that does not answer any of those questions.

## Complexity should earn its place

A new layer is worthwhile when it removes more uncertainty than it creates. A build step that fingerprints assets can be useful when caching is important. A database is useful when content needs transactions. Neither is automatically an improvement for a small, mostly static site.

The same rule applies to client-side JavaScript. If a page needs a search index or an interactive editor, add the smallest implementation that meets that requirement and update the content security policy deliberately. If the page is a document, plain HTML is often easier to inspect and more resilient.

## Make ownership explicit

Every file should have a reason to exist and a clear way to update it. A vendored theme should record its upstream version and license. A generated directory should be marked as generated. A resource download should say what it contains, when it was updated, and how its checksum was produced.

These small statements prevent future operators from treating an accidental artifact as a supported interface.

## Leave room for the next change

The best small system is not the one with the fewest lines. It is the one where the next change has a visible place to go. A notes site can start with Markdown, a static generator, and a web server; if it later needs search or comments, the new requirement can be evaluated on its own rather than assumed from the beginning.

## Sources

- [Hugo directory structure](https://gohugo.io/getting-started/directory-structure/), directory conventions for content and assets, checked 2026-08-22.
- [Caddy `file_server` directive](https://caddyserver.com/docs/caddyfile/directives/file_server), static file serving defaults referenced by the design, checked 2026-08-22.

========================================================================


## ssh-host-key-policy.md

---
title: SSH host keys are part of the deployment record
date: 2026-08-22
description: Keep first contact explicit and reject unexpected host-key changes.
summary: Record the expected fingerprint, use a deliberate StrictHostKeyChecking policy, and treat changes as an incident until explained.
tags:
  - ssh
  - security
  - operations
series:
  - Web security
ShowToc: true
cover:
  image: /visuals/ssh-host-key.svg
  alt: A recorded SSH host fingerprint connected to a key symbol.
series_weight: 4
---

An SSH private key authenticates a client; it does not tell the client which server it reached. The server's host key and the local `known_hosts` database provide that second half of the identity check.

## Separate first contact from rotation

With `StrictHostKeyChecking=yes`, SSH refuses unknown hosts and changed keys. That is the safest default for a fixed production endpoint, provided the expected key is installed through a trusted channel. `accept-new` is a narrower convenience for a fleet where new hosts are expected: it accepts a never-seen key but still refuses a changed key.

~~~sshconfig
Host production-site
    HostName www.example.test
    User deploy
    IdentityFile ~/.ssh/site_ed25519
    StrictHostKeyChecking yes
    UserKnownHostsFile ~/.ssh/known_hosts
~~~

Do not “fix” a warning by adding `StrictHostKeyChecking=no` to a deployment script. That converts a useful signal into silent acceptance. If a host is rebuilt, record the replacement fingerprint and review why the old key is no longer valid.

## Make the fingerprint reviewable

Obtain the fingerprint from the host owner or console before the first connection, then compare it with what the client sees. A key change should have a ticket, maintenance note, or other explanation that another operator can inspect.

The same rule applies to automation: the known-hosts file is configuration with security consequences, not disposable cache data. Back it up with the deployment record, but do not publish private keys or copied credentials with the site artifact.

## Sources

- [OpenBSD `ssh_config`](https://man.openbsd.org/ssh_config), `StrictHostKeyChecking` and `UserKnownHostsFile`, checked 2026-08-22.
- [OpenSSH `ssh-keygen` manual](https://man.openbsd.org/ssh-keygen), fingerprint inspection, checked 2026-08-22.

========================================================================


## static-site-release-checklist.md

---
title: A release checklist for a static site
date: 2026-08-22
description: A compact pre-publish check for generated HTML, links, assets, and response headers.
summary: Verify the artifact before replacing the live directory, and keep the checks close to the release command.
tags:
  - static-web
  - release
  - testing
series:
  - Release and recovery
ShowToc: true
cover:
  image: /visuals/release-checklist.svg
  alt: A release clipboard with four completed verification checks.
series_weight: 3
---

A static site is simple to publish, but simplicity does not remove the need for a release check. The useful checks are small enough to run on every release and specific enough to fail for a real reason.

## Build a clean artifact

Use a pinned generator version and build into a disposable output directory:

~~~sh
hugo version
hugo --gc --minify --destination public
~~~

The exact command depends on the project, but the important property is that the output is reproducible from the tracked source. Do not edit generated HTML by hand after the build.

## Check the artifact, not only the source

Inspect the generated tree and verify that expected entry points exist:

~~~sh
test -s public/index.html
test -s public/404.html
test -s public/robots.txt
test -s public/sitemap.xml
find public -type f -print | sort
~~~

Then search for accidental placeholders, local paths, and development-only hosts:

~~~sh
rg -n 'localhost|127\.0\.0\.1|TODO|example\.com|/Users/' public --glob '!notes/a-release-checklist-for-a-static-site/**'
~~~

An intentional example in an article should be clearly fenced as an example. A host name or private path in a link is usually a release mistake.

## Exercise the public routes

Serve the artifact locally and request the paths a visitor can reach:

~~~sh
hugo server --bind 127.0.0.1 --port 1313 --disableFastRender
curl -I http://127.0.0.1:1313/
curl -I http://127.0.0.1:1313/notes/
curl -I http://127.0.0.1:1313/resources/
curl -I http://127.0.0.1:1313/missing-page/
~~~

Confirm that the 404 route returns a 404 status in the production server. A pretty error page with a 200 status still breaks monitoring and caches.

## Record the result

Keep the source commit, generator version, and checksum of each downloadable resource in the release record. This makes it possible to compare a live file with the source that produced it without relying on memory.

The downloadable version of this checklist is in the [Resources](/resources/) section.

## Sources

- [Hugo command reference](https://gohugo.io/commands/hugo/), generator flags for a clean production build, checked 2026-08-22.
- [curl manual](https://curl.se/docs/manpage.html), HTTP verification and header inspection options, checked 2026-08-22.
- [Static-site release checklist](/resources/), downloadable companion resource.

========================================================================


## systemd-restart-policy.md

---
title: Restart policies need a rate limit
date: 2026-08-22
description: Let systemd recover transient failures without hiding a permanent configuration error.
summary: Use an explicit service type, bounded restart behavior, and start-rate limits that leave evidence in the journal.
tags:
  - systemd
  - operations
  - reliability
series:
  - Release and recovery
ShowToc: true
cover:
  image: /visuals/restart-loop.svg
  alt: A service inside a bounded and rate-limited restart loop.
series_weight: 9
---

Automatic restart is useful for a long-running service, but an unbounded loop can turn a clear failure into a noisy one. The unit should describe both the normal process lifecycle and the amount of recovery that is acceptable.

## Track setup failures

For a long-running process, `Type=exec` makes failures to execute the configured program visible during start-up rather than treating a fork as success. Pair it with an explicit `ExecStart` and the least-privileged `User` that can perform the work.

~~~ini
[Service]
Type=exec
User=site
ExecStart=/srv/site/bin/server
Restart=on-failure
RestartSec=5s
~~~

`Restart=on-failure` is a recovery policy, not a health check. If the process stays alive while serving bad responses, systemd will not know that the site is broken; an external smoke test still has a job.

## Bound the loop

Systemd applies start-rate limiting through `StartLimitIntervalSec=` and `StartLimitBurst=`. Choose values that allow a short transient outage but stop a crash loop from consuming the host's resources. When the limit is hit, the journal should contain the evidence needed to distinguish “restarted successfully” from “gave up after repeated failure”.

After changing a unit, verify the loaded configuration and inspect recent logs:

~~~sh
systemctl daemon-reload
systemctl restart example.service
systemctl status --no-pager example.service
journalctl -u example.service -n 80 --no-pager
~~~

Do not use a restart policy to conceal missing files, invalid permissions, or a bad environment. Fix the first failure, then test the recovery path.

## Sources

- [systemd.service](https://www.freedesktop.org/software/systemd/man/latest/systemd.service.html), service type, restart, and start-rate limiter, checked 2026-08-22.
- [systemd.unit](https://www.freedesktop.org/software/systemd/man/latest/systemd.unit.html), common unit sections and drop-in semantics, checked 2026-08-22.

========================================================================


## systemd-timer-maintenance.md

---
title: A maintenance timer should leave its last result visible
date: 2026-08-23
description: Pair a oneshot service with a timer, bounded runtime, and journal evidence.
summary: Scheduling is only half the job; operators also need to see when the task last ran and how it ended.
tags:
  - systemd
  - maintenance
  - operations
series:
  - Release and recovery
ShowToc: true
cover:
  image: /visuals/maintenance-timer.svg
  alt: A maintenance task connected to a clock, bounded runtime, and journal result.
series_weight: 10
---

Cron-like syntax is not the whole contract for a maintenance task. The command needs a clear service identity, a bounded runtime, and evidence that distinguishes “not scheduled” from “ran and failed”. A systemd timer and oneshot service keep those concerns separate.

## Define the work as a service

Use an explicit user and absolute command path:

~~~ini
# /etc/systemd/system/site-check.service
[Unit]
Description=Read-only public site check

[Service]
Type=oneshot
User=sitecheck
ExecStart=/usr/local/bin/check-public-site
TimeoutStartSec=2min
~~~

`TimeoutStartSec` prevents a network or subprocess wait from occupying the schedule indefinitely. The helper should return non-zero when its contract fails and must not modify the service it checks.

## Schedule the service

~~~ini
# /etc/systemd/system/site-check.timer
[Unit]
Description=Run the public site check every hour

[Timer]
OnCalendar=hourly
Persistent=true
RandomizedDelaySec=5min
Unit=site-check.service

[Install]
WantedBy=timers.target
~~~

`Persistent=true` can run a missed calendar event after the machine returns. `RandomizedDelaySec` spreads routine work across a window; it is not appropriate when the task must run at an exact instant.

## Validate before enabling

Inspect the calendar expression, then start the service manually:

~~~sh
systemd-analyze calendar hourly
systemctl start site-check.service
systemctl status site-check.service
journalctl -u site-check.service -n 50 --no-pager
~~~

Enable the timer only after the service succeeds with the intended user, environment, paths, and network access:

~~~sh
systemctl enable --now site-check.timer
systemctl list-timers site-check.timer
~~~

## Alert on the service result

The timer firing is not success. Monitor the oneshot service's exit status or journal result. Keep stdout concise and send detailed artifacts to a bounded path if they are needed for debugging.

## Sources

- [systemd.timer(5)](https://man7.org/linux/man-pages/man5/systemd.timer.5.html), calendar timers, persistence, and delay behavior, checked 2026-08-23.
- [systemd.time(7)](https://man7.org/linux/man-pages/man7/systemd.time.7.html), calendar expression syntax, checked 2026-08-23.
- [systemd.service](https://www.freedesktop.org/software/systemd/man/latest/systemd.service.html), oneshot service semantics, checked 2026-08-23.
