My blog deploy broke, and the root cause was “I added a private git dependency.” That one change then failed in five completely different ways across two deploy targets, and it took a dozen commits to chase all of them down. The five failures were unrelated to each other, and each one looked like the bug had finally been found.
Here is the setup. I had pulled my system-map content out of my blog repo and into its own private repo, so that both my public site (dxdev.com) and an internal dashboard could render the same source. dxdev-landing now imports it as a git dependency. The repo is private. The moment a content package goes private, every consumer’s CI needs auth to fetch it. That is the whole story, and it detonated five times.
The first sign was not in my own logs. It was a failure email from Railway. Hold that thought, because it matters at the end.
Layer 1: the SSH key the runner doesn’t have
The first failure was npm trying to clone the dep over SSH:
npm error command git --no-replace-objects ls-remote ssh://[email protected]/your-org/your-private-repo.gitnpm error [email protected]: Permission denied (publickey).CI runners have no SSH key. Obvious in hindsight, invisible until you hit it. The dependency was declared in a way that resolved to an ssh:// URL, and there is no private key sitting on a fresh GitHub Actions or Railway runner to authenticate with.
The fix is to rewrite the URL to HTTPS-with-token at the git layer, before npm or pnpm ever shells out to git:
git config --global url."https://x-access-token:${TOKEN}@github.com/".insteadOf "ssh://[email protected]/"The token comes from a fine-grained PAT stored as a CI secret. One insteadOf rewrite and the SSH path is gone. Except it broke again, for a reason that has nothing to do with SSH keys.
Layer 2: git config overwrites, it does not append
A git dependency can resolve to more than one URL shape. There is the ssh://[email protected]/... form and the scp-style [email protected]:... form. So I added two insteadOf rewrites, one for each.
The second one silently overwrote the first.
A git-config key holds a single value unless you explicitly tell it to hold more. Setting url.<x>.insteadOf twice does not give you two rewrites. It gives you the last one. So only the scp-style rewrite was live, npm went back to resolving ssh://, and I was right back at Permission denied with config that looked correct.
The fix is --add:
git config --global --add url."https://x-access-token:${TOKEN}@github.com/".insteadOf "ssh://[email protected]/"git config --global --add url."https://x-access-token:${TOKEN}@github.com/".insteadOf "[email protected]:"Now both rewrites coexist. This is a lost hour all on its own, and it has zero overlap with layer 1. You can know everything about SSH keys and still get bitten by git-config’s overwrite semantics.
Layer 3: “Repository not found” is GitHub lying to you politely
Token threaded, both rewrites live, and npm got:
npm error remote: Repository not found.The repo is not missing. It exists. I was staring at it in the browser.
GitHub returns Not Found instead of Unauthorized when a token cannot see a private repo. This is deliberate. Returning 403 Forbidden would confirm the repo exists, which leaks information to anyone probing private namespaces. So a permission problem and a genuinely-deleted repo look identical from the outside.
The actual cause was the token itself. “Repository not found” with a rewrite that is clearly hitting HTTPS means the token cannot see that repo: no Contents permission, the repo not selected under resource access, or the secret value got mangled when it was pasted into Actions. GitHub’s polite lie meant the error message pointed at the wrong problem for a while, since the same Not-Found masks all of those. The fix was to regenerate the fine-grained PAT with Contents: Read on that one repo and rewrite it cleanly into the CI secret, after which the clone went through.
Three layers in, and I had only fixed one deploy target. The other one failed for a reason that had nothing to do with auth at all.
Layer 4: npm hoists peer deps, pnpm doesn’t
Two targets deploy on every push to main. One path is a GitHub Actions workflow that runs npm ci against package-lock.json and pushes the build to Cloudflare Pages with wrangler-action. The other is Railway, which runs pnpm against pnpm-lock.yaml. The npm path went green once the auth was sorted. Railway, once I had threaded the same token-rewrite through its build (more on that below), got past auth and then died on a different error entirely:
Rollup failed to resolve import "mermaid"The culprit was astro-mermaid, which declares mermaid as a peer dependency (^10.0.0 || ^11.0.0), not a direct one. npm auto-hoists peers into a flat node_modules, so the bundler could find mermaid and the npm build resolved it without anyone declaring it. pnpm’s strict, isolated node_modules does not hoist peers the same way, so on Railway mermaid was unresolvable at build time.
The fix is to stop relying on hoisting and declare the peer as a direct dependency, then refresh both lockfiles:
npm pkg set "dependencies.mermaid=^11.0.0"npm install --package-lock-onlypnpm install --lockfile-onlyBoth lockfiles have to agree or the next deploy fails on whichever one you forgot. This is the same lesson dxdev-landing has taught me before with its dual npm/pnpm setup: a dep change means refreshing both lockfiles, not one.
The Railway side had one more wrinkle: getting the token rewrite to actually run there was its own fight. Railway builds with Railpack, not Nixpacks, so the nixpacks.toml I reached for first was silently ignored. The install commands had to go in a railpack.json install step, writing the git config before pnpm ran:
{ "$schema": "https://schema.railpack.com", "steps": { "install": { "commands": [ "git config --global --add url.\"https://x-access-token:${TOKEN}@github.com/\".insteadOf \"ssh://[email protected]/\"", "git config --global --add url.\"https://x-access-token:${TOKEN}@github.com/\".insteadOf \"[email protected]:\"", "pnpm install --frozen-lockfile" ] } }}So even the “same fix as the other target” was not the same fix.
So a single private dependency produced an SSH-key failure, a git-config overwrite, a GitHub permission mask, and a package-manager peer-hoisting divergence. Four unrelated failures. The fifth was not a failure at all. It was the absence of one.
Layer 5: the target that doesn’t email is the one that stays broken
Both targets fire on every push to main, and nothing in my flow watched either one. The only reason I knew the deploy was broken at all was that Railway sent me a failure email. My exact reaction was “how come I didn’t know about this until I was emailed about it.” If that one target had also failed silently, the whole thing would have sat broken with no signal while I moved on.
A deploy target you do not actively watch is only as visible as whatever notification it happens to send. Lean on a failure email and you have outsourced your awareness to a channel you did not design. The site would just quietly serve the last good build and wait for someone to notice.
So the process fix is a rule, not a code change: every deploy target needs its own failure signal you actually own, not whatever email it happens to send. I documented both targets and the full PAT-plus-insteadOf-plus-token chain in the repo’s deployment notes so a future me does not relearn all five layers from scratch.
Related
- Our grandfathered $25 GitHub plan silently blocked every CI run, and the cheaper fix was a one-way door, another CI layer that blocks silently with no actionable error
- My red CI was a lie: a deleted workflow haunted every push while nothing real ran, false CI signal masking a deeper absence of coverage
- One monorepo, two build lanes: keeping classic-ASP pushes at zero CI minutes, managing multiple build targets with different auth and tooling requirements
- The Self-Updating Deploy Script That Has to Fail Once to Fix Itself, a deploy that must fail once before it can succeed, same unexpected-first-run pattern
- “Bamboo is broken” was wrong: a deploy that races the filesystem under CPU pressure, silent failure attributed to the wrong layer across a multi-step deploy