Get a real Ghost site running on your server. Then get out of the way.
Installs the latest Ghost on any SSH-reachable ISPConfig host β database, loopback port,
systemd --user unit, vhost directives β and claims the owner account before the site is
ever public. You pick the theme and wire up email yourself, in Ghost, where those choices
belong.
Ships as an MCP server and a CLI.
| ghostkit does | left to you |
|---|---|
| Create the database and user | Write the articles β that's themeseed |
| Allocate a free loopback port | |
| Install the latest Ghost | |
| Claim the owner account, over loopback | |
| Create the Admin API key for themeseed | |
Upload and activate your theme, plus its routes.yaml |
|
| Generate a favicon, wordmark, accent colour and navigation | |
| Wire SendGrid for transactional mail | |
Run it under systemd --user, no sudo for the tenant |
|
| Emit the vhost directives |
Claiming the owner is automated because it is not really a choice β it is a race. Ghost's
setup endpoint is unauthenticated and fires exactly once, so the only safe moment to call
it is while Ghost is still bound to 127.0.0.1 and nobody else can reach it.
As a plugin β one command, nothing else to install:
/plugin marketplace add ThemeAnax/ghostkit
/plugin install ghostkit@ghostkit
Then restart Claude Code. The plugin ships a self-contained bundled server, so
there is no npm install, no node_modules, and no registry access needed.
Manage it with /plugin β it lists what's installed and lets you update or remove.
Or as a plain MCP server, if you'd rather not use a plugin:
npx -y @indianic/ghostkit install --editors claudeThat writes the entry into ~/.claude.json pinned to @latest. This route needs
access to the npm registry the package is published to.
ghostkit registers itself. Ask your agent to run detect_editors, pick the ones you want,
and it calls register_editors:
| Editor | Config it writes |
|---|---|
| Claude Code | ~/.claude.json |
| Codex | ~/.codex/config.toml (TOML section, rest of the file untouched) |
| Cursor | ~/.cursor/mcp.json |
| Windsurf | ~/.codeium/windsurf/mcp_config.json |
| Gemini CLI | ~/.gemini/settings.json |
| Antigravity | via its own agy mcp add |
| Zed | ~/.config/zed/settings.json (context_servers) |
| VS Code | ~/Library/.../Code/User/mcp.json |
Every file is backed up first, because rewriting JSON drops comments.
Updates take care of themselves. Registration uses
npx -y --package @indianic/ghostkit@latest ghostkit-mcp, which re-resolves the registry
on every launch β so restarting your editor is the update. version shows what is running
against what is published. The server never rewrites its own code mid-session; that would
change tool behaviour under a running agent and break every editor at once on a bad
release.
npx @indianic/ghostkit init # writes a blank ghostkit.config.json
$EDITOR ghostkit.config.json # set site.domain + server.sshcon_alias
npx @indianic/ghostkit preflight # is the host actually ready?As an MCP server:
Then:
"Use ghostkit with dir
~/sites/blog.example.comβ resolve_server, preflight, install_ghost, publish_site."
The whole file, when you use sshcon:
{
"site": { "domain": "blog.example.com", "title": "My Blog", "port": null },
"admin": { "name": "Jane Doe", "email": "jane@example.com", "password": "", "api_key": "" },
"theme": { "source": "~/themes/bastian.zip", "activate": true },
"branding": { "description": "", "accent_color": "", "generate_assets": true, "navigation": [] },
"mail": { "sendgrid_api_key": "", "from": "" },
"author": { "name": "", "email": "" },
"server": { "sshcon_alias": "blog-example" },
"database": { "name": "", "user": "", "password": "" }
}Everything except site.domain, admin.name and admin.email is optional:
theme.sourceβ a local zip path or an http(s) URL. Blank keeps Ghost's default theme.branding.accent_colorβ blank derives a stable colour from the title.authorβ byline for generated content; themeseed asks per blog without it.content.sourceβ a Ghost export to import: a.json, or a.zipholding that json alongsidecontent/images/β¦. A bare.jsonbrings no images with it.admin.api_keyandadmin.passwordare written by ghostkit, not filled in by you.
Leave admin.password blank and ghostkit generates a 20-character one, writes it back to
this file, and returns it in the tool result. Give it one instead if you'd rather; Ghost
requires at least 10 characters. site.title falls back to the domain.
The file holds the database password and the admin password, so it is written 0600 β and
a config left 0644 by an older version is corrected on the next write.
resolve_server runs sshcon list <alias> all and fills in the rest β the root exec
alias, host, SSH port, the database the panel provisioned, and the allocated application
port. Without sshcon, fill server.host, server.user, server.ssh_key_path and the
whole database block by hand; nothing can discover them for you.
Note the server block has one field. Nothing is pre-answered: a value in this file is a
decision you made, never one ghostkit guessed for you.
Installed as a Claude Code plugin, ghostkit ships two runbooks so a whole site is one line instead of a pasted brief. Each derives what it can from the theme name, asks once for the rest, then runs the steps β stopping at the places that have bitten before: the missing config fields, the preflight fixes, the loopback port, and the vhost check.
| Command | Content comes from |
|---|---|
/ghostkit:new-site <theme> [server] |
the theme's own demo export (.json or .zip) |
/ghostkit:new-site-seeded <theme> [server] |
themeseed-generated articles |
Both refuse a loopback port outside 51000β51999 and stop on an IP-bound vhost, because each of those has taken a whole host down once already.
| Tool | Does |
|---|---|
init_config |
Write a blank config; list what still needs filling |
resolve_server |
Fill server + database from an sshcon alias |
config_status |
Report remaining blank fields |
preflight |
Host readiness matrix + fix commands |
install_ghost |
Install the latest Ghost, then claim the owner over loopback |
create_admin |
Claim the owner on its own β retry, or an older install |
create_admin_key |
Admin API key as {id}:{secret}, saved to admin.api_key |
install_theme |
Upload + activate a theme, and its routes.yaml |
apply_branding |
Favicon, wordmark, accent colour, navigation |
configure_mail |
SendGrid for transactional mail |
publish_site |
Return the vhost directives; you apply them |
status |
Is it up, and has anyone claimed it yet? |
next_steps |
The hand-off instructions |
detect_editors / register_editors |
Install ghostkit into your editors |
version |
Running vs published |
POST /ghost/api/admin/authentication/setup/ is unauthenticated and works exactly
once. Whoever calls it first owns the publication β no login required, no way to undo it.
That makes the obvious install order dangerous: point DNS and a reverse proxy at a fresh Ghost, then set up the owner, and there is a window β minutes, or days β in which any stranger who finds the URL can claim your client's blog.
ghostkit closes it by construction:
| Step | Reachable from | Why |
|---|---|---|
install_ghost |
127.0.0.1 only |
nothing public exists yet |
| β³ owner claimed | 127.0.0.1 only |
claimed where nobody else can reach it |
publish_site |
the internet | the site is already owned |
status will tell you the truth at any point:
owner_claimed true
If it ever says false on a published site, run create_admin immediately β though at
that point you are in a race you may already have lost.
preflight checks all of this and prints fix commands:
| Requirement | Notes |
|---|---|
| The website + an active shell user in ISPConfig | Chroot = None |
Node ^22.23.1 |
Ghost 6's own requirement |
ghost-cli |
npm i -g ghost-cli |
| MySQL 8 or MariaDB | see below |
systemd (loginctl) |
for --user units + linger |
Node and ghost-cli are checked as the tenant, not as root. Root's PATH says nothing
about the shell user's, and that mismatch is a genuinely confusing way for an install to
fail: preflight goes green, then ghost install cannot find ghost.
A jailkit chroot has no XDG_RUNTIME_DIR and no dbus socket, so systemctl --user cannot
work inside one β the owner could never restart their own site. Set Chroot = None.
Ghost's official position is that MySQL 8 is the only supported database. In practice Ghost 6.62 installs and runs on MariaDB 10.11; there is no version check in ghost-cli or Ghost that blocks it. ghostkit installs the latest Ghost either way and tells you which engine it found.
The vhost needs X-Forwarded-Proto, or nobody can sign in
Ghost issues its admin session cookie with Secure, and express-session will not issue
a Secure cookie on a connection it believes is plain http. mod_proxy does not set
X-Forwarded-Proto itself. Without that header POST /ghost/api/admin/session/ returns
201 with no Set-Cookie and the admin UI simply spins β a success status and a
completely broken login.
publish_site writes this into the vhost itself, configtests, and reloads β it does not
hand you a block to apply elsewhere, because the obvious route for that
(sshmanager_set_domain_port) regenerates its own proxy block and drops the header.
Never pass ip_address when provisioning the domain
The one trap ghostkit cannot protect you from, because it happens before ghostkit is
involved β in sshmanager_provision_domain, or in the ISPConfig panel.
An explicit IP makes ISPConfig emit <VirtualHost 192.0.2.1:443>. Every other site on a
shared host uses <VirtualHost *:443>, and an IP-literal vhost outranks every wildcard
one on the same address β so the new site silently becomes the default for every
hostname on that IP. Visitors to unrelated domains get your site, and HTTPS throws
certificate-mismatch errors because your cert does not cover their hostnames.
The symptom looks nothing like the cause: other people's sites break, yours looks fine.
Worse, the value persists in ISPConfig's web_domain row. Fixing the vhost file by
hand works until the next time ISPConfig regenerates it β which any directive update
does β and then the binding comes straight back. The real fix is the panel value: set
the site's IPv4 address to *, as every other site on the host has it.
Check after provisioning. Empty output is correct:
sshcon exec <host> -- 'apache2ctl -S | grep -E "^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+:"'publish_site rewrites an IP-bound vhost to a wildcard and reports rebound_to_wildcard,
but that is a safety net for the file only: between provisioning and publishing the
hijack is live, and the panel value still needs correcting.
Ghost emails a sign-in code, and that locks you out
Ghost 5.9x+ defaults security.staffDeviceVerification to true and emails a code on every
staff sign-in. On a site with no mail configured the session endpoint fails outright β you
cannot log in to the site you just created.
ghostkit sets it to false at install. Once SendGrid works, set it back to true.
next_steps spells this out.
Never proxy /.well-known/
Proxy it and Let's Encrypt renewals fail silently about 60 days later. The emitted
directives exclude it, and the installer moves .well-known aside and restores it around
the install, because ghost install refuses any non-empty directory.
--process local is mandatory
--no-setup-systemd only skips creating the unit; it leaves "process": "systemd" in
the config. Every later ghost command then shells out to sudo systemctl β¦, which hangs
forever on a password prompt the tenant cannot answer.
ghost ls will say "stopped" β ignore it
Ghost-CLI's local process manager cannot see the systemd --user unit. Trust
systemctl --user is-active ghost.
The web folder gets cleared
Ghost installs into the domain's own web folder, and ghost install requires it empty.
ISPConfig defaults (standard_index.html, favicon.ico, robots.txt) and a previous
Ghost are removed without ceremony; .well-known and error/ are preserved. Anything
else stops the install until you pass wipe: true.
npm install
npm run buildProbe the MCP server directly:
printf '%s\n%s\n%s\n' \
'{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"probe","version":"1"}}}' \
'{"jsonrpc":"2.0","method":"notifications/initialized"}' \
'{"jsonrpc":"2.0","id":2,"method":"tools/list"}' \
| node dist/mcp/index.js- themeseed β reads the active theme and generates content that fits it. Give it the Admin API key you create in Ghost.
- Ghost Β· Ghost-CLI docs
- Model Context Protocol
MIT Β© ThemeAnax