mabel.ink

My staging environment wasn't protecting anything

July 23, 2026

I built a staging environment for this site. Self-hosted, its own CI runner, HTTPS, the full pipeline. Then I used it in a way that gave me exactly none of its benefit, and it took me a while to notice.

The setup

Two environments, two CI systems, one repository.

Staging runs on a server in my house. A self-hosted Gitea instance triggers a runner that builds the site and publishes it behind a reverse proxy. Production runs on Azure Static Web Apps, deployed by GitHub Actions.

The repo has two remotes. origin points at Gitea, github points at GitHub. Both were tracking main. So my deploy routine was:

git push origin main
git push github main

Two commands, run back to back, usually within the same second.

What that actually does

Both pipelines watch main. Pushing to both remotes means staging and production receive the same commit at the same moment and start building in parallel.

If a change were broken, production would break at the same time staging did. Staging would warn me about nothing, because there is no window between the two in which a warning could matter.

I had built a gate and installed it beside the door.

The fix is a branch, not a habit

The first correction that comes to mind is discipline. Push to Gitea, check staging, then push to GitHub. That is better, and it costs nothing to start doing.

But it is a control that depends on remembering, and controls that depend on remembering fail eventually. It will be late, the change will be small, and I will push both because the change is "obviously fine."

The structural fix is to stop having both pipelines watch the same branch:

Now the flow has a gap in it on purpose:

push staging  →  Gitea builds  →  staging site
                                   verify here
open a PR: staging → main
merge         →  Azure builds  →  production

Promotion became a merge instead of a second push. It is deliberate, it produces a diff I can read before anything ships, and it leaves a record.

The reason main became production rather than the other way around is that Azure was already wired to main, and Static Web Apps routes non-production branches to preview environments. It was less risky to adopt the platform's assumption than to fight it. main as the production-ready branch is the common convention anyway.

The part I haven't fixed

There is a second problem, and the branch change doesn't touch it.

Staging and production build separately. Gitea builds on my self-hosted runner. GitHub builds again on its own hardware, with its own npm ci, on a different Node version than my server happens to be running.

So the thing I verify on staging is not the thing that ships to production. It is a rebuild of the same source. Those are not the same claim. A committed lockfile makes them very likely to match, which is exactly why committing it matters, but "very likely" is doing real work in that sentence.

Proper pipelines build an artifact once and promote that artifact through environments. Test the bytes you are going to ship, not a fresh copy of them. I know what the fix looks like and I haven't done it yet, which is at least better than not knowing.

What I took from it

An environment is not a gate because of what you built. It is a gate because something can fail there before it reaches production. If both environments receive the same change at the same time, you own two production environments and one of them has fewer users.

Worth asking of any staging setup: what would have to go wrong for this to stop a release? If there isn't an answer, it isn't stopping anything.