ghoststandard #
A self-hosted bridge that mirrors one or many Ghost blogs into Standard.site records on AT Protocol PDSes. Each blog becomes its own site.standard.publication record (with its own handle, app password, theme, icon) and every published post becomes a site.standard.document. New posts sync automatically via Ghost webhooks; the <link rel="site.standard.document"> tag gets dropped into each post's <head> without you touching the Ghost editor.
Self-contained: one Docker container, one named volume, Caddy (bundled or pre-existing) in front for TLS. Configuration is a CLI you run inside the container — no admin web UI to log into.
Status: v0.1, end-to-end validated. Production-tested against one Ghost on 2026-05-29. Not yet released or version-tagged.
What ghoststandard does #
For each Ghost blog you configure:
- Creates a
site.standard.publicationrecord on your PDS (you pick the handle / app password / publication metadata) - Listens for Ghost webhooks (auto-installed during setup)
- On
post.published/post.published.edited: fetches the post, uploads its cover image as a blob, writes asite.standard.documentrecord (the post body travels asat.markpub.markdowncontent, converted from Ghost's HTML), and updates the post's per-postcodeinjection_headso its<head>carries the link tag — all without touching Ghost's editor UI - On
post.unpublished/post.deleted: deletes the document record from the PDS - Tracks every post's
ghost_post_id↔ AT-URI mapping in a local SQLite (so deletes always know which record to remove)
What it doesn't do automatically (you wire these up once per Ghost — see Ghost-side configuration):
- The
/.well-known/site.standard.publicationfile on the blog's domain (reverse-proxy config, not Ghost config) - The blog-homepage
<link rel="site.standard.publication">(theme or Ghost Code Injection — you pick the placement)
What you need #
- A Docker host with ports 80 and 443 reachable (or an existing reverse proxy on the host — see Deploying alongside another service)
- A hostname for the bridge (
bridge.example.com) - One or more self-hosted Ghost instances (the Admin API is the dependency — Ghost Pro is not currently supported)
- One AT Protocol PDS account per Ghost blog you want to bridge — your own (
pds.example.com) or Bluesky (bsky.social). One bridge can mirror many blogs to many publications. - An app password generated on each PDS account (NOT your personal Bluesky password)
Quick start #
git clone <this repo>
cd ghoststandard
GS_HOSTNAME=bridge.example.com docker compose up -d
docker compose exec app gs setup
gs setup collects the bridge's public URL (one-time) and runs the per-site wizard for your first site. The wizard:
- PDS connection — service URL, handle, app password (validated by login)
- Ghost connection — URL + Admin API key (validated by a
posts.browsecall). Include any subpath:https://blog.example.comorhttps://example.com/blogboth work. - Site slug — a short identifier for this site (used in the webhook URL and as the optional first argument to per-site commands)
- Publication metadata — defaults pulled from Ghost's
/admin/site/(title, description, accent color, icon). Hit Enter through anything you don't want to override. - Bootstrap + auto-installs:
- Creates the
site.standard.publicationrecord on the PDS - Generates the webhook secret
- Installs the four required Ghost webhooks via the Admin API (no manual UI clicks)
- Prints the two pieces you still have to do by hand: the
.well-knownfile and the blog-homepage<link rel>. Both with ready-to-paste snippets.
- Creates the
To add another site later:
docker compose exec app gs site add
After all sites are set up, fire a test publish and tail the log:
docker compose exec app gs logs
You should see [<slug>] sync <postId> at://… within a couple seconds of clicking Publish in Ghost.
Ghost-side configuration #
The bridge auto-handles webhooks and per-post codeinjection_head via the Admin API. The two things you wire up manually per site are below — both involve the reverse proxy or theme in front of Ghost, neither of which Ghost itself exposes a way to manage from the API.
1. The /.well-known/site.standard.publication file #
This is how Standard.site verifies that you control the domain. The file must:
- Be served at the exact path
/.well-known/site.standard.publication - Return
200 - Have
Content-Type: text/plain - Contain your publication's AT-URI as the entire body
The wizard prints the exact AT-URI to use. Below are three common Docker-Ghost patterns.
Pattern A: official Ghost docker-compose with bundled Caddy #
This is the TryGhost/docker compose template (also used by some community variants). Caddy sits in the same compose stack as Ghost and uses a {$DOMAIN} substitution. Find your Caddyfile (typically /opt/ghost/caddy/Caddyfile or $GHOST_HOME/caddy/Caddyfile) and add a handle block to your existing {$DOMAIN} site — before the catch-all handle { reverse_proxy ghost:2368 }:
{$DOMAIN} {
import snippets/Logging
import snippets/TrafficAnalytics
import snippets/ActivityPub
# Standard.site publication verification
handle /.well-known/site.standard.publication {
header Content-Type text/plain
respond "at://did:plc:.../site.standard.publication/REPLACE_RKEY"
}
# Default proxy everything else to Ghost
handle {
reverse_proxy ghost:2368
}
encode gzip
import snippets/SecurityHeaders
}
Two important details:
- Tabs, not spaces. Caddy's
caddy fmtconvention uses tabs. Mixed indentation produces a non-fatal "Caddyfile input is not formatted" warning; pasting from markdown often converts tabs to spaces, so check withcat -Aor runcaddy fmt --overwriteafter editing. - The path-matched
handlemust come BEFORE the catch-allhandle. Caddy stops at the first matching directive within a site block.
Reload Caddy from the Ghost stack:
cd /opt/ghost # or wherever your Ghost compose is
docker compose exec caddy caddy reload --config /etc/caddy/Caddyfile
Pattern B: Ghost behind a host-installed Caddy #
If Caddy runs as a systemd service on the host (not in Docker), the Caddyfile is typically /etc/caddy/Caddyfile. Same handle block, same ordering rule. Reload:
sudo systemctl reload caddy
Pattern C: Ghost behind nginx (Ghost-CLI default) #
Ghost-CLI sets up nginx on the host with vhost files in /etc/nginx/sites-available/. Add a location = block to your site (exact-match takes precedence over regex/prefix locations):
server {
server_name blog.example.com;
# … your usual ssl_certificate / ssl_certificate_key lines …
# Standard.site publication verification
location = /.well-known/site.standard.publication {
default_type text/plain;
add_header Content-Type "text/plain";
return 200 "at://did:plc:.../site.standard.publication/REPLACE_RKEY";
}
location / {
proxy_pass http://127.0.0.1:2368;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
Reload:
sudo nginx -t && sudo systemctl reload nginx
Verification (any pattern) #
From a machine other than the Ghost host:
curl -i https://your-blog.example.com/.well-known/site.standard.publication
You want HTTP/2 200, content-type: text/plain, body = your full AT-URI. If you get 404, the reverse-proxy block isn't matching the path. If you get the Ghost 404 page, the catch-all proxy ran first — re-order so the well-known directive comes before it.
2. The blog-homepage <link rel="site.standard.publication"> #
This advertises the publication's AT-URI in the HTML head. The wizard prints the exact tag with your AT-URI substituted in. Two reasonable placements:
Option A — site-wide via Ghost Code Injection (easy) #
In Ghost Admin → Settings → Code injection → Site Header, paste the tag. Ghost renders it in <head> on every page (home, posts, tags, etc). The per-post <link rel="site.standard.document"> still wins as the canonical pointer for individual post pages, so there's no real conflict — just a slightly more-tags-than-strictly-needed <head> on content pages.
Option B — homepage-only via theme edit (precise) #
Edit your Ghost theme's default.hbs (or wherever the shared <head> lives) and wrap the tag in Ghost's {{#is "home"}}…{{/is}} block:
{{#is "home"}}
<link rel="site.standard.publication" href="at://did:plc:.../site.standard.publication/REPLACE_RKEY">
{{/is}}
Renders only on the index page. Requires theme access (and theme upload + activation if the theme is bundled in your Ghost). Survives Ghost UI changes.
CLI reference #
# Setup / sites
gs setup # first-run wizard
gs site add # add another site
gs site list # show all configured sites
gs site show <slug> # per-site details
gs site rm <slug> # remove a site (with optional PDS document cleanup)
# Operate on a site — slug optional if there's only one site
gs sync [<site>] <post> # manually sync one post
gs unsync [<site>] <post> # delete one PDS record + mapping
gs backfill [<site>] # re-sync every currently-published post
gs set-icon [<site>] <path> # replace the publication icon
gs set-theme [<site>] [preset] # change the theme palette (skip|light|dark)
gs webhooks [<site>] # (re-)install the four Ghost webhooks
# Inspect
gs status [<site>] # health / counts (per-site or summary)
gs config [<site>] # stored config (secrets masked)
gs logs # tail webhook delivery log
All commands run inside the container — prefix with docker compose exec app (or docker compose -f compose.shared-host.yaml exec app for the shared-host deployment shape).
gs is the short alias; the longer ghoststandard works identically if gs ever collides with something in your environment.
Site-arg behavior. When the first positional argument matches a configured site slug, it's treated as the site selector. Otherwise commands default to the single configured site (and error out if multiple sites exist).
Webhook URLs #
Each site gets a unique webhook endpoint shaped as:
https://bridge.example.com/webhooks/ghost/<slug>
The wizard auto-installs four webhooks pointing at this URL with the site's secret. If the auto-install fails (network issue during setup, or you're updating an old install), gs webhooks <slug> re-runs the installer. If you ever need to do it by hand, the events to bind are:
- Post published
- Published post updated
- Post unpublished
- Post deleted
Deploying alongside another service #
If the box already runs a reverse proxy (your PDS, another site — anything terminating TLS on 80/443), use compose.shared-host.yaml instead of the default. It omits the bundled Caddy and binds the app to 127.0.0.1:8080.
docker compose -f compose.shared-host.yaml up -d
docker compose -f compose.shared-host.yaml exec app gs setup
Then add a site block to whatever's already running on 80/443:
Caddy:
bridge.example.com {
reverse_proxy localhost:8080
}
nginx:
server {
listen 443 ssl http2;
server_name bridge.example.com;
# … your usual ssl_certificate / ssl_certificate_key lines …
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
The wizard's "public URL of this bridge" prompt is where you tell ghoststandard which hostname Ghost will hit (https://bridge.example.com). Per-site webhook URLs become https://bridge.example.com/webhooks/ghost/<slug> from there.
Backfill #
After a Ghost blog has been configured, the bridge syncs new posts automatically via webhooks — but existing published posts at setup time are NOT retroactively synced. Run:
gs backfill <slug>
The bridge browses every currently-published post via the Admin API and syncs each one. Idempotent (rkey = Ghost post id), so safe to re-run — already-synced posts just get their PDS records overwritten with the same content.
Per-post errors don't abort the run; they end up in the returned failed: [...] summary you can re-try by hand.
Architecture #
┌────────────────────┐
│ Ghost blog #1 │ ──webhook─┐
└────────────────────┘ │
┌────────────────────┐ │
│ Ghost blog #2 │ ──webhook─┤ ┌──────────────────┐ atproto API ┌──────────────┐
└────────────────────┘ ├──>│ ghoststandard │ ──────────────> │ AT proto │
┌────────────────────┐ │ │ (container) │ │ PDS(es) │
│ Ghost blog #N │ ──webhook─┘ └────────┬─────────┘ └──────────────┘
└────────────────────┘ │ ┌──────────────────┐
│ stores ──────> │ /data/...sqlite │
│ │ (sites + maps) │
▼ └──────────────────┘
┌──────────┐
│ CLI │ ← docker compose exec app gs …
└──────────┘
Caddy (bundled or pre-existing) sits in front for TLS termination + automatic Let's Encrypt. The app listens on 8080 internally.
Data + backups #
Everything lives in one named Docker volume (gs_data) at /data/ghoststandard.sqlite. Back it up. Schema is sites, post_mapping (site-scoped), and a small config table for global settings.
If you lose the SQLite file, re-running gs setup + gs site add (for any extras) + gs backfill <slug> rebuilds everything. The bridge writes records by Ghost post ID (rkey = post.id), so a backfill from a fresh DB produces the same AT-URIs as before. No duplicates, no orphans.
The webhook delivery log lives at /data/webhook.log (append-only JSONL). gs logs tails it.
Troubleshooting #
Webhooks aren't firing. First check Ghost's webhook delivery history (Settings → Integrations → your integration → click into a webhook → "Recent deliveries"). If those show non-2xx, the URL or secret might be wrong; re-run gs webhooks <slug> to re-create or use gs config <slug> to confirm the secret length matches what Ghost has. If Ghost shows successful deliveries but you see no sync lines in gs logs, look at the bridge's container logs (docker compose logs app) — webhook handler errors that don't make it into the delivery log will show up there.
gs site add says "Ghost Admin API call failed". The Admin API key is key_id:hex_secret, all one string with a colon — don't paste the Content API key by mistake. Also confirm your ghost_url includes any subpath (https://example.com/blog, not just https://example.com, if Ghost is mounted at /blog).
The .well-known file returns 404. The reverse-proxy directive must come before the catch-all proxy directive in the same site block. Caddy uses handle /... (first-match wins); nginx uses location = /... (exact match takes precedence over location /).
TLS errors when calling out to Ghost from inside Docker. This bit Tim's original integrated bridge — usually the cause is a stale /etc/hosts entry on the host after an IP change, not a real network problem. Check that the box's /etc/hosts matches the current public IP.
Development #
npm install
npm run dev # webhook server with watch reload
npm run cli -- help # run CLI from source
npm run typecheck
npm run build # → dist/
Stack (for contributors) #
- TypeScript (strict, ESM)
- Hono — webhook receiver
- better-sqlite3 — embedded persistence
- @atproto/api — PDS client
- @tryghost/admin-api — Ghost Admin client
- @inquirer/prompts + picocolors + ora — CLI polish
- sharp — cover-image resize
- Caddy — reverse proxy + automatic TLS (when using bundled compose)
License #
MIT — see LICENSE.