What It Took to Take a Private Repo Public — Part 3 of 3
OPEN-SOURCE · SECURITY

A Public Repo That Still Deploys Real Infrastructure

Part 2 ended with a history finally scrubbed clean of every hostname, mount path and secret name it used to carry. This piece is about what that scrub cost somewhere else.

Nowhere on the original checklist was “design a private deploy pipeline.” Rotate the leaked credential, scrub the history, license it properly, review the design: those were all things I knew, going in, that open-sourcing a repo would require. What I hadn’t accounted for was the question that only shows up once you start actually doing the scrubbing: if the public repo can no longer know the hostname, the mount path, or the deploy secrets, something still has to know them. Where does that live?

The pipeline needed a home before it needed a design

The same day the secret scrub started, before anyone had thought through what a “proper” deploy architecture should look like, a new private repo existed. Its first commit was docker-compose.yml, moved over unchanged: real Traefik hostnames, the real NAS mount path, untouched. Not a redesign. A paste.

That ordering matters more than it looks like it should. The “somewhere” a public repo’s real infrastructure knowledge goes has to exist before the public repo is forced to lose it, or the scrub stalls waiting for an architecture decision that doesn’t need to be made yet. Design the pattern later. First, just make sure nothing falls on the floor.

Defaults that were quietly doing real work

Two settings had never been written by any deploy pipeline, old or new: a Prefect job-routing map, and an internal repo URL feeding a logging integration’s code-source link. Nobody had needed to write them, because the application’s own code shipped working defaults, and production had been silently living off those defaults since the day it launched.

The public-repo scrub zeroed those defaults out, correctly. Shipping a public repo with a real internal routing map and a real internal URL baked in as the fallback isn’t a detail you can leave in. But zeroing a default that’s secretly load-bearing doesn’t remove the need for the real value; it just relocates the question of where the real value lives. The new private repo picked it up, writing the actual routing map and the actual URL into production’s environment for the first time as explicit configuration, rather than an implicit default nobody had ever looked at directly.

The rename tax

A few hours later, the same story played out again, smaller. The public repo renamed its Keycloak-specific config fields to generic OIDC names as part of de-branding. Nothing about the deploy pipeline’s own logic needed to change for that, except that it was still writing environment variables under the old names. Left alone, the next deploy would have silently fallen back to placeholder issuer and audience values and broken production auth, the exact same failure shape as the routing-map gap, at a smaller scale.

Once is a one-off. Twice starts to look like a category: every rename or de-branding change made for public-repo reasons has a shadow cost somewhere else, a place that has to be told about the rename or it quietly starts serving a placeholder instead of the truth.

The problem actually designed for

Everything above was reactive, patch the thing that broke once you noticed it was broken. This next part was the first piece of actual design work, and it came from a real shift in what a “release” meant. Before any of this, a new version reaching production was something done by hand, by someone who knew exactly what had changed. Once the public repo builds and publishes an image on every version tag, a release becomes something that can happen without anyone reviewing the diff first.

The fix was a staging gate: every release auto-deploys to a staging environment immediately; production only moves on an explicit, manual trigger naming a specific, already-verified tag. Nothing reaches production unattended, no matter how the release got triggered. That shape generalised well enough that it, the migration check below, and the backstop at the end of this piece all ended up written down as a recipe I now apply to every repo — the mechanics live there.

What’s worth recording here is the part a recipe can’t tell you: why this one isn’t the platform-native answer. GitHub does offer a built-in version of the gate, an environment protection rule requiring a human reviewer’s approval before a deploy can proceed. For private repos it needs a paid Team or Enterprise plan, which the API confirms by rejecting the request outright rather than degrading into something weaker. Not worth paying for on the strength of one gate. So manual-dispatch-only is what’s actually in place, documented as a stand-in in case that ever changes.

A credential that didn’t need to be there

Small one, but a good example of catching a mistake fast rather than not making it at all. A registry access token got added to the deploy workflow so it could pull the newly published image. Within the same day, it was removed again, specifically from the container’s own runtime environment, once it was clear the login step that actually needed the token already consumed it directly and the application itself had no reason to ever see it.

Migrations get a check, not blind faith

Every deploy checks alembic current against alembic heads in a disposable container, after pulling the new image and before restarting the live service, and only runs the upgrade if the two disagree. It’s a genuinely optional step: an unconditional alembic upgrade head is idempotent either way, so running it blind every time would have worked fine. What the check buys is that the common case logs a fast, boring “already up to date, nothing to do” instead of a migration command running against production on a release that never needed one. A taste call, not a correctness fix, kept because a clean log line has real value on the day something does go wrong and you need to trust what the log is telling you.

One mount, two environments

Staging and production don’t get separate storage. They share one physical NAS mount, split logically into distinct subpaths per environment through three path variables read from configuration rather than hardcoded. This is the detail a generic “just fork the compose file for staging” instinct would walk straight past: fork the file, forget the storage is physically shared, and end up with two environments quietly overwriting each other’s media files the first time both happen to run at once. The split has to be explicit and deliberate, because the underlying disk genuinely isn’t split at all.

Removing the safety net once you trust the real one

The very first version of this pipeline, before any of the above existed, had an hourly cron job as a dead-man’s-switch backstop: if the real trigger ever failed silently, the cron would eventually catch up and redeploy anyway. Once the actual trigger (a dispatch fired directly by the public repo’s publish step) and the manual-dispatch path had both been exercised enough times to trust, the backstop came out. Not because it was doing harm, but because it was scaffolding for a level of uncertainty that no longer existed. Removing it was itself a small piece of evidence that the pattern had landed, not just shipped.


Nothing about this was designed end to end from a whiteboard. Every piece of it was written in response to something breaking, or something about to break, in the order those things happened to surface. A CORS_ORIGINS variable landed days after everything above looked finished, because staging and production had quietly become two separate public-facing domains rather than one hardcoded internal host, and that finally mattered somewhere.

The actual pattern, the one worth taking away rather than the specific variable names: going public doesn’t just mean scrubbing what’s already there. It splits what used to be one repo’s problem into two repos’ problem, and that split has to be designed on purpose, once the first thing breaks, not guessed at in advance before anything has told you what actually needs splitting.

That’s the whole series, really. Three pieces, three different shapes of the same discovery: a checklist that turned up two real bugs and a deferred trade-off worth finally paying off; a scrub that kept finding one more place the same leak was still sitting; a deploy pipeline that had to exist before anyone had time to design it properly. None of it was hard on its own. All of it took longer, and turned up more, than “just make it public” sounded like it should.

The repo: github.com/Glitchedpixel-io/media-api