Staging is not production: how this site ships

This website is built by a pipeline you can read: the workflow is .forgejo/workflows/ymrtech-deploy.yml in the same public repository as the pages themselves. It is short enough to read in one sitting, and it contains one design decision worth writing about, plus one incident that produced it.

Three triggers, two environments

Event What runs What it writes
Pull request the full gate, plus a throwaway preview of the branch nothing outside the preview root
Push to main the full gate again staging only
Manual dispatch with promote to production the full gate a third time production

main is the release branch for both environments. They differ by trigger, not by branch: merging to main automatically verifies the exact commit that will later go to production, and production only moves when a human dispatches the promotion, which re-runs the entire suite before touching the prod docroot.

The gates are the same in every job — icon font coverage, signed audit artifacts, the Jekyll build, a check that the contact form is actually visible in the rendered page rather than hiding in an HTML comment, htmlproofer over the built site, a fragment-resolution check, the palette check, and the writing-content check that guards these posts. Each job is self-contained, so a gate added in one place has to be added in all four. That is a cost, and it is paid deliberately: a job that depends on another job’s artifacts can be skipped into a false green.

The day a pull request overwrote staging

The workflow’s own comments keep the scar tissue. Originally there was a single job, and it ran its rsync on pull_request events too. The consequence: opening or updating a PR — any PR, from any branch — replaced /srv/ymrtech-stage with that branch’s build. Staging stopped meaning “what main verified” and started meaning “whatever was pushed most recently”, which is the same as meaning nothing.

The fix is one condition on the deploy job — staging is gated to push and explicit workflow_dispatch, and the cleanup that removes a preview is gated to pull_request events instead. That one line is the difference between a staging environment and a scratch directory.

Previews get a path, not a hostname

Rather than give each branch a subdomain, a PR gets a path: stage.ymrtech.com/preview/pr-<N>/, built with --baseurl /preview/pr-<N>. That works because every internal link and asset reference in this site is generated through a Jekyll filter that applies the site’s baseurl — relative_url for links, asset_url for the handful of design assets, which adds the file’s own content hash on top. There is nowhere to hardcode a root-relative path and have it render, which is exactly the kind of property you fail to maintain until the day you need it.

The preview root is a separate tmpfs from the staging docroot, so a staging deploy (rsync --delete) cannot erase a preview, and a preview cannot overwrite staging. It is deleted when the PR closes. And because it is unreviewed branch content published on a real domain, the pipeline verifies the served URL after publishing it: it polls for HTTP 200 and then requires the X-Robots-Tag: noindex and Cache-Control: no-store headers to be present. If those headers are missing, the job fails — the assumption being that a response without them is not the preview route that was supposed to answer.

The bits that only show up in production

Two failures are worth keeping in mind as arguments for testing the real artifact rather than the source:

Why this is here rather than in a wiki

Because it is the same argument as the audit page: a delivery claim you cannot inspect is a claim about intentions. The workflow is public, the failure modes are documented next to the code that caused them, and the pipeline that builds this page is the one that would have caught me getting it wrong — the gate for these posts runs in all four jobs, and it fails the build if a post’s stable slug and its rendered campaign tag stop agreeing.

For the infrastructure underneath it — the hosts, the runner, the deploy health gate and the automatic rollback — see how the stack is built.

󰔟 Running the same kind of stack?
The tunnel this site is written from is the one I sell: WireGuard to a gateway in Montréal, three DNS postures, one price — and the same "check it yourself" habit as everything above.
[ CREATE ACCOUNT ] [ WHAT IS THE VPN ]
󰣨 ymrtech@ymrtech | 󰌠 NixOS | 󰍢 UTF-8