Four Cloudflare Workers that turn two separate Ghost instances into a seamless bilingual site — no theme modifications, no third-party service, no Ghost(Pro) required.
What you get:
- EN content served under your main domain (
your-blog.com/en/) instead of a subdomain — the URL structure Google recommends for multilingual sites <link rel="alternate" hreflang>tags injected on every translated article- A visible in-page "Read in English / Lire en français" notice under the article title
en.your-blog.com301-redirecting toyour-blog.com/enfor SEO consolidation- Sitemap, RSS, and canonical URLs all rewritten to the subdirectory form
Everything runs at the edge. No round-trips to an external translation service, no JavaScript payload, no theme changes.
Read this before starting.
-
Two Ghost instances. One per language, each with its own hosting, domain, and content. These Workers connect them — they do not create, sync, or translate content.
-
Cloudflare on both domains. Both
your-blog.comanden.your-blog.commust be on Cloudflare (the free plan is fine). -
Manual translation workflow. You write articles independently on each Ghost instance, then add a matching
#i18n-<id>private tag to both. The Workers detect the link — they don't create it. -
wranglerCLI required. Install withnpm i -g wrangleror usenpx wrangler. -
Cloudflare Workers free tier. 100,000 requests/day. Sufficient for most blogs; check your traffic before deploying.
Add the same internal tag #i18n-<id> to the FR article and the EN
article (use any short unique id, e.g. #i18n-climate-report). Ghost
slugifies internal tags as hash-*, so the rendered <body> class will
contain tag-hash-i18n-climate-report. The i18n Worker detects this and
looks up the matching article via the Ghost Content API.
Article slugs stay independent — /rapport-climatique/ on FR,
/climate-report/ on EN. The tag does the matching.
visitor
│
▼
┌──────────────────────────────────────────────────────────┐
│ your-blog.com/* your-blog.com/en/* │
│ │
│ ┌─────────────────┐ ┌──────────────────────────────┐ │
│ │ i18n-worker │ │ subdir-proxy-worker │ │
│ │ (hreflang + │ │ (strips /en, forwards to │ │
│ │ 301 redirect) │ │ fetcher over HTTPS) │ │
│ └────────┬────────┘ └──────────────┬───────────────┘ │
│ │ HTTPS/workers.dev │ HTTPS/workers.dev│
│ │ │ │
└───────────┼────────────────────────────┼──────────────────┘
│ │
▼ ▼
┌──────────────────┐ ┌────────────────────────────┐
│ api-proxy- │ │ subdir-fetcher-worker │
│ worker │ │ (fetches en.your-blog.com,│
│ (Content API │ │ rewrites all URLs to │
│ relay) │ │ your-blog.com/en) │
└──────────────────┘ └────────────────────────────┘
│ │
▼ ▼
Ghost Content API Ghost EN origin
(FR or EN blog) (en.your-blog.com)
If both Ghost blogs share the same Cloudflare zone (e.g. your-blog.com
and en.your-blog.com both on the your-blog.com zone), Cloudflare
blocks any Worker-to-Worker fetch on that zone with an opaque HTTP 500
(error 1042).
This affects both the Content API lookup and the subdirectory proxying.
The workaround is to put the "outbound" workers (api-proxy-worker and
subdir-fetcher-worker) on workers.dev — outside your zone — so their
outbound fetches are cross-zone and succeed. The zone-bound workers
(i18n-worker and subdir-proxy-worker) reach them over HTTPS, auth-gated
by a shared secret.
Things that look like they'd fix the same-zone problem but don't:
| Attempt | Why it fails |
|---|---|
global_fetch_strictly_public flag |
Causes the main pass-through fetch(request) to also route through the Worker → infinite loop → 503 on every request |
| Service Binding to a proxy Worker | Service Bindings propagate the calling Worker's zone context, so the proxy's outbound fetch is still same-zone — same 500 |
| Custom Domain instead of Route | Requires deleting the DNS record that points to Ghost origin — breaks Ghost |
ghost-bilingual-workers/
├── i18n-worker/ # main Worker — on your-blog.com/* and en.your-blog.com/*
│ ├── src/index.ts # hreflang injection + 301 redirect en.X.com → X.com/en
│ ├── wrangler.jsonc
│ ├── package.json
│ └── tsconfig.json
├── api-proxy-worker/ # workers.dev only — relays Ghost Content API calls
│ ├── src/index.ts
│ ├── wrangler.jsonc
│ ├── package.json
│ └── tsconfig.json
├── subdir-proxy-worker/ # on your-blog.com/en/* — strips /en, forwards to fetcher
│ ├── src/index.ts
│ ├── wrangler.jsonc
│ ├── package.json
│ └── tsconfig.json
└── subdir-fetcher-worker/ # workers.dev only — fetches Ghost EN origin, rewrites URLs
├── src/index.ts
├── wrangler.jsonc
├── package.json
└── tsconfig.json
git clone https://github.com/bst1n/ghost-bilingual-workers.git my-blog-i18n
cd my-blog-i18nInstall dependencies in each worker directory:
for d in i18n-worker api-proxy-worker subdir-proxy-worker subdir-fetcher-worker; do
(cd $d && npm install)
donecd i18n-worker && npx wrangler login && cd ..Replace the placeholders in each wrangler.jsonc:
| Placeholder | Replace with |
|---|---|
your-blog.com |
your FR Ghost domain |
en.your-blog.com |
your EN Ghost domain |
your-account |
your Cloudflare account subdomain (visible after first wrangler deploy) |
Worker names (ghost-subdir-i18n, ghost-subdir-proxy, etc.) can also be
renamed to match your project.
Generate shared secrets:
PROXY_SECRET=$(openssl rand -base64 32)
BYPASS_SECRET=$(openssl rand -base64 32)
FETCHER_SECRET=$(openssl rand -base64 32)Deploy and set secrets:
cd api-proxy-worker
npx wrangler deploy
echo "<your-FR-content-api-key>" | npx wrangler secret put FR_CONTENT_API_KEY
echo "<your-EN-content-api-key>" | npx wrangler secret put EN_CONTENT_API_KEY
echo "$PROXY_SECRET" | npx wrangler secret put PROXY_SHARED_SECRET
cd ..Ghost Content API keys: Ghost admin → Settings → Integrations → Add custom integration → copy the Content API Key. Create one per blog.
Note the workers.dev URL printed after deploy. Make sure it matches
API_PROXY_URL in i18n-worker/wrangler.jsonc.
cd subdir-fetcher-worker
npx wrangler deploy
echo "$FETCHER_SECRET" | npx wrangler secret put FETCHER_SHARED_SECRET
echo "$BYPASS_SECRET" | npx wrangler secret put INTERNAL_BYPASS_SECRET
cd ..Note the workers.dev URL and make sure it matches FETCHER_URL in
subdir-proxy-worker/wrangler.jsonc.
cd subdir-proxy-worker
npx wrangler deploy
echo "$FETCHER_SECRET" | npx wrangler secret put FETCHER_SHARED_SECRET
cd ..cd i18n-worker
npx wrangler deploy
echo "$PROXY_SECRET" | npx wrangler secret put PROXY_SHARED_SECRET
echo "$BYPASS_SECRET" | npx wrangler secret put INTERNAL_BYPASS_SECRET
cd ..The Workers inject a <p class="gh-translation-notice"> after the article
<h1>. Add this to Ghost admin → Settings → Code Injection → Site Header
on both blogs:
<style>
.gh-translation-notice {
margin: 1rem 0 2rem;
padding: 0.8rem 1rem;
background: #f5f5f5;
border-left: 3px solid #888;
font-style: italic;
font-size: 0.9em;
}
.gh-translation-notice a {
color: inherit;
text-decoration: underline;
}
</style>Pick an article tagged #i18n-<id> with its translation published on the
other blog:
# hreflang tags (expect 3 lines: fr, en, x-default)
curl -s https://your-blog.com/<slug>/ | grep 'hreflang'
# translation notice (expect 1 line)
curl -s https://your-blog.com/<slug>/ | grep 'gh-translation-notice'
# EN content served under subdirectory (expect Ghost HTML)
curl -s https://your-blog.com/en/<slug>/ | grep '<title>'
# sitemap URLs (expect your-blog.com/en/...)
curl -s https://your-blog.com/en/sitemap-posts.xml | grep '<loc>' | head -5Then in Google Search Console → URL Inspection → "View tested page" → HTML: the three hreflang lines should appear in the rendered source.
The i18n Worker uses one theme-specific selector:
.on("h1.gh-article-title", ...)gh-article-title is the class on the article <h1> in the official
Ghost themes Source and Casper. If your theme uses a different
class, edit this selector in i18n-worker/src/index.ts.
| Situation | Worker behaviour |
|---|---|
| Non-HTML response (assets, RSS, sitemap, JSON) | Pass through |
Path is /, /tag/..., /author/..., /ghost/... |
Pass through |
HTML article without #i18n-* tag |
Pass through |
HTML article with #i18n-* but no translation published |
Pass through (miss cached 5 min) |
| Proxy or fetcher down / API error | Pass through, error logged |
Request to en.your-blog.com (admin, assets, preview) |
Served directly, not redirected |
Request to en.your-blog.com (any article) |
301 → your-blog.com/en/... |
MIT. Use freely.