Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion OPERATIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,15 @@ The deploy root and the base URL are the only host-specific values. A local run

**Production's deploy checks a sample of the contract, and every other run checks all of it.** The production edge bans a client that requests more than 40 distinct pages in a burst. It also bans one when more than 10 distinct pages answer `404`, `403` or `400`. A full run from the GitHub runner therefore gets it banned. The deploy hook sets `SAMPLE_CONTRACT=1` for `production` alone, and the check then requests one URL from each class. That covers each redirect class, each shape of page that must render, each media tree, and the family pages. It also covers `/all/`, `/feed.xml`, and `/sitemap.xml`, the fixed redirect destinations no list names. The script holds that to 30 distinct pages at most, and none is expected to answer an error. Staging and the local mirrors keep the whole contract, which is where it is proven. **A hand run against the VPS production site sets `SAMPLE_CONTRACT=1` too**, for the same reason the hook does. The local production mirror sits behind no such edge and keeps the whole contract. `checks/check-live-urls.sh --print-sample` lists what a sampled run requests without requesting anything. [`deploy/README.md`](./deploy/README.md#environment-variables) carries the budget.

**A hand run counts as measured through Cloudflare only once the target answers `server: cloudflare` with a `cf-ray` header.** Right after a hostname flips to proxied, the resolver on the machine running the check can keep answering with the origin for minutes. Another resolver can already answer with Cloudflare by then. A run in that window measures the origin directly. Before the run, check every host it reaches from the same machine, the family host included, and go ahead only when each prints `proxied`:

```sh
for host in <blog-host> <family-host>; do
h=$(curl -q -sSI "https://$host/")
if printf '%s\n' "$h" | grep -qi '^server: cloudflare' && printf '%s\n' "$h" | grep -qi '^cf-ray:'; then echo "$host proxied"; else echo "$host UNCONFIRMED"; fi
done
```

**Always set `SITE_BASE_URL` for anything that is not production.** The base URL is baked into the canonical tag, the feed links, and every absolute permalink, so a mirror built without it serves pages that all point back at the production address. Nothing downstream catches this, because the pages render at the right paths and the build gate passes. `make-release.sh` bridges it to Hugo's own `HUGO_BASEURL` internally, and the effective value is printed on every build for that reason.

**Never promote a staging build to production.** The base URL is baked into the canonicals, the feeds, and every absolute permalink at build time. A staging artifact served as production points every page at the staging host. Each environment's deploy builds its own release from the ref instead.
Expand Down Expand Up @@ -593,7 +602,7 @@ The maps are generated by [`capture/build-redirects.py`](./capture/build-redirec

The bundle also carries `viljoen.family`, a single static page whose source is [`sites/viljoen.family/`](./sites/viljoen.family/). `make-release.sh` copies it to `family/` beside `site/`, leaving out its README, and precompresses it the same way. It shares the release, the containers, and the rollback with the blog, so a family change ships as a blog release.

A second site block in `deploy/Caddyfile` serves it on the same port, selected by the `Host` header the proxy forwards. Any other hostname falls through to the blog's `:8080` block, so the blog's redirects never answer on the family host. The family block carries its own security headers and CSP, and answers a missing path with the page itself at a `404` status. It also routes the page's two languages: `/` redirects to `/af/` for a browser whose first language is Afrikaans, while `/en/` and `/af/` never redirect.
A second site block in `deploy/Caddyfile` serves it on the same port, selected by the `Host` header the proxy forwards. Any other hostname falls through to the blog's `:8080` block, so the blog's redirects never answer on the family host. The family block adds a CSP to the security headers every block sends. It answers a missing path with the page itself at a `404` status. It also routes the page's two languages: `/` redirects to `/af/` for a browser whose first language is Afrikaans, while `/en/` and `/af/` never redirect.

Two container variables belong to it, both part of the [container contract](./deploy/README.md#container-contract). `FAMILY_SITE_ROBOTS` sets the family site's `X-Robots-Tag` apart from the blog's `SITE_ROBOTS`, for the reason given in [Identifying the environment](./deploy/README.md#identifying-the-environment). `FAMILY_SITE_ADDRESS` adds a local mirror's private name, per [Serving the family site on a local name](./deploy/README.md#serving-the-family-site-on-a-local-name).

Expand Down
12 changes: 9 additions & 3 deletions deploy/Caddyfile
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,12 @@
# Without this a stale config passes the URL contract against rules never shipped.
header X-Blog-Release "@@RELEASE@@"

header {
Strict-Transport-Security "max-age=31536000"
X-Content-Type-Options "nosniff"
Referrer-Policy "strict-origin-when-cross-origin"
}

# One map serves four classes because their key spaces are disjoint.
# Merging them keeps those four classes to a single lookup.
map {path} {mapped} {
Expand Down Expand Up @@ -178,11 +184,11 @@ http://viljoen.family:8080, http://viljoen.vps.insanegenius.net:8080 {$FAMILY_SI
Content-Security-Policy "default-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src https://fonts.gstatic.com; connect-src 'self' https://fonts.googleapis.com https://fonts.gstatic.com; script-src 'self'; img-src 'self' data:"
}

@svg path *.svg
header @svg Cache-Control "public, max-age=604800"
@image path *.svg *.png *.ico
header @image Cache-Control "public, max-age=3600"
@code path *.css *.js
header @code Cache-Control "no-cache"
@page not path *.svg *.css *.js
@page not path *.svg *.png *.ico *.css *.js
header @page Cache-Control "no-cache"

# The root follows the browser's first language, and /en/ and /af/ never redirect, so the language switch always holds.
Expand Down
16 changes: 15 additions & 1 deletion deploy/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -281,13 +281,27 @@ Every file the blog serves, and its error page, names its own lifetime. A browse
| `/assets/`, the fingerprinted stylesheet and script | `public, max-age=31536000, immutable` | The name changes with the content, so a copy is never stale |
| `/media/` and the root icons | `public, max-age=3600` | An image rarely changes, and an hour bounds how long a replaced one is still served from any copy |

A `404` answers with `no-cache` whatever its path, since a missing file in a cached class would otherwise keep that class's lifetime. The blog's redirects answer `no-cache` too, since the baseline header runs before them. A map entry a release changes is then followed at the next request. The family site keeps its own, shorter list in its block, and its error page answers `no-cache` the same way. Its language redirects answer `no-cache` as well, because the `@page` matcher reaches them.
A `404` answers with `no-cache` whatever its path, since a missing file in a cached class would otherwise keep that class's lifetime. The blog's redirects answer `no-cache` too, since the baseline header runs before them. A map entry a release changes is then followed at the next request. The family site follows the table's page and image rows, its SVGs included. Its stylesheet and script keep their names across releases, so they answer `no-cache` like its pages. A longer lifetime there would pair a new page with an old script. Its error page answers `no-cache` the same way, and so do its language redirects, because the `@page` matcher reaches them.

**Two zone settings decide whether these lifetimes hold behind Cloudflare.** Both are the host side's to set.

- **Browser Cache TTL must be "Respect Existing Headers".** A new zone's default is 4 hours. Cloudflare replaces an origin lifetime shorter than that setting with the setting. A browser would then keep a replaced image for 4 hours rather than one.
- **Caching Level must not be "Ignore Query String".** `check-live-urls.sh` asks for each media file with a query no earlier run sent, so the edge's copy cannot answer for a file a release lost. The default level, Standard, keys the cache on the query string. Ignore Query String would answer from the copy again.

## Security headers

The sites send the same security headers on every response, redirects and the `404` included:

| Header | Value | Why |
| --- | --- | --- |
| `Strict-Transport-Security` | `max-age=31536000` | After the first HTTPS visit a browser skips the cleartext redirect, which an on-path attacker could intercept |
| `X-Content-Type-Options` | `nosniff` | A browser trusts the served `Content-Type` rather than guessing one from the body |
| `Referrer-Policy` | `strict-origin-when-cross-origin` | Another site learns only the origin a reader came from, never the path |

The HSTS value carries neither `includeSubDomains` nor `preload`. `includeSubDomains` would extend the policy to every subdomain of the site's hostname, and this bundle serves none of them. `preload` asks for the whole domain to be built into browsers, where a removal takes months to reach readers. HSTS at the edge is the host side's setting, and it stays off because Cloudflare applies it to every name in the zone.

**Only the family site sends a `Content-Security-Policy`.** Its page loads few resources, so its policy names each kind: its own files, Google Fonts, inline styles, and `data:` images. Blog posts embed third-party content, so a blog policy waits on a survey of what the posts load.

## Trusting the proxy

A proxy fronts Caddy in every environment, so the peer address is always the proxy and the real
Expand Down
Loading