Bilingual Ghost with Cloudflare Workers

If you’ve ever tried to run a bilingual Ghost blog, you know the options are limited: Ghost has no native i18n, and most workarounds are either expensive, fragile, or both.

I’ve been running two paired Ghost blogs (one French, one English) and wanted a clean setup: EN content served under the main domain (your-blog.com/en/) instead of a subdomain, proper hreflang tags for SEO, and a visible in-page link between translated articles. No plugin, no managed translation service, no recurring subscription — just self-hosted Ghost and Cloudflare Workers running code I control.

The result is a set of four Cloudflare Workers that do all of this at the edge.


How it works

The system relies on a Ghost tagging convention: any article that has a translation gets a private tag #i18n-<id> (same id on both blogs). The Workers handle the rest:

  • i18n-worker — sits on both your-blog.com and en.your-blog.com, detects the pivot tag, fetches the matching translation via the Content API, and injects <link rel="alternate" hreflang> tags + an in-page “Read in English / Lire en français” notice
  • api-proxy-worker — relays Content API calls to escape Cloudflare’s same-zone Worker->Worker restriction
  • subdir-proxy-worker — serves your-blog.com/en/*, strips the /en prefix and forwards to the fetcher
  • subdir-fetcher-worker — fetches the Ghost EN origin (en.your-blog.com) and rewrites all URLs in the body, Location headers, and cookies so they point to your-blog.com/en

Sitemap, RSS, and canonical URLs all come out with the correct subdirectory paths. The en.your-blog.com subdomain 301-redirects to your-blog.com/en for SEO consolidation.


:warning: Constraints — read before using

This is a working production setup, but it comes with real requirements:

  1. Two separate Ghost instances. One for each language. This means two hosting plans, two Ghost configs, two sets of content. The Workers only connect them — they don’t merge or sync anything.

  2. Cloudflare is required. Both domains need to be on Cloudflare (the free plan works). The subdirectory mount trick relies on Workers running at the edge — there’s no equivalent for other DNS/CDN setups.

  3. 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.

  4. Technical setup. You’ll need wrangler (Cloudflare’s CLI), basic familiarity with deploying Workers, and a few secrets to wire between workers. Not a one-click install.

  5. Cloudflare Workers free tier limits. 100,000 requests/day on the free plan. Fine for most blogs, worth knowing.


Repo

-> GitHub - bst1n/ghost-bilingual-workers: Cloudflare Workers: serve a Ghost blog under a subdirectory (X.xyz/en/*) with FR↔EN hreflang cross-linking · GitHub

Each worker has a commented wrangler.jsonc listing all variables and secrets to set. The architecture is documented in the source files.

Happy to answer questions about the setup.

1 Like

After researching every approach available to run a multilingual Ghost site, I’m grouping them into DIY solutions (you build and host it yourself, using whichever infrastructure you choose) and paid / managed solutions (you depend on a third-party service or commercial product). Each row reflects the real trade-offs.

Two columns drive everything:

  • Mount / link-rewrite layer — When the EN content lives on a second instance served under /en/, something has to serve it there and rewrite Ghost’s internal links (href="/about/"/en/about/), sitemap, RSS, cookies. This column says what does that job. See the terminology note below.
  • Translation pairing — How a FR article is linked to its EN translation (which is also what makes automatic hreflang possible).

And two columns matter most for SEO:

  • Translated slugs possible — Google’s best practice is language-specific slugs (/our-mission/ in EN, /notre-mission/ in FR). Some solutions force identical slugs across languages, a real SEO compromise.
  • Automatic hreflang — Server-side injection is what Google recommends; client-side JS injection is allowed but discouraged for critical SEO signals.

DIY solutions

Approach Instances URL Mount / link-rewrite layer Translation pairing Translated slugs Automatic hreflang Localized frontend UI (Portal, Search, Comments, theme strings) Localized Ghost emails (transactional + boilerplate) Theme to maintain Setup
Single instance, tag routing 1 subdir Ghost-native (routes.yaml) none (tags only route) :white_check_mark: :cross_mark: :cross_mark: :cross_mark: :white_check_mark: stock theme medium
Single instance + custom theme 1 subdir Ghost-native (routes.yaml) internal tag + {{#get}}, or per-post Code Injection :white_check_mark: :white_check_mark: server-side :warning: achievable via theme work (custom translation helper + dynamic data-locale for widgets) :cross_mark: (single publication_language) :warning: custom theme (re-merge on Ghost / base-theme updates) high
Headless + SSG (Next.js / Astro / Gatsby / Hugo) 1 (headless) subdir SSG build SSG i18n routing (build-time) :white_check_mark: :white_check_mark: server-side :white_check_mark: (rebuilt in SSG) :cross_mark: if Ghost still sends emails; :white_check_mark: only if email system is also replaced :counterclockwise_arrows_button: entire frontend replaced very high
Two instances, no extra layer 2 subdomain n/a (subdomain) none :white_check_mark: :cross_mark: :white_check_mark: native :white_check_mark: native :white_check_mark: stock themes low
Two instances + hreflang layer 2 subdomain n/a (subdomain) internal tag, via edge function / programmable proxy :white_check_mark: :white_check_mark: server-side :white_check_mark: native :white_check_mark: native :white_check_mark: stock themes medium
Two instances + reverse proxy (no rewriting) 2 subdir nginx (routing only — internal links break) none :white_check_mark: :cross_mark: :white_check_mark: native :white_check_mark: native :white_check_mark: stock themes medium
Two instances + sub_filter 2 subdir nginx sub_filter none :white_check_mark: :cross_mark: :white_check_mark: native :white_check_mark: native :white_check_mark: stock themes high
Two instances + custom theme (theme-side pairing) 2 subdir nginx sub_filter per-post Code Injection, or identical-slug convention :white_check_mark: with Code Injection / :cross_mark: with identical-slug :white_check_mark: server-side :white_check_mark: native :white_check_mark: native :warning: custom theme high
Two instances + custom theme + client-side JS 2 subdir nginx sub_filter internal tag, via client-side JS :white_check_mark: :warning: client-side (SEO penalty) :white_check_mark: native :white_check_mark: native :warning: custom theme high
Two instances + programmable proxy 2 subdir programmable proxy (Varnish, OpenResty, Caddy plugin, Node middleware…) internal tag, via proxy :white_check_mark: :white_check_mark: server-side :white_check_mark: native :white_check_mark: native :white_check_mark: stock themes very high (self-hosted layer to write & maintain)
Two instances + edge function (this project) 2 subdir Cloudflare Worker internal tag, via Worker :white_check_mark: :white_check_mark: server-side :white_check_mark: native :white_check_mark: native :white_check_mark: stock themes high

Paid / managed solutions

Approach Instances URL Mount / link-rewrite layer Translation pairing Translated slugs Automatic hreflang Localized frontend UI (Portal, Search, Comments, theme strings) Localized Ghost emails (transactional + boilerplate) Theme to maintain Cost
JS translation widget (Weglot, Bablic, ConveyThis, Linguise, Localize, Lokalise) 1 subdir or subdomain vendor proxy vendor-managed (URL-based) :white_check_mark: (managed) :white_check_mark: server-side :warning: translates page content + nav, but Ghost-issued emails stay monolingual :cross_mark: :white_check_mark: stock theme subscription (€79/mo+ for Weglot)
Commercial multilingual theme (Crimson, TanaFlows) 2 subdir nginx (required, per theme docs) identical-slug convention :cross_mark: (identical slugs required) :white_check_mark: server-side :white_check_mark: native :white_check_mark: native :money_bag: vendor-maintained paid theme
Managed Ghost host w/ subdirectory (Ghost Pro, Synaps Media, Magic Pages, DigitalPress, Cloudways) 2 subdir managed host up to you (theme or extra layer) :white_check_mark: :cross_mark: (add via theme) :white_check_mark: native :white_check_mark: native :white_check_mark: stock theme (:warning: custom if you want hreflang) subscription / +$50/mo for Ghost Pro

Terminology: the mount / link-rewrite layer

When you serve a second Ghost under blog.com/en/, Ghost still emits links like href="/some-post-slug/" that break out of the subdirectory and resolve to the FR site. Something has to rewrite them (plus sitemap, RSS, Location headers, cookies). The options form a single axis, from dumbest to smartest:

  • nginx (vanilla) — a reverse proxy. Routes requests, but doesn’t touch the response → internal links break.
  • nginx + sub_filter — adds find-and-replace on the HTML body → internal links work. But it’s text-only: no API calls, so it can’t discover the other language’s URL for hreflang.
  • Programmable proxy — a reverse proxy that runs your code (OpenResty/Lua, Caddy plugin, Node + http-proxy-middleware, Varnish/VCL). Can rewrite the response and call the other instance’s API → hreflang too.
  • Cloudflare Worker — a programmable proxy that runs at the edge instead of on your server.
  • Managed host — Ghost Pro, Synaps, Magic Pages, etc. handle all of this in their infrastructure.

So a Cloudflare Worker and a self-hosted programmable proxy are the same thing architecturally — the difference is just where it runs (next section).

Subdomain vs subdirectory: an SEO trade-off

If you keep your EN content on a subdomain (en.blog.com), you avoid the entire mount/rewrite problem — no internal link rewriting, no sitemap/RSS gymnastics, no cookie domain juggling. You still need a thin layer to inject hreflang between the two instances (an edge function or programmable proxy; nginx alone can’t), but it’s drastically simpler than the full subdir setup.

The trade-off is SEO: Google treats en.blog.com as a separate site from blog.com, so you don’t consolidate domain authority across languages. Google’s official recommendation is the subdirectory structure, which is why most of the rows above go through the trouble. Subdomain + hreflang-only makes sense when you’re starting both languages from scratch, when you have many languages, or when you accept the SEO cost for simplicity.

Why theme-based pairing on two instances is constrained

A theme running on the FR Ghost can only query the FR Ghost’s own Content API (via the {{#get}} helper). It has no way to discover, at render time, the slug of the EN translation on the other instance. Two theme-only workarounds exist:

  • Identical-slug convention — hardcode href="/en/{{slug}}/". Simple, but forces both languages to share the same slug (used by Crimson, TanaFlows).
  • Per-post Code Injection (credit to Cathy Sarisky for raising this) — store the translated slug as data in each post’s Code Injection field; the theme reads it via {{post.codeinjection_head}} to build the hreflang. Keeps translated slugs, at the cost of editing that field manually on every post pair (and re-editing it whenever a slug changes).

To get translated slugs and zero manual per-post work, you need a layer that calls the other instance’s API at request time: a programmable proxy or an edge function — the approach this project takes.

Programmable proxy vs Cloudflare Worker

Architecturally identical — both intercept the response, query the other Ghost instance, inject hreflang, and rewrite internal links server-side. The difference is where they run and what you maintain:

  • A programmable proxy runs on your server. No platform dependency, full control — but you add a component to your stack, and on a managed PaaS like Coolify (which routes via Traefik), inserting a custom proxy in front of Ghost is non-trivial.
  • A Cloudflare Worker runs at the edge. Free tier covers most blogs, nothing to insert into your existing routing, works transparently on top of any host (Coolify, Docker, VPS, managed). The trade-off is a dependency on the Cloudflare platform.

The same edge-function approach works on Vercel Edge Functions, Netlify Edge Functions, etc. — equivalent trade-offs.

Reading the tables

  • Separate members and newsletters per language requires two instances. No single-instance approach solves this — it’s a fundamental Ghost limitation.
  • Translated slugs + automatic server-side hreflang together is rare. Commercial themes (Crimson, TanaFlows) give you automatic hreflang but force identical slugs. Managed subdirectory hosts give you translated slugs but no automatic hreflang. Only the programmable proxy, edge function, theme + Code Injection, and headless-SSG approaches give you both — and only the first two stay both stock-theme and zero-touch.
  • Separate comments per language are a trade-off, not a one-sided cost. For cross-language audiences (Cathy Sarisky’s Initium is the canonical example), shared threads build community. For distinct audiences per language, keeping each thread linguistically homogeneous is a feature: monolingual readers aren’t confronted with comments they can’t read, and the author can respond in the thread’s language.
  • Tools like Activepieces + DeepL are content production strategies (how to generate translations), not architectures — they can be layered on top of any row above.

For a broader overview of where Ghost stands on multilingual support overall (including what’s structurally missing from Ghost core), Cathy Sarisky’s article What would it take to make Ghost multilingual? is the best single-source read.

3 Likes

Are you talking about the placeholder page ghost created when it starts? Not a big deal, just revise the link in navigation.

Hi @Cathy_Sarisky. Sorry — the /about/ example was a bad choice on my part since it literally reads as the Ghost placeholder page. I meant it as a generic placeholder for any internal link. In a two-instance subdir setup, Ghost EN emits hundreds of relative URLs at runtime (pagination, related posts, tag/author archives, sitemap URLs, RSS items, image src, in-editor embedded links, OG/canonical tags, etc.) that all assume it’s being served at its root. They can’t be edited one by one — they need systematic response rewriting at the proxy / Worker / managed host layer. That’s what the Workers in this project handle.

OK, I think I’ve mapped this out as thoroughly as I can — but I’m not deeply technical and there’s a real chance I’ve gotten some details wrong or oversimplified. I’d love to hear from anyone who’s actually built one of these setups in production.

Tagging a few people whose work shows up in this comparison and who might have useful corrections or additions: @Cathy_Sarisky, @muratcorlu, @mheland.

If I got something wrong I’ll edit the tables and credit the correction.

1 Like

I don’t have time today to review this whole document for accuracy.

There’s perhaps another option for linking two (or more) languages together - take advantage of per-post code injection as another place to store data. I don’t love the idea of using a separate tag to connect each pair of posts. It’ll make tags pretty unmanageable in the editor, if there are a ton of these pairing tags. It’ll be hard to apply tags to posts, and hard to view the list of tags in the dashboard. I’d instead consider putting the slugs of the paired posts into the post code injection (in some consistent and easily parsed format) and then use that data to construct the hreflang and your cross-language links. I think if I were setting up a busy publisher for this, I’d stand up a little tool somewhere (not within Ghost) that would take the names of the new posts and then create drafts with the linkage text already added, and provide links to open each draft in the browser. That might make that idea feasible, along with some high level abuse of the #split helper.

I do think that a downside of running two separate installs is that your members need separate accounts (or you need to solve that problem additionally). And your staff need separate accounts. Everything is per-site, including comments and social web follows. When I worked on the Initium (https://theinitium.com/), having users able to interact with each other and both languages was a high priority, and so we went with a single install. But oh boy, was it complicated, and you’ll notice if you click the language switcher that we opted for related slugs to keep things paired. (Given how badly Chinese language slugs look in, it’s perhaps not as great of a loss as it might feel for French/English.)

Aside: you might find some useful info here, as well: What would it take to make Ghost multilingual?

1 Like

Thanks @Cathy_Sarisky , this is really helpful.

You’re right that my “identical slugs required” section overstates it — per-post code injection as a local pairing store does let theme-based two-instance setups keep translated slugs without cross-instance API calls. I’ll add a row for that and rewrite the section to credit it.

For my own use case (small bilingual blog, two instances + Workers), I think I’ll stick with internal tags — mainly because the Worker re-fetches the URL from the Content API each time, so a slug rename just works. With code injection storing the slug, both posts have to be manually updated. But at Initium scale the tag picker pollution is real, and your approach is clearly better there. The auxiliary tool you described is worth stealing either way.

On separate members/staff/comments: agreed those are real costs of two installs. I’d just add that when the two language audiences are largely distinct (which is common for FR<->EN blogs), separate member pools map naturally to the editorial structure rather than feeling like overhead. And linguistically homogeneous comment threads can actually be a feature for monolingual readers — different audience model from Initium, where cross-language interaction is the whole point.

Going to update the post with the code injection row and a link to your “What would it take to make Ghost multilingual?” article — it’s a much better overview than anything else out there.

Thanks again.

1 Like

My approach is somewhat detailed here: