The Symptom
A Telegram bot that mirrors my Ghost admin panel suddenly started handing me a dead link. I clicked the URL it spooled out—https://www.emrys.name.ng/blog/<slug>—and Chrome promptly displayed the generic “This site can’t be reached” screen. No 404, no Ghost‑styled “article not found”, just a network‑level timeout. Oddly, the same device could load the home page in a flash, using the exact same Wi‑Fi network.
First Theory: Bad SITE_URL
The bot builds its live URLs from an environment variable called SITE_URL. The snippet looks like this:
const liveUrl = SITE_URL
? `${SITE_URL.replace(/\/$/, "")}/blog/${slug}`
: `/blog/${slug}`;
In Vercel I had entered www.emrys.name.ng without a scheme. The string therefore resolved to a relative path, which browsers interpret as something like https://myapp.vercel.app/www.emrys.name.ng/blog/slug. That would certainly break the link. I patched the variable to https://www.emrys.name.ng and redeployed. The link format looked correct, yet the same “can’t be reached” error persisted. The problem was deeper than a missing https://.
Second Theory: Missing Slug Data
Ghost renders a post via an edge function (api/post.mjs) that queries Firestore for a document whose ID matches the slug. If the query fails, the function returns a rendered “article could not be found” page – a proper HTTP response, not a network timeout. To rule this out I opened the Firebase console and inspected the document directly:
- slug – present and matches the URL
- title – correct
- tags – populated
- excerpt – non‑empty
- body – full HTML content
All fields were intact. The function’s notFound branch was never hit, so the slug itself was not the culprit.
Third Theory: Firestore Security Rules
Could a ruleset be silently rejecting the read? My firestore.rules file looked like this:
match /posts/{postId} {
allow read: if true;
allow write: if isAdmin();
}
Public reads were explicitly allowed, and any denial would surface as a 403 error that the edge function would capture and turn into a rendered page. Since I was seeing a raw connectivity failure, the rules were not the cause.
Fourth Theory: Deployment Mix‑up
I turned to Vercel’s request logs, filtering for /blog/<slug>. The log returned zero hits. The request never even reached the edge function. I verified that the rewrite rule in vercel.json was still present:
{
"rewrites": [{"source": "/blog/:slug", "destination": "/api/post.mjs"}]
}
The rewrite was correct, so I dug into the live package.json. To my surprise it contained the configuration of an entirely different Telegram‑bot project:
- Wrong
namefield (bot‑name instead of blog‑name) - Extraneous dependencies (
adm-zip,firebase-admin) - Missing dependencies required by the blog’s edge function
The blog’s edge function actually pulls Firebase from a CDN and only uses the native fetch API, so those extra packages were irrelevant—but they also hinted that the build might be pulling the wrong entry point. I restored a clean package.json that matched the blog’s minimal needs, committed, and triggered a fresh deployment. The rewrite still fired, the logs now showed hits, yet the URL still refused to load.
Fifth Theory: Deployment Protection Settings
Vercel’s Deployment Protection can hide production URLs behind a login wall when misconfigured. I opened a preview URL (something.vercel.app) and was greeted by a sign‑in prompt. I wondered if the custom domain was being shielded as well. In the project settings I confirmed the protection mode was set to Standard Protection, which only guards preview builds. Production on www.emrys.name.ng remained public, so this wasn’t the blocker.
The Real Fix: A Simple Network Switch
Having exhausted every code‑related avenue, I tried something I hadn’t thought of: changing the phone’s network. I toggled off mobile data, connected to my home Wi‑Fi, and refreshed the link. Instantly, the Ghost post rendered perfectly.
The root cause turned out to be a flaky Wi‑Fi router that intermittently dropped outbound HTTPS connections to Vercel’s edge network. The home page, being cached on Cloudflare, survived the hiccup; the freshly‑generated post, however, required a live fetch to the edge function and hit the dead socket.
What I Learned
- Environment variables matter, but they’re not always the villain. A missing scheme can break a link, yet the same symptom can persist after the fix.
- Firestore data integrity is easy to verify. A quick console check can rule out missing fields in seconds.
- Security rules surface as HTTP errors, not network failures. If you see a “can’t be reached” page, the request never hit your code.
- Deployment artifacts can get cross‑contaminated. Accidentally committing another project’s
package.jsonis a subtle way to break builds. - Vercel logs are your friend. Zero hits mean the request never left the CDN, pointing you toward routing or network problems.
- Never underestimate the network layer. A misbehaving router can masquerade as a code bug, especially when only a subset of routes fail.
Closing Thoughts
The investigation turned into a mini‑audit of the entire stack: environment variables, Firestore documents, security rules, Vercel rewrites, package configuration, and finally the underlying network. Two unrelated bugs surfaced—a malformed SITE_URL and a stray package.json—both of which I cleaned up. The real blocker, however, was entirely outside the codebase.
If you ever find yourself chasing a “ghost” post that refuses to load, remember to look beyond the application layer. A quick Wi‑Fi toggle can save you hours of debugging time, and it’s a reminder that sometimes the wild goose chase ends with a simple switch of network.