diff --git a/README.md b/README.md
index aa55e98..197e5d3 100644
--- a/README.md
+++ b/README.md
@@ -2,38 +2,53 @@
A self-hosted bridge that mirrors **one or many** Ghost blogs into [Standard.site](https://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 `` tag gets dropped into each post's `
` without you touching the Ghost editor.
-Self-contained: one Docker container, one named volume, Caddy in front for TLS. Configuration is a CLI you run inside the container — no admin web UI to log into.
+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 in development.** Multi-tenant refactor just landed; full end-to-end test against a real Ghost still pending.
+> **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.publication` record 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 a `site.standard.document` record, and updates the post's per-post `codeinjection_head` so its `` 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](#ghost-side-configuration)):
+
+- The `/.well-known/site.standard.publication` file on the blog's domain (reverse-proxy config, not Ghost config)
+- The blog-homepage `` (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 — see [Deploying alongside another service](#deploying-alongside-another-service))
+- A Docker host with ports 80 and 443 reachable (or an existing reverse proxy on the host — see [Deploying alongside another service](#deploying-alongside-another-service))
- A hostname for the bridge (`bridge.example.com`)
-- One or more Ghost instances with the Admin API enabled
-- One AT Protocol PDS account per Ghost instance 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
+- 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
```bash
git clone
cd ghoststandard
-mkdir -p assets && cp ~/path/to/icon.png assets/icon.png # optional, used by 'Provide local file path' icon step
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 then runs the **site-add wizard** for your first site. The wizard walks you through:
+`gs setup` collects the bridge's public URL (one-time) and runs the **per-site wizard** for your first site. The wizard:
1. **PDS connection** — service URL, handle, app password (validated by login)
2. **Ghost connection** — URL + Admin API key (validated by a `posts.browse` call). Include any subpath: `https://blog.example.com` or `https://example.com/blog` both work.
-3. **Site slug** — a short identifier for this site (used in the webhook URL and as an optional arg to per-site commands)
-4. **Publication metadata** — defaults pulled from Ghost's `settings.browse()` (title, description, accent color, icon). Hit Enter through anything you don't want to override.
-5. **Bootstrap** — creates the `site.standard.publication` record, auto-generates a webhook secret, and prints the three pieces of glue:
- - The `/.well-known/site.standard.publication` content for that Ghost's reverse-proxy
- - The four Ghost webhooks to add (URL + secret prefilled — URL includes the site slug)
- - The `` to drop into the theme's homepage ``
+3. **Site slug** — a short identifier for this site (used in the webhook URL and as the optional first argument to per-site commands)
+4. **Publication metadata** — defaults pulled from Ghost's `/admin/site/` (title, description, accent color, icon). Hit Enter through anything you don't want to override.
+5. **Bootstrap + auto-installs**:
+ - Creates the `site.standard.publication` record 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-known` file and the blog-homepage ``. Both with ready-to-paste snippets.
To add another site later:
@@ -41,32 +56,166 @@ To add another site later:
docker compose exec app gs site add
```
-## CLI
+After all sites are set up, fire a test publish and tail the log:
+
+```bash
+docker compose exec app gs logs
+```
+
+You should see `[] sync 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](https://github.com/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 }`:
+
+```caddy
+{$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 fmt` convention uses tabs. Mixed indentation produces a non-fatal "Caddyfile input is not formatted" warning; pasting from markdown often converts tabs to spaces, so check with `cat -A` or run `caddy fmt --overwrite` after editing.
+- **The path-matched `handle` must come BEFORE the catch-all `handle`.** Caddy stops at the first matching directive within a site block.
+
+Reload Caddy from the Ghost stack:
+
+```bash
+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:
+
+```bash
+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):
+
+```nginx
+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:
+
+```bash
+sudo nginx -t && sudo systemctl reload nginx
+```
+
+#### Verification (any pattern)
+
+From a machine other than the Ghost host:
+
+```bash
+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 ``
+
+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 `` on every page (home, posts, tags, etc). The per-post `` still wins as the canonical pointer for individual post pages, so there's no real conflict — just a slightly more-tags-than-strictly-needed `` on content pages.
+
+#### Option B — homepage-only via theme edit (precise)
+
+Edit your Ghost theme's `default.hbs` (or wherever the shared `` lives) and wrap the tag in Ghost's `{{#is "home"}}…{{/is}}` block:
+
+```handlebars
+{{#is "home"}}
+
+{{/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
```bash
# Setup / sites
-docker compose exec app gs setup # first-run wizard
-docker compose exec app gs site add # add another site
-docker compose exec app gs site list # show all sites
-docker compose exec app gs site show # per-site details
-docker compose exec app gs site rm # remove a site
-
-# Operate on a site (slug optional if there's only one site)
-docker compose exec app gs sync []
-docker compose exec app gs unsync []
-docker compose exec app gs backfill []
-docker compose exec app gs set-icon []
-docker compose exec app gs set-theme [] [skip|light|dark]
+gs setup # first-run wizard
+gs site add # add another site
+gs site list # show all configured sites
+gs site show # per-site details
+gs site rm # remove a site (with optional PDS document cleanup)
+
+# Operate on a site — slug optional if there's only one site
+gs sync [] # manually sync one post
+gs unsync [] # delete one PDS record + mapping
+gs backfill [] # re-sync every currently-published post
+gs set-icon [] # replace the publication icon
+gs set-theme [] [preset] # change the theme palette (skip|light|dark)
+gs webhooks [] # (re-)install the four Ghost webhooks
# Inspect
-docker compose exec app gs status [] # health / counts
-docker compose exec app gs config [] # stored config (secrets masked)
-docker compose exec app gs logs # tail webhook delivery log
+gs status [] # health / counts (per-site or summary)
+gs config [] # 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.
-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 if there are multiple).
+**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
@@ -76,7 +225,12 @@ Each site gets a unique webhook endpoint shaped as:
https://bridge.example.com/webhooks/ghost/
```
-You'd configure four Ghost webhooks per site (Post published / Published post updated / Post unpublished / Post deleted), all pointing at the same `/webhooks/ghost/` URL with that site's secret.
+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 ` 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
@@ -93,7 +247,7 @@ Then add a site block to whatever's already running on 80/443:
```caddy
bridge.example.com {
- reverse_proxy localhost:8080
+ reverse_proxy localhost:8080
}
```
@@ -101,20 +255,32 @@ bridge.example.com {
```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;
- }
+ 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 setup 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/` from there.
+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/` 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:
+
+```bash
+gs backfill
+```
+
+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
@@ -140,10 +306,22 @@ Caddy (bundled or pre-existing) sits in front for TLS termination + automatic Le
## 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` for global settings.
+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 ` 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 ` to re-create or use `gs config ` 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
```bash
@@ -163,7 +341,7 @@ npm run build # → dist/
- [@tryghost/admin-api](https://github.com/TryGhost/SDK/tree/main/packages/admin-api) — Ghost Admin client
- [@inquirer/prompts](https://github.com/SBoudrias/Inquirer.js) + [picocolors](https://github.com/alexeyraspopov/picocolors) + [ora](https://github.com/sindresorhus/ora) — CLI polish
- [sharp](https://sharp.pixelplumbing.com/) — cover-image resize
-- [Caddy](https://caddyserver.com/) — reverse proxy + automatic TLS
+- [Caddy](https://caddyserver.com/) — reverse proxy + automatic TLS (when using bundled compose)
## License