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
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,12 @@ __pycache__/
# Each names one particular machine rather than the project.
# The whole directory is ignored so a value added later lands ignored by default.
# `deploy/env.example` is the committed template and sits outside the directory.
# The last pattern is the backstop for one written outside the directory, matching the
# `<server>.<environment>.env` shape those files are named for rather than a single literal name.
# `deploy/env.example` does not end in `.env`, so it is unaffected.
secrets/
**/secrets
.env
*.env

# The working copies of the host channel described in OPERATIONS.md.
# They carry server internals, and the host's own backup is what makes them durable.
Expand Down
104 changes: 86 additions & 18 deletions OPERATIONS.md

Large diffs are not rendered by default.

97 changes: 96 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ See [Release History][history] for complete release notes and older versions.
## Table of Contents <!-- omit from toc -->

- [Use Cases](#use-cases)
- [Migration from WordPress](#migration-from-wordpress)
- [How a Change Reaches the Site](#how-a-change-reaches-the-site)
- [Configuration](#configuration)
- [Questions or Issues](#questions-or-issues)
- [Development Environment Setup](#development-environment-setup)
Expand All @@ -52,6 +54,97 @@ The site answers far more addresses than it renders pages, because it has served

Deployment is a release directory plus a symlink. A build is installed alongside its predecessors, verified, and made live by swapping one link, so a rollback is the same swap in reverse. See [OPERATIONS.md][operations].

## Migration from WordPress

The site has served the same domain since 2008, across three platforms: Blogger until 2012, WordPress until 2026, and Hugo from then on. Converting the posts took an afternoon. Preserving sixteen years of inbound links was the work, and it is why this repository carries a URL contract and gates it rather than trusting the build.

The account of that migration is a post on the site, [Moving This Blog From WordPress to Hugo][migration-post]. It covers what a WordPress export holds and what it leaves out, why the sitemap named barely a tenth of the addresses the site was actually serving, how the Blogger-era permalinks resolve through a lookup table rather than a pattern, why media fetched over HTTP is not the same bytes as the media in the export and only a content hash tells them apart, and which Hugo default moves every taxonomy archive to a new address without reporting anything.

## How a Change Reaches the Site

A change starts on a branch in this repository and ends as bytes on a VPS. Every stage carries a gate, and three of them add a human read, because the failure this repository exists to catch is an address that stops answering, and no check that skips a running server can see one.

Both deploys are manual workflow dispatches, so a merge publishes nothing and each environment goes live by a deliberate run.

[`WORKFLOW.md`][workflow] states the same CI machinery as a contract for tooling, and [`OPERATIONS.md`][operations] holds the procedure to run at a keyboard.

```mermaid
flowchart LR
subgraph local["1. On a branch"]
direction TB
w["New branch, write the post"] --> b["Build, warnings are fatal"]
b --> p["Gate: every page that must render, renders"]
p --> m["Release onto the local mirror"]
m --> l["Gate: every URL in the contract, against a running server"]
l --> e1["Human: read the page"]
end

subgraph gh["2. On GitHub"]
direction TB
pr["Pull request into develop"] --> ci["CI: lint, spelling, build, URL parity"]
ci --> rv["Automated review, then a human squash merge"]
end

subgraph stg["3. Staging, behind the auth gate"]
direction TB
ds["Dispatch: deploy staging"] --> ls["Gate: every URL in the contract, run by CI"]
ls --> e2["Human: read staging"]
end

subgraph prd["4. Production, public"]
direction TB
pm["Pull request: develop into main"] --> dp["Dispatch: deploy production"]
dp --> lp["Gate: every URL in the contract, run by CI"]
lp --> e3["Human: read production"]
end

e1 --> pr
rv --> ds
e2 --> pm
```

**1. Write on a branch, and prove the artifact locally.** A post is a markdown file under `content/posts/`, written on a feature branch with whatever editor the author prefers. The build treats a warning as fatal, so a deprecated theme API fails it rather than accumulating. The build gate then checks the render half of the contract, which is every address that must return a page.

Most of the contract is not pages, though. It is redirects, and a redirect is the web server's job, so no build reaches them. The release installs onto a mirror on the maintainer's own network, which runs the same Caddy container and the same bundle as the server behind the same Traefik front end. The live gate follows every URL in the contract against that mirror and checks each redirect's destination rather than its status code. A human then reads the page, because no gate has an opinion about the writing.

**2. Open a pull request into `develop`.** CI runs lint, spelling, workflow and config validation, the build, and the render gate on a clean checkout. It does not prove the redirects, which is why the local run is a prerequisite rather than a convenience: a change to the Caddy config or to a redirect map goes green in CI while the redirect it broke stays broken. An automated review runs against the branch, and a human squash-merges it.

**3. Deploy to staging.** A dispatched workflow builds the commit, uploads the release, flips the symlink, and runs the same live gate from CI against the running site. The local mirror proves the artifact, and staging proves the infrastructure that exists only on the server: routing, TLS, the deploy key, and its confined transport. Staging keeps its authentication gate on and the check presents a token, so a byte-identical copy of the public site is never exposed to a crawler. A human then reads it.

**4. Promote, and deploy production.** `develop` merges into `main` through a pull request, and production is a second dispatch that accepts `main` alone. The same gate runs a third time, against the public address and with no token, and a human reads the result.

Each branch feeds one environment:

```mermaid
flowchart LR
feat["feature branch"] -->|squash| dev["develop"]
dev -->|merge commit| main["main"]
dev -.->|manual dispatch| stg["staging site"]
main -.->|manual dispatch| prod["production site"]
```

### What Happens While the Site Runs <!-- omit from toc -->

Publishing is half of it. The other half runs on its own cadence, because the contract proves only the addresses someone wrote down.

```mermaid
flowchart LR
site["Production, serving"] --> log["Edge access log, one line per request"]
log --> outward["Outward: asked for, and not here"]
log --> inward["Inward: here, and never asked for"]
outward --> add["Add the address to the contract, add a redirect"]
add --> next["Ships with the next change, top of the pipeline"]
inward --> judge["Decide whether unread content is preservation or clutter"]
site --> cfg["Host configuration archived off the VPS"]
cfg --> rebuild["A rebuild restores the config and redeploys"]
```

**Real traffic is the only source that finds what the contract misses.** Every request to the host is recorded at the edge, one line per request, and the review runs in two directions. The outward pass reads non-200 responses. A 404 on a path shaped like real content means an old link nobody recorded, and the fix is to add the address to the contract and add a redirect, which then ships through the pipeline above like any other change. Scanners probing for `wp-login.php` dominate the raw count and are filtered by shape rather than investigated. The inward pass subtracts every address that has ever answered 200 from the set the site builds, which names content no reader has reached. That evidence accumulates slowly, and it is the only thing that settles whether media the old platform never published is worth carrying.

**Backups protect the server rather than the site.** The site is reproducible from this repository by running a deploy, so what is worth keeping is the host's configuration: the container definitions, the proxy configuration, and the deploy account with its restricted key. A bare-metal restore rebuilds the host, restores that configuration, and deploys again. A procedure that backs up the deploy root protects a copy of something git already holds.

[OPERATIONS.md][operations] holds the detail: the commands, the environments, the rollback, and the three log tiers with what each cannot see.

## Configuration

| Path | Holds |
Expand Down Expand Up @@ -86,7 +179,7 @@ deploy/make-release.sh
checks/check-live-urls.sh "$HUGO_BASEURL"
```

The deploy root and the base URL come from an untracked file per environment under `secrets/`, copied from [deploy/env.example][env-example] and selected with `ENV_FILE`. The whole `secrets/` directory is gitignored, so host-specific values stay out of the published history.
The deploy root and the base URL come from an untracked file per environment under `secrets/`, named `<server>.<environment>.env`, copied from [deploy/env.example][env-example] and selected with `ENV_FILE`. `secrets/local.production.env` is the one read when `ENV_FILE` is unset. The whole `secrets/` directory is gitignored, so host-specific values stay out of the published history.

## 3rd Party Tools <!-- omit from toc -->

Expand Down Expand Up @@ -126,8 +219,10 @@ Licensed under the [MIT License][license]\
[history]: ./HISTORY.md
[hugo-config]: ./hugo.yaml
[license]: ./LICENSE
[migration-post]: ./content/posts/2026/08/01/moving-this-blog-from-wordpress-to-hugo.md
[operations]: ./OPERATIONS.md
[releases-link]: https://github.com/ptr727/Blog/releases
[workflow]: ./WORKFLOW.md

<!-- External -->

Expand Down
Loading