Skip to content

Native multi-site configuration for headless deployments #121

Description

@bst1n

Context

ghst mcp http is increasingly deployed in headless environments (Docker on Coolify / fly.io / Railway, CI workers, etc.) where the canonical configuration mechanism is environment variables.

For single-site deployments this works natively: ghst reads GHOST_URL and GHOST_STAFF_ACCESS_TOKEN at runtime (see src/lib/config.ts:358). One docker run with two env vars and you're done — clean.

For multi-site deployments (e.g. mirroring a blog FR/EN translated variant, or hosting two related blogs in one container) there is no equivalent. The only path today is to script ghst auth login calls at container startup.

Current workaround

FROM node:20-alpine
RUN npm install -g @tryghost/ghst@0.13.0
EXPOSE 3100
CMD ["sh", "-c", "\
  ghst auth login --non-interactive --insecure-storage \
    --url \"$GHOST_URL_1\" --staff-token \"$GHOST_TOKEN_1\" --site \"$GHOST_ALIAS_1\" && \
  ghst auth login --non-interactive --insecure-storage \
    --url \"$GHOST_URL_2\" --staff-token \"$GHOST_TOKEN_2\" --site \"$GHOST_ALIAS_2\" && \
  ghst mcp http --host 0.0.0.0 --port 3100 --tools all --auth-token $GHST_AUTH_TOKEN --unsafe-public-bind \
"]

This works but has several rough edges:

  1. Forced --insecure-storage because containers have no OS keychain, which generates a warning every boot:

    Warning: secure credential storage is unavailable; plaintext staff access tokens remain in config.

  2. Implicit default site by login order — the last ghst auth login wins as active. If you add a third site later and forget the ordering, the default silently changes.
  3. Runtime config mutation — each boot rewrites ~/.config/ghst/config.json, which is a side effect on a stateless container.
  4. Naming friction — env var names (GHOST_URL_1, GHOST_TOKEN_1, GHOST_ALIAS_1, etc.) are arbitrary and user-defined in the startup script, not standardized.

Proposal

Add a native mechanism for declaring multiple sites via environment variables, that ghst reads on startup without any auth login step. Two options I see, not mutually exclusive:

Option A — JSON-encoded env var

GHOST_SITES='[
  {"alias":"blog-fr","url":"https://blog.example.com","staffToken":"id:secret","default":true},
  {"alias":"blog-en","url":"https://en.example.com","staffToken":"id:secret"}
]'

Pros: single variable, clean. Cons: shell escaping for special chars (rare for Ghost tokens but possible).

Option B — Discovery via numbered or named env vars

GHOST_SITE_BLOG_FR_URL=https://blog.example.com
GHOST_SITE_BLOG_FR_STAFF_TOKEN=id:secret
GHOST_SITE_BLOG_EN_URL=https://en.example.com
GHOST_SITE_BLOG_EN_STAFF_TOKEN=id:secret
GHOST_DEFAULT_SITE=blog-fr

Pros: each value is a plain string, no escaping. Ergonomic in Coolify / fly secrets UIs. Cons: more variables to manage.

In both cases, an explicit GHOST_DEFAULT_SITE (or equivalent in the JSON) replaces the implicit "last login wins" rule.

Why this matters

  • Eliminates the --insecure-storage warning by making the env-vars path first-class for multi-site (no plaintext config file written).
  • Removes the brittle startup script and runtime config mutation.
  • Makes the default site explicit, removing a class of "oops, wrong blog" bugs that matter when destructive tools are exposed via MCP.
  • Aligns multi-site deployments with the same pattern that already works for single-site.

Environment

  • ghst v0.13.0
  • Deployment context: Docker on Coolify, multi-site setup (e.g. FR ↔ EN mirror translation use case via Claude.ai MCP connector)

Happy to test a patch or contribute one if there's interest and direction on the preferred API shape.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions