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
38 changes: 36 additions & 2 deletions OPERATIONS.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -45,9 +45,16 @@ So release to the local mirror and run the live check **before** opening a pull
```sh
set -a; . secrets/.env; set +a
deploy/make-release.sh
docker restart "$CADDY_CONTAINER" # required: see below
checks/check-live-urls.sh "$HUGO_BASEURL"
```

**Restart every time, even though only some changes strictly need it.** Caddy expands `import` at config-parse time, both for the site config and for the `map` blocks that read `maps/*.map`, and it does not watch those files. Swapping the `current` symlink therefore changes what a *static file* request resolves to, per request, while the redirect rules and map tables stay exactly as they were when Caddy last loaded. Verified against the running mirror: a new map entry present in the live release on disk returned 404 until the container was restarted, then 301.

So the failure is specific. **When the release changed `deploy/Caddyfile` or anything under `deploy/maps/`**, checking without a restart exercises the **previous** rules, and a broken redirect reports `PASS` while the shipped artifact is broken. A content-only release does not have this problem, because the rules Caddy already holds are still the right ones.

The step is unconditional anyway, for two reasons. Deciding correctly means knowing whether anything reached the config, which is easy to get wrong when a change spans several paths or a map was regenerated as a side effect. And getting it wrong is silent, since the wrong answer is a green check rather than an error. A restart costs a few seconds on a static site, which is cheaper than reasoning about it each time.

Sourcing `secrets/.env` first puts the deploy root and the base URL in the environment, so no literal value is typed. `make-release.sh` then takes no arguments, and it refuses to install a release that fails the build gate. `check-live-urls.sh` does take a base URL, which is where the sourced `$HUGO_BASEURL` goes. It follows all 1,245 URLs against the running mirror, checking each redirect's destination rather than trusting its status code.

Expect `PASS - 1245 URLs honored`. Anything less is a finding, and the output names each URL that failed and what it answered.
Expand DownExpand Up@@ -81,9 +88,12 @@ Point `current` at the previous release. The swap is a single rename, so a reque
```sh
ln -sfn "releases/<previous>" "<deploy-root>/.current.tmp"
mv -Tf "<deploy-root>/.current.tmp" "<deploy-root>/current"
docker restart "$CADDY_CONTAINER"
```

No restart and no reload. The container mounts the parent directory, so the kernel resolves `current` per request and the change is visible immediately.
The content reverts on the rename alone, because the container mounts the parent directory and the kernel resolves `current` per request. **The rules do not.** Caddy holds the Caddyfile and the maps as parsed config, so without the restart a rollback serves the previous release's content under the current release's redirects, which is precisely the mismatch that shipping the config inside the bundle exists to prevent.

The restart is therefore part of the rollback, not an optional follow-up. It costs a few seconds of downtime on a static site, which is the cheaper half of the trade.

Verify with `checks/check-live-urls.sh` against the environment before considering the rollback finished.

Expand All@@ -101,9 +111,33 @@ The container mounts the deploy root **read-only**, and mounts the **parent** ra

Routing differs by environment and the bundle does not. Traefik on the home host has the Docker provider enabled, so container labels route. Pangolin's Traefik on the VPS does not, so routing there is created in the Pangolin UI and labels are silently ignored.

### The bootstrap, and why it is not in the release

The container reads three host paths, and only one of them a release ever writes:

| Host path | Mounted at | Written by |
| --- | --- | --- |
| `$DEPLOY_ROOT` | `/srv/blog`, read-only | every release |
| `$CADDY_APPDATA/config` | `/config` | placed once, by hand |
| `$CADDY_APPDATA/data` | `/data` | Caddy itself, persisting state across a recreate |

[`deploy/bootstrap.Caddyfile`](./deploy/bootstrap.Caddyfile) goes in the `config` directory and is the **only** Caddy file outside the release bundle. It carries a single `import` and no rules of its own, deliberately: everything describing the site ships inside the release, so a rollback reverts the rules and the content together. Rules held here instead would leave a rolled-back site being served by the current release's redirects.

Because it sits outside the bundle, no release updates it. Install or refresh it explicitly:

```sh
set -a; . secrets/.env; set +a
install -m 644 deploy/bootstrap.Caddyfile "$CADDY_APPDATA/config/Caddyfile"
docker restart "$CADDY_CONTAINER"
```

A restart is needed whenever **any** Caddy config changes, not only this file. That includes `deploy/Caddyfile` and anything under `deploy/maps/`, because both are expanded at config-parse time and Caddy does not watch them. Only static file requests follow the `current` symlink per request. See "Local Verification Before a Pull Request" above, where skipping the restart is the difference between a real check and a false pass.

`CADDY_APPDATA` is recorded in `secrets/.env` for exactly this reason. No script reads it, so a rebuild would otherwise depend on someone remembering where the bootstrap goes.

## Redirects

The site answers roughly a thousand addresses it does not render. They are satisfied by eleven regular-expression rules and five map files, all inside the bundle.
The site answers 917 addresses it does not render, satisfied by 13 `redir` directives reading 5 map files, all inside the bundle. [`deploy/README.md`](./deploy/README.md) carries the per-class breakdown and the counts; this section covers the operational shape only, so the two do not restate each other.

Ordering is load-bearing, so every redirect lives in a single `route` block. Outside one, Caddy sorts directives by its own precedence rather than by file order, and the broad attachment rule claims the per-post comment feeds that the narrower rule must match first.

Expand Down
1 change: 0 additions & 1 deletion TODO.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,7 +23,6 @@ The site is built and gated in CI. It is on GitHub, and it is not yet serving it
## Next, in dependency order

- Dispatch `publish-release.yml` once to prove the release path, which exists but has never run.
- Re-derive the rule counts in `deploy/README.md`. It says "11 regex rules plus 5 map files", while the Caddyfile carries 13 `redir` directives, so the R1 to R11 numbering does not map one-to-one onto what the file actually does. Found by review on #5, where the same count was quoted and has since been dropped rather than guessed at.
- Provision the VPS: an unprivileged `blogdeploy` user, the deploy root, and `unattended-upgrades` with automatic reboot.
- Restrict the deploy key with `restrict,command=...`, no pty and no forwarding, so it can do nothing but rsync into `releases/` and swap the symlink. Generate per-environment keys so staging cannot reach production.
- Choose the staging FQDN, add its DNS record, and expose it through Pangolin as a public resource with **no auth**, since CI's live-URL check has to reach it. Authentication defaults to on for a public resource and has to be turned off deliberately.
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -196,9 +196,15 @@ Everything that is not a rendered page is the web server's job, so the choice of

I evaluated static-web-server and ruled it out. Its redirect matching looks at the path only, and the query string is never an input. This blog has 110 legacy `/?p=<id>` shortlinks, so `/?p=123` would have matched `/`, redirected the homepage, and carried the query through. It also does a linear regex scan per request with no lookup primitive.

Caddy handles it in **11 regex rules and 5 map files**. Maps are the right structure for the cases where no pattern can derive the answer: the Blogger permalinks, the `?p=` ids, and the attachment slugs.
Caddy handles it in **13 redirect directives and 5 map files**. Maps are the right structure for the cases where no pattern can derive the answer: the Blogger permalinks, the `?p=` ids, and the attachment slugs.

The deploy is deliberately boring. A release is a directory containing the built site, the Caddy config, and the redirect maps *together*, and going live is swapping one symlink. Shipping the config inside the release is what makes a rollback honest, because the redirect rules and the content they point at move as one unit. Rolling back cannot leave yesterday's site being served by today's rules.
The deploy is deliberately boring. A release is a directory containing the built site, the Caddy config, and the redirect maps *together*, and going live is swapping one symlink. Shipping the config inside the release is what makes a rollback honest, because the redirect rules and the content they point at move as one unit.

**With one catch I got wrong at first, and it is worth knowing if you build this.** Swapping the symlink reverts the *content* immediately, because the kernel resolves the link per request. It does not revert the *rules*. Caddy expands its config, including the imported map files, when it loads, and it does not watch those files afterwards. So a rollback without a reload gives you yesterday's pages served by today's redirects, which is the exact mismatch the bundle was supposed to prevent.

Worse, it makes verification lie. Change a redirect, deploy, run your checker without reloading, and the checker exercises the *old* rules and reports a pass while the thing you shipped is broken. I found this by adding a deliberate probe entry to a map, deploying it, and watching the URL keep returning 404 until I restarted the container, at which point it returned the 301 it should have all along.

The fix is one line, a restart after any deploy or rollback that touches the config. The lesson is the general one: an atomic swap is only atomic for the thing that actually reads through it per request.

Unchanged files are hard-linked from the previous release, so ten retained releases cost about 600 MB rather than 5.6 GB.

Expand Down
45 changes: 27 additions & 18 deletions deploy/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -88,26 +88,35 @@ Everything the site does not render is the web server's job, and the workload co
- **The query string must be matchable.** 110 `?p=<id>` shortlinks redirect on the query alone. A server that matches on the path only would resolve `/?p=123` as `/`, redirect the homepage, and carry the query through to it.
- **There must be a lookup primitive.** 279 of the 917 resolve through map files rather than patterns, since no rule can derive their destination, and the five maps carry 661 entries between them. A linear scan of that many rules per request is the wrong shape.

The 917 redirects are 11 regex rules plus 5 map files. A map is used wherever no pattern can derive the answer from the input.
The Caddyfile carries **13 `redir` directives**, reading **5 map files** through **3 `map` blocks**. Ten directives match on a pattern and three resolve through a map lookup, which is used wherever no pattern can derive the destination from the input.

| Rule | Covers | Shape |
Directives and URL classes are not one to one, in both directions. `@mapped` is a single directive serving three classes, because their key spaces are disjoint and merging them keeps one lookup on the hot path. `@uploads` is one directive covering a URL set that is gated separately.

Each row below is a **URL class**, named by the matcher that serves it, so the table can be checked against [`Caddyfile`](./Caddyfile) by grep rather than by trust.

| Matcher | Class size | Shape |
| --- | --- | --- |
| R1 | 216 | `/YYYY/MM/DD/post/<child>/` -> `/YYYY/MM/DD/post/`, attachment pages and per-post feeds |
| R2 | 107 | `/YYYY/MM/DD/post/<child>/feed/` -> `/YYYY/MM/DD/post/`, ordered **before** R1 |
| R3 | 78 | `/YYYY/` and `/YYYY/MM/` -> `/` |
| R4 | 5 | `/YYYY/page/N/` -> `/` |
| R5 | 11 | `/author/<name>/` and its pagination -> `/` |
| R6 | 1 | `/feed/` -> `/feed.xml` |
| R7 | 192 | `/tag/<t>/feed/` and `/category/<c>/feed/` -> the term archive |
| R8 | 778 | `/wp-content/uploads/(.*)` -> `/media/$1`, preserving every legacy image URL |
| R9 | 2 | `/p/<slug>.html` -> `/<slug>/`, Blogger's static-page shape |
| R10 | 2 | `/feeds/posts/default` -> `/feed.xml`, Blogger's Atom feed |
| R11 | wildcard | `/YYYY_MM_01_archive.html` -> one post, whatever the date |
| `slugs.map` | 107 | bare `/<attachment-slug>/` -> best destination |
| `p-ids.map` | 110 | `/?p=<id>` -> permalink |
| `blogger.map` | 59 | `/YYYY/MM/slug.html` -> current post URL, both full and truncated slug |

Two orderings are load-bearing. R2 precedes R1 because both match the same shape. No golden URL is 5 segments under a date, so R1 cannot swallow a page that must render, and R8 rewrites under a prefix no rendered page occupies.
| `@post_child` | 216 | `/YYYY/MM/DD/post/<child>/` -> the post, attachment pages |
| `@term_feed` | 192 | `/tag/<t>/feed/` and `/category/<c>/feed/` -> the term archive |
| `@post_id` | 110 | `/?p=<id>` -> the permalink, via `p-ids.map` |
| `@post_child_feed` | 107 | `/YYYY/MM/DD/post/<child>/feed/` -> the post, ordered **before** `@post_child` |
| `@mapped` via `slugs.map` | 107 | bare `/<attachment-slug>/` -> best destination |
| `@date_archive` | 83 | `/YYYY/`, `/YYYY/MM/`, and their pagination -> `/all/` |
| `@mapped` via `blogger.map` | 59 | `/YYYY/MM/slug.html` -> the current post |
| `@blogger_archive` | 21 | `/YYYY_MM_01_archive.html` -> `/all/`, any date, including ones never covered |
| `@author` | 12 | `/author/<name>/`, its pagination and feed -> `/` |
| `@site_feed` | 3 | `/feed/`, `/comments/feed/`, `/about/feed/` -> `/feed.xml` |
| `@mapped` via `terms.map` | 3 | the three empty term archives |
| `@blogger_feed` | 2 | `/feeds/posts/default` -> `/feed.xml`, Blogger's Atom feed |
| `@blogger_page` | 2 | `/p/<slug>.html` -> `/<slug>/`, Blogger's static-page shape |

**Those thirteen classes sum to 917**, which is the line count of [`checks/redirect-urls.txt`](../checks/redirect-urls.txt) and the whole redirect contract.

`@uploads` is deliberately absent from that table and from the 917. It rewrites `/wp-content/uploads/(.*)` to `/media/$1`, preserving all 778 legacy image URLs, which are gated by `golden-media-legacy.txt` on their own. Counting them here would double-count a set that has its own list.

`@label` is the fourteenth class and is deliberately **not** in the contract. `/search/label/<Label>` was never a redirect: the old platform answered it with a generic search page that returns 200 for a label that never existed, so it is a soft 404 that looks alive. `labels.map` sends each label to its term archive and defaults anything unmatched to `/all/`, which is a choice rather than a preservation.

Two orderings are load-bearing. `@post_child_feed` precedes `@post_child` because both match the same shape and the broader one would claim both. No golden URL is five segments under a date, so `@post_child` cannot swallow a page that must render, and `@uploads` rewrites under a prefix no rendered page occupies.

`blogger.map` carries 59 entries rather than 48 because Blogger truncated an auto-generated slug at 40 characters on a whole-word boundary, so a long-titled post was served at the truncated URL and that is the form in search indexes. `slugs.map` is generated by `checks/build-redirects.py`, which recovers each attachment's parent from the media inventory, since all 107 have `post_parent = 0` in the export. 85 resolve to a real post and the remaining 22 were never used anywhere, so `/` is correct for them.

Expand Down
32 changes: 27 additions & 5 deletions deploy/env.example
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,32 @@
# Copy to secrets/.env and set for this host. Every value here names a particular machine rather
# than the project, which is why secrets/ is gitignored as a whole directory and this template
# lives outside it. CI sets the same variables from environment secrets and never reads a file.
# lives outside it. CI sets the deploy values from environment secrets and never reads a file.
#
# DEPLOY_ROOT is the fallback deploy root, which make-release.sh also takes as its first argument.
# HUGO_BASEURL must be set 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 production while every gate still passes.
# Naming convention: the prefix names whatever owns the value, not whatever reads it.
# HUGO_ is fixed by Hugo, which maps HUGO_<KEY> onto its own config natively.
# DEPLOY_ is the release tooling, which writes the deploy root.
# CADDY_ is the container, which owns state the release never touches.

# Written by every release, and mounted read-only by the container at /srv/blog.
# make-release.sh also takes it as a first argument, which wins over this value.
# CI sets the same name per environment, where the deploy is an rsync rather than a local copy.
DEPLOY_ROOT=/path/to/deploy/root

# Must be set 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 production while every gate
# still passes.
HUGO_BASEURL=https://blog.example.com/

# The container's persistent state root, deliberately outside DEPLOY_ROOT.
# Two directories hang off it, and a release writes neither:
# <CADDY_APPDATA>/config mounted at /config, holding the bootstrap Caddyfile
# <CADDY_APPDATA>/data mounted at /data, persisting Caddy state across a recreate
# No script reads this. It is recorded so a rebuild does not depend on someone remembering
# where the bootstrap goes, since the release bundle deliberately does not carry it.
CADDY_APPDATA=/path/to/container/appdata

# The container to restart when config changes.
# Caddy expands the site config and the map files at parse time and does not watch them, so a
# release or a rollback that changes either is not live until the container restarts.
CADDY_CONTAINER=blog