Ghost CMS setup and management expert.
Ghost is an open-source, professional publishing platform built on Node.js. It handles websites, blogs, newsletters, and paid memberships in a single platform. Ghost exposes two APIs — Admin (read/write, JWT-authenticated) and Content (read-only, key-authenticated) — making it ideal as a headless CMS or traditional publishing tool.
Docs: https://ghost.org/docs/
# Prerequisites
sudo apt update && sudo apt install -y nginx mysql-server nodejs npm
sudo npm install -g ghost-cli@latest
# Install Ghost
sudo mkdir -p /var/www/ghost && cd /var/www/ghost
sudo chown $(whoami):$(whoami) /var/www/ghost
ghost install
The ghost install wizard prompts for:
# docker-compose.yml
version: "3.8"
services:
ghost:
image: ghost:5-alpine
restart: always
ports:
- "2368:2368"
environment:
url: https://example.com
database__client: mysql
database__connection__host: db
database__connection__user: ghost
database__connection__password: ${GHOST_DB_PASSWORD}
database__connection__database: ghost
mail__transport: SMTP
mail__options__host: smtp.mailgun.org
mail__options__port: 587
mail__options__auth__user: ${MAILGUN_SMTP_USER}
mail__options__auth__pass: ${MAILGUN_SMTP_PASS}
volumes:
- ghost-content:/var/lib/ghost/content
depends_on:
- db
db:
image: mysql:8.0
restart: always
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
MYSQL_USER: ghost
MYSQL_PASSWORD: ${GHOST_DB_PASSWORD}
MYSQL_DATABASE: ghost
volumes:
- ghost-db:/var/lib/mysql
volumes:
ghost-content:
ghost-db:
ghost start # Start Ghost
ghost stop # Stop Ghost
ghost restart # Restart Ghost
ghost update # Update to latest version
ghost log # View logs (add --follow for tail)
ghost config # View/edit config
ghost config url https://newdomain.com # Change site URL
ghost doctor # Diagnose issues
ghost backup # Create backup (content + DB)
Ghost Admin API uses short-lived JWT tokens generated from an Admin API key. The key format is id:secret (hex-encoded).
const jwt = require('jsonwebtoken');
// Admin API key from Ghost Admin → Integrations → Custom
const key = process.env.GHOST_ADMIN_API_KEY; // format: "id:secret"
const [id, secret] = key.split(':');
const token = jwt.sign({}, Buffer.from(secret, 'hex'), {
keyid: id,
algorithm: 'HS256',
expiresIn: '5m',
audience: '/admin/'
});
// Use in requests
// Authorization: Ghost ${token}
GET https://example.com/ghost/api/content/posts/?key=YOUR_CONTENT_API_KEY
Content API keys are passed as a key query parameter. No JWT required.
Ghost Admin → Settings → Integrations → Add custom integration. This generates both an Admin API key and a Content API key.
Never hardcode credentials. Use environment variables: GHOST_ADMIN_API_KEY, GHOST_CONTENT_API_KEY, GHOST_API_URL.
Base URL: https://
# List posts
curl -s "https://example.com/ghost/api/admin/posts/" \
-H "Authorization: Ghost ${TOKEN}"
# Get single post (include author + tags)
curl -s "https://example.com/ghost/api/admin/posts/${POST_ID}/?include=authors,tags" \
-H "Authorization: Ghost ${TOKEN}"
# Create post
curl -s -X POST "https://example.com/ghost/api/admin/posts/" \
-H "Authorization: Ghost ${TOKEN}" \
-H "Content-Type: application/json" \
-d '{"posts": [{"title": "My Post", "html": "<p>Content here</p>", "status": "draft"}]}'
# Update post (requires updated_at for collision detection)
curl -s -X PUT "https://example.com/ghost/api/admin/posts/${POST_ID}/" \
-H "Authorization: Ghost ${TOKEN}" \
-H "Content-Type: application/json" \
-d '{"posts": [{"title": "Updated Title", "updated_at": "2026-01-01T00:00:00.000Z"}]}'
# Delete post
curl -s -X DELETE "https://example.com/ghost/api/admin/posts/${POST_ID}/" \
-H "Authorization: Ghost ${TOKEN}"
Same endpoints as posts but at /ghost/api/admin/pages/.
# Create tag
curl -s -X POST "https://example.com/ghost/api/admin/tags/" \
-H "Authorization: Ghost ${TOKEN}" \
-H "Content-Type: application/json" \
-d '{"tags": [{"name": "Technology", "slug": "technology", "description": "Tech articles"}]}'
# List members
curl -s "https://example.com/ghost/api/admin/members/?filter=status:paid" \
-H "Authorization: Ghost ${TOKEN}"
# Create member
curl -s -X POST "https://example.com/ghost/api/admin/members/" \
-H "Authorization: Ghost ${TOKEN}" \
-H "Content-Type: application/json" \
-d '{"members": [{"email": "reader@example.com", "name": "Reader Name", "labels": [{"name": "VIP"}]}]}'
# Upload image
curl -s -X POST "https://example.com/ghost/api/admin/images/upload/" \
-H "Authorization: Ghost ${TOKEN}" \
-F "file=@/path/to/image.jpg" \
-F "purpose=image"
# List newsletters
curl -s "https://example.com/ghost/api/admin/newsletters/" \
-H "Authorization: Ghost ${TOKEN}"
# Create newsletter
curl -s -X POST "https://example.com/ghost/api/admin/newsletters/" \
-H "Authorization: Ghost ${TOKEN}" \
-H "Content-Type: application/json" \
-d '{"newsletters": [{"name": "Weekly Digest", "sender_name": "Blog Team", "sender_email": "hello@example.com"}]}'
Base URL: https://
Read-only API for public content. Use for headless front-ends, static site generators, or content syndication.
# List published posts
curl -s "https://example.com/ghost/api/content/posts/?key=${CONTENT_KEY}&include=tags,authors&limit=10"
# Filter posts by tag
curl -s "https://example.com/ghost/api/content/posts/?key=${CONTENT_KEY}&filter=tag:technology"
# Get post by slug
curl -s "https://example.com/ghost/api/content/posts/slug/my-post-slug/?key=${CONTENT_KEY}"
# List tags
curl -s "https://example.com/ghost/api/content/tags/?key=${CONTENT_KEY}&include=count.posts"
# List authors
curl -s "https://example.com/ghost/api/content/authors/?key=${CONTENT_KEY}&include=count.posts"
# Pagination
curl -s "https://example.com/ghost/api/content/posts/?key=${CONTENT_KEY}&page=2&limit=15"
Ghost uses NQL (Nested Query Language) for filtering:
| Filter | Example |
|---|---|
| Exact match | filter=tag:news |
| Multiple values | filter=tag:[news,tech] |
| Negation | filter=tag:-draft |
| Combined | filter=tag:news+featured:true |
| OR | filter=tag:news,tag:tech |
| Date range | filter=published_at:>'2026-01-01' |
| Null check | filter=feature_image:-null |
Ghost themes use Handlebars templates. Theme directory structure:
my-theme/
├── package.json # Theme metadata (name, version, config)
├── index.hbs # Home page / post listing
├── post.hbs # Single post
├── page.hbs # Static page
├── tag.hbs # Tag archive
├── author.hbs # Author archive
├── default.hbs # Base layout wrapper
├── error.hbs # Error page
├── custom-page.hbs # Custom template (selectable in editor)
├── partials/
│ ├── header.hbs
│ ├── footer.hbs
│ └── post-card.hbs
└── assets/
├── css/
├── js/
└── images/
{{!-- Post loop --}}
{{#foreach posts}}
<h2><a href="{{url}}">{{title}}</a></h2>
<p>{{excerpt words="30"}}</p>
{{#if feature_image}}
<img src="{{feature_image}}" alt="{{title}}">
{{/if}}
<span>{{date published_at format="DD MMM YYYY"}}</span>
<span>{{reading_time}}</span>
{{/foreach}}
{{!-- Navigation --}}
{{navigation}}
{{!-- Pagination --}}
{{pagination}}
{{!-- Content output --}}
{{content}}
{{!-- Conditional visibility --}}
{{#has tag="premium"}}
{{!-- Paid content --}}
{{/has}}
{{!-- Member-aware content --}}
{{#if @member}}
<p>Welcome back, {{@member.name}}</p>
{{else}}
<p>Subscribe to read more</p>
{{/if}}
# Install gscan for theme validation
npm install -g gscan
# Validate theme
gscan /path/to/theme
# Upload theme via API
curl -s -X POST "https://example.com/ghost/api/admin/themes/upload/" \
-H "Authorization: Ghost ${TOKEN}" \
-F "file=@theme.zip"
# Activate theme
curl -s -X PUT "https://example.com/ghost/api/admin/themes/${THEME_NAME}/activate/" \
-H "Authorization: Ghost ${TOKEN}"
Ghost has built-in membership and subscription support with Stripe integration.
Ghost Admin → Settings → Membership → Stripe Connect. Use the "Connect with Stripe" button for OAuth flow, or manually set keys:
ghost config set stripe:secretKey sk_live_xxx
ghost config set stripe:publicKey pk_live_xxx
# Create tier
curl -s -X POST "https://example.com/ghost/api/admin/tiers/" \
-H "Authorization: Ghost ${TOKEN}" \
-H "Content-Type: application/json" \
-d '{"tiers": [{"name": "Premium", "monthly_price": 900, "yearly_price": 9000, "currency": "usd", "description": "Full access to all content"}]}'
Prices are in cents (900 = $9.00).
public — visible to everyonemembers — visible to free + paid memberspaid — visible to paid members onlytiers — visible to specific tiers (set via tiers array on post)Ghost sends newsletters via Mailgun (recommended) or other SMTP providers.
// config.production.json
{
"mail": {
"transport": "SMTP",
"options": {
"service": "Mailgun",
"host": "smtp.mailgun.org",
"port": 587,
"secure": true,
"auth": {
"user": "postmaster@mg.example.com",
"pass": "mailgun-smtp-password"
}
}
},
"bulk_email_settings": {
"provider": "mailgun",
"apiKey": "key-xxx",
"domain": "mg.example.com",
"baseUrl": "https://api.eu.mailgun.net/v3"
}
}
Use baseUrl: "https://api.eu.mailgun.net/v3" for EU Mailgun accounts.
# Export WordPress to WXR XML, then use the Ghost migration tool
npm install -g @tryghost/migrate
# Convert WordPress export to Ghost JSON
migrate wp /path/to/wordpress-export.xml
# Import via Ghost Admin → Settings → Import
# Or via API:
curl -s -X POST "https://example.com/ghost/api/admin/db/" \
-H "Authorization: Ghost ${TOKEN}" \
-F "importfile=@ghost-import.json"
# Full JSON export
curl -s "https://example.com/ghost/api/admin/db/" \
-H "Authorization: Ghost ${TOKEN}" \
-o ghost-export.json
# Create webhook
curl -s -X POST "https://example.com/ghost/api/admin/webhooks/" \
-H "Authorization: Ghost ${TOKEN}" \
-H "Content-Type: application/json" \
-d '{"webhooks": [{"event": "post.published", "target_url": "https://hooks.example.com/ghost", "integration_id": "INTEGRATION_ID"}]}'
Available events: post.published, post.unpublished, post.deleted, page.published, member.added, member.deleted, tag.added.
Ghost works well behind Cloudflare for CDN, SSL, and caching:
/ghost/ (admin) and /members/ (auth). Purge cache on post.published webhook.Ghost config lives in config.production.json (production) or config.development.json (dev):
{
"url": "https://example.com",
"server": {
"port": 2368,
"host": "127.0.0.1"
},
"database": {
"client": "mysql",
"connection": {
"host": "localhost",
"user": "ghost",
"password": "password",
"database": "ghost_prod"
}
},
"logging": {
"level": "info",
"transports": ["stdout"]
},
"paths": {
"contentPath": "/var/lib/ghost/content"
}
}
updated_at is required for PUT requests. Ghost uses optimistic locking — you must send the current updated_at value or the update will fail with a 409 Conflict. Always GET before PUT.url config must match exactly. If Ghost is configured with https://example.com but accessed via http://example.com or https://www.example.com, redirects and links will break. Set this correctly at install time.mysql_native_password auth plugin, not caching_sha2_password (MySQL 8 default). Set ALTER USER 'ghost'@'localhost' IDENTIFIED WITH mysql_native_password BY 'password';./content/images/. For scalability, configure an S3-compatible storage adapter or use Cloudflare R2.bulk_email_settings, not just SMTP.