Retiring the staging environment
I was trying to answer a question that had been sitting in the backlog since the prod-data sync work raised it: does blog-manager actually need a staging deploy target? It’s a solo-dev app running on a homelab LXC, and every merge to main was auto-deploying to a second container via a Deploy staging GitHub Actions workflow that fired on every green CI run.
Before touching anything I went digging into what staging was actually buying. It turned out to be less than I expected. There’s no environments/staging.rb - it’s literally the same RAILS_ENV=production image, just pointed at a second Kamal destination. The only runtime differences between prod and staging are two ENV-var reads (the mailer host and the OIDC callback URL). Production deploys were already fully manual and independent of staging - the “confirm staging ran the same SHA” step was an unenforced documentation convention, not something the pipeline checked. And the Medium bridge accessory shares the same live Medium session as production, so staging couldn’t even safely exercise that integration without risking a duplicate publish on the real account.
On the cost side: a whole second LXC, its own DNS entry and Traefik route, a separate Uptime Kuma monitor, a separate secrets vault entry, entries in the homelab’s log-anomaly-detector pattern library, and a GitHub Actions workflow with an 11% failure rate where every failure I checked was a registry-pull race, not the app catching a real bug. I couldn’t find any git history evidence staging had ever caught something production deploys hadn’t.
I didn’t make the call in the middle of the audit, either. I wrote the tradeoff out both ways first - what staging costs, what it would take for it to start earning its keep - and only then decided: retire it now.
The actual change was mechanical once decided: delete config/deploy.staging.yml, .kamal/secrets.staging, and .github/workflows/deploy-staging.yml, then chase down every doc and comment that referenced staging (README, the agent instructions, two initializers, three docs files). I kept the CI runner’s staging-flavored label as-is rather than renaming it, since an earlier homelab change already relocated the runner off the staging host without renaming it (the workflows match on the label, not the hostname), and a rename here would be unrelated scope creep. I left a note explaining that in both places it comes up.
The homelab-side teardown (the LXC itself, DNS, Traefik, Uptime Kuma, backups) is a separate concern from this repo, and destructive infra work needs its own authorization at execution time, so it went into the homelab tracker as its own item instead of happening here.
What surprised me: this repo doesn’t use git worktrees, so it shares one working tree across concurrent agent sessions. Twice while this PR was open, a commit from what looked like a different session landed on my branch, both times a dependabot-workflow fix referencing an unrelated portfolio-wide issue. I caught both via routine git log/git status checks (the second one while the review pass was mid-flight), stripped them with git rebase --onto, and preserved the foreign work on its own pushed branch rather than discarding it. It didn’t block anything, but it’s the same lesson this portfolio keeps re-learning: check git state at every step instead of assuming the tree is exclusively yours between commands.
The code review caught something else worth naming: three em dashes in text I’d written for this PR (a couple of doc notes, one rewritten code comment), which is a hard style rule in this project that applies to everything produced, not just blog prose. Easy fix, but a good example of how a review pass catches process violations, not just logic bugs.
What’s next: the homelab teardown item is sitting in its backlog whenever I want to actually decommission the container.
Related reading
Pulling sanitized prod data into dev when rsync isn't there
A rake task that copies the production SQLite file down, nulls out every secret before it touches dev, and survives both schema drift and a host with no rsync installed.
Backfilling reality into a syndication tracker
Prod said zero Medium posts; the rake tasks fixed that in minutes. Then the reconciler crashed everywhere, a dashboard got scraped from the DOM, and CI failed three ways.
Moving CI to a self-hosted runner after GitHub broke our billing
Dead CI, live deploys: folding every job onto the homelab runner, deleting the hosted-runner compensation machinery, and the CVE backlog waiting behind the gate.