This is the operator's and engineer's reference for Ghost, the professional publishing platform produced by the Ghost Foundation (MIT licensed). It is written for administrators, DevOps engineers, and architects who deploy, configure, secure, operate, and extend Ghost — and for developers who want an exhaustive, source-based map of how the application is built.
Where the User Guide covers using a Ghost publication, this guide covers running and understanding one: the boot sequence, the configuration system, the data layer, the service architecture, the HTTP/API surface, security controls, operations, and the internal structure of the codebase.
Note
Ghost is a pnpm + Nx monorepo targeting Node.js
^22.13.1, with the package manager pinned topnpm@11.4.0. The backend is a Node/Express application; the admin and reader widgets are a mix of Ember.js and React apps. All facts in this guide are derived from the repository at commitf283e8e.
Table of Contents
- Executive Summary
- Repository & Monorepo Structure
- Architecture Overview
- The Boot Sequence
- Configuration System
- Configuration Reference
- Data Layer
- Service Catalogue
- HTTP & API Surface
- Adapters & Extensibility
- Security
- Email & Newsletter Pipeline
- Analytics Pipeline (Tinybird)
- Deployment
- Operations
- Development Workflow
- Testing Strategy
- Code Patterns & Conventions
- Potential Improvements
- Quick Reference
1. Executive Summary
Ghost is a monolithic-but-modular Node.js application that serves three logical surfaces from one process:
- A frontend (the public website) — Handlebars themes, dynamic routing, RSS, sitemaps, members content gating.
- A backend (admin + API) — the management UI and the Content/Admin REST APIs.
- A set of background services — email sending and analytics, scheduled publishing, mentions/webmentions, milestones, update checks, and federation (ActivityPub).
The application is built around a layered configuration system (nconf), a Knex/Bookshelf data layer over MySQL (or SQLite for development), and a large catalogue of cohesive services that are initialised in a carefully ordered boot sequence. Frontend and reader-facing widgets (Portal, Comments, Search, Signup form, Announcement bar) are independently versioned packages delivered as UMD bundles, served from a CDN in production.
| Attribute | Value |
|---|---|
| Language/runtime | Node.js ^22.13.1, JavaScript + TypeScript |
| Web framework | Express (multiple mounted apps via vhost) |
| Data layer | Knex query builder + Bookshelf models |
| Production database | MySQL 8 (mysql2 client) |
| Dev database | SQLite 3 |
| Templating (themes) | Handlebars (via the theme engine + gscan validation) |
| Payments | Stripe |
| Web analytics | Tinybird |
| Package manager | pnpm 11.4.0 (Nx task runner) |
| Default bind | 127.0.0.1:2368 |
| License | MIT |
2. Repository & Monorepo Structure
The monorepo is organised into a backend core, a family of frontend apps, end-to-end tests, and architecture records.
Ghost/
├── apps/ # Frontend applications (React/Preact/Ember-adjacent)
│ ├── admin/ # New React admin shell + Ember bridge
│ ├── admin-x-settings/ # React settings app
│ ├── admin-x-framework/ # Shared admin app framework (API hooks, routing, test utils)
│ ├── admin-x-design-system/ # Shared admin design-system components
│ ├── shade/ # Newer shared component/design library (Radix + Tailwind)
│ ├── portal/ # Member signup/account widget (UMD)
│ ├── comments-ui/ # Comments widget (UMD, Tiptap editor)
│ ├── sodo-search/ # Search overlay (UMD, flexsearch)
│ ├── signup-form/ # Embeddable signup form (UMD)
│ ├── announcement-bar/ # Announcement bar widget (UMD)
│ ├── admin-toolbar/ # Front-of-site admin toolbar (UMD, Preact)
│ ├── posts/ # React posts/members/analytics app
│ ├── stats/ # React stats app (Tinybird charts)
│ └── activitypub/ # ActivityPub/federation UI
├── ghost/
│ ├── core/ # The Ghost backend application (the heart)
│ │ ├── index.js # Process entry point
│ │ └── core/
│ │ ├── boot.js # The boot sequence
│ │ ├── app.js # Root Express app (maintenance/site-id middleware)
│ │ ├── bridge.js # Server → frontend communication
│ │ ├── cli/ # Internal dev CLI (generate-data, repl, timetravel)
│ │ ├── shared/ # config, settings cache, events, labs
│ │ ├── server/ # services, models, data, web, adapters, api
│ │ └── frontend/ # theme engine, routing, rendering, helpers
│ ├── admin/ # Legacy Ember.js admin application
│ └── i18n/ # Internationalisation strings & loader
├── e2e/ # Playwright end-to-end suite (own infra harness)
├── adr/ # Architecture Decision Records
└── docker/ # Dev container & service definitions (minio, stripe, analytics, …)
Note
The scope
@tryghostnamespaces internal packages. Within the monorepo, packages reference each other viaworkspace:*, and many third-party dependency versions are centralised through pnpm catalog entries ("catalog:").
Backend core sub-structure (ghost/core/core)
| Path | Responsibility |
|---|---|
shared/config/ |
nconf-based configuration loader, defaults, overrides, helpers |
shared/settings-cache/ |
In-memory cache of editable settings |
shared/labs.js |
Feature flags ("labs") |
server/boot.js (via core/boot.js) |
Ordered initialisation of everything |
server/models/ |
Bookshelf models (72 files in the models module alone) |
server/data/ |
DB connection, schema, migrations, importer/exporter, seeders, Tinybird datafiles |
server/services/ |
The service catalogue (members, stripe, email, themes, …) |
server/web/ |
Express apps: parent, admin, api, members, comments, webmentions |
server/api/endpoints/ |
API controllers, serializers, validators |
server/adapters/ |
Pluggable storage, cache, scheduling, SSO, redirects |
frontend/ |
Theme engine, dynamic routing, rendering, RSS, sitemap, helpers |
3. Architecture Overview
High-level request topology
┌─────────────────────────────────────────┐
│ Root Express app │
│ (maintenance + site-id middleware) │
└───────────────────┬─────────────────────┘
│ mounted at config subdir
┌───────────────────▼─────────────────────┐
│ Parent app (vhost) │
┌──────────┴───────────┐ ┌─────────────┴──────────┐
│ Backend mount │ │ Frontend mount │
│ (admin URL host) │ │ (all other hosts) │
├──────────────────────┤ ├────────────────────────┤
│ Admin app (/ghost) │ │ Site (themes/render) │
│ Admin API │ │ Members (portal/auth) │
│ Content API │ │ Comments, Webmentions │
└──────────┬───────────┘ │ RSS, Sitemap, Search │
│ └────────────┬───────────┘
└──────────────┬─────────────────────────┘
▼
Services layer (members, stripe, email, themes, url, …)
▼
Data layer (Bookshelf models → Knex → MySQL/SQLite)
The root app starts immediately in maintenance mode and serves a 503 page until the full Ghost app is mounted (after the database and services are ready). A vhost split routes requests to the backend (admin + API) when a separate admin host is configured, and to the frontend (public site + members) otherwise.
Frontend vs. backend hosting
Ghost supports serving the admin from a separate hostname than the public site. The config helpers compute mount paths:
- If an admin URL with a different host is configured, the backend serves only on that host and the frontend serves on everything else.
- Otherwise both serve on all hosts.
This is what enables setups like admin.example.com (backend) + example.com (frontend).
Independently versioned reader widgets
Portal, Comments, Search, Signup form, Announcement bar, and the Admin toolbar are separate UMD packages with their own semantic versions, loaded from cdn.jsdelivr.net in production. Ghost's config records the CDN URL and pinned version line for each, so they can be upgraded independently of the core app.
Important
This decoupling is a core architectural principle: the application, the theme, and the widgets version and deploy independently. Upgrading Ghost does not force a theme or widget rewrite, and vice versa.
4. The Boot Sequence
Boot lives in ghost/core/core/boot.js and is deliberately written as one linear, heavily instrumented async function so timings are visible with DEBUG=ghost:boot* node ghost. Understanding it is the key to diagnosing startup problems.
Step 0 Load version, config, logging (fundamentals; nothing works without these)
Step 1 Load Sentry + Prometheus client (early observability)
Step 2 Start GhostServer with minimal app (maintenance mode 503s until ready)
Step 3 Initialise database (DatabaseStateManager.makeReady → migrations)
Step 4 initCore (url-utils, limits, settings, i18n, URL service, jobs)
initServicesForFrontend (route settings, redirects, link redirects, themes, offers)
initFrontend (frontend helpers)
initExpressApps (mount backend + frontend via vhost)
initDynamicRouting (routes.yaml → router manager; bridge.init)
initAppService (internal frontend apps, e.g. private-blogging)
initServices (the big service fan-out — see below)
Step 5 Mount full app, disable maintenance (rootApp.disable('maintenance'))
Step 6 notifyServerReady ("booted" metric)
Step 7 initBackgroundServices (fire-and-forget recurring jobs)
initCore
Loads, in order: URL utils → limits service (booted before settings so limits are available for calculated settings) → settings service (and email-settings sync) → i18n → URL service (precomputes resource URLs unless running in lazy mode) → job service and mentions job service (background workers, with test-mode variants). Cleanup tasks are registered with the server for graceful shutdown.
initServices — the service fan-out
A large Promise.all initialises the bulk of Ghost's services concurrently after a few ordered prerequisites (emailAddressService.init(), the scheduler adapter, and stripe.init() run first). The concurrently-initialised set includes:
identity-tokens, member-attribution, mentions, staff, members, tiers,
tags-public, posts-public, members-events, permissions, indexnow, slack,
audience-feedback, email-service, email-analytics, webhooks, comments,
link-tracking, email-suppression-list, slack-notifications, media-inliner,
donations, recommendations, stats, explore-ping, gifts, automations
After services are up, if the scheduler adapter supports it, all scheduled posts are rescheduled (postScheduling.rescheduleAll()).
initBackgroundServices
Fire-and-forget (boot does not await it): loads inactive themes, then — unless under test — starts ActivityPub, schedules email-analytics recurring jobs (gated by backgroundJobs.emailAnalytics), the update-check job, the milestones service, and the (deprecated) outbox service.
Tip
When boot is slow, set
DEBUG=ghost:boot*to see per-step timings. Thetheme-service-initandboot-timemetrics are emitted explicitly; the URL service building its cache is a common contributor to first-boot latency on large sites.
Note
The
GHOST_CI_SHUTDOWN_AFTER_BOOTenvironment variable makes the process exit immediately after a successful boot — used in CI to verify the app boots cleanly without keeping it running.
The bridge
bridge.js is the sanctioned place for server→frontend communication. It listens for setting changes (settings.locale.edited, settings.labs.edited, settings.timezone.edited) and reacts — re-initialising theme i18n, rebuilding card/admin-auth assets on theme activation, and triggering frontend reloads. Data flows only server → frontend.
5. Configuration System
Ghost configuration is built on nconf and assembled in ghost/core/core/shared/config/loader.js. Values are resolved from multiple sources with a strict precedence (highest wins):
1. overrides.json (baked-in; nothing can override these)
2. command-line arguments (nconf.argv)
3. environment variables (separator "__", values parsed)
4. config.{env}.json (custom env file in CWD)
5. config.development.docker.json (only when GHOST_DEV_IS_DOCKER=true)
6. config.local.json / config.local.jsonc (skipped in testing envs)
7. config.{env}.json (default env file in config dir)
8. defaults.json (the baseline — applied last)
Where {env} is process.env.NODE_ENV (default development).
Environment-variable mapping
Nested keys map to environment variables with a double-underscore separator. For example:
database__client=mysql2
database__connection__host=127.0.0.1
database__connection__user=ghost
database__connection__password=secret
database__connection__database=ghost_prod
server__port=2368
url=https://example.com
logging__level=info
Important
Values from
nconf.env({separator: '__', parseValues: true})are parsed — so"true"/"false"/numbers become real booleans/numbers. This is the recommended way to inject secrets and host-specific settings in containerised deployments.
Startup sanitisation & validation
After loading, the loader performs important fix-ups and guards:
- Relative → absolute paths. All
paths.*(and sqlitedatabase.connection.filename) are normalised to absolute paths relative to the core directory. - Database client normalisation. A
database.clientofmysqlis rewritten tomysql2. Irrelevant connection keys are stripped depending on client (e.g.filenameremoved for MySQL; host/user/password/database removed for SQLite). - URL protocol check. The
urlmust begin withhttp://orhttps://, or boot throws: "URL in config must be provided with protocol…". - Content path existence.
paths.contentPathmust exist, or boot throws: "Your content path does not exist!…".
Config helpers
The loader binds helper methods onto the nconf instance, including:
getContentPath(type)— resolvesimages/media/files/themes/adapters/logs/data/settings/publicsub-paths undercontentPath.isPrivacyDisabled(flag)— evaluates theprivacyblock (including theuseTinfoil"disable everything" mode with per-feature opt-ins).getBackendMountPath()/getFrontendMountPath()— compute vhost mount paths from the configured site/admin URLs.
Tip
Prefer a
config.production.json(or environment variables) for production settings.config.local.json/.jsoncis for developer overrides and is skipped in testing environments. JSONC (config.local.jsonc) is supported, so you can comment your local config.
6. Configuration Reference
The following are the shipped defaults from shared/config/defaults.json and key baked-in overrides.json values. Override any of them via the layered config sources above. Values are exact as found in source.
Server, URL & admin
| Key | Default | Notes |
|---|---|---|
url |
http://localhost:2368 |
Public site URL; must include protocol |
server.host |
127.0.0.1 |
Bind address |
server.port |
2368 |
Bind port |
server.shutdownTimeout |
60000 |
Graceful shutdown timeout (ms) |
admin.redirects |
true |
Redirect to canonical admin URL |
adminFrameProtection |
true |
Prevents admin being framed |
maintenance.enabled |
false |
Force maintenance mode (overrides) |
Database (set per-install; not in defaults)
Configured via database.client (mysql2 or sqlite3) and database.connection. Production uses MySQL 8; development can use SQLite. The loader normalises mysql → mysql2 automatically.
Storage, scheduling & adapters
| Key | Default | Notes |
|---|---|---|
storage.active |
LocalImagesStorage |
Image storage adapter |
storage.media |
LocalMediaStorage |
Media storage adapter |
storage.files |
LocalFilesStorage |
File storage adapter |
scheduling.active |
scheduling-default |
In-process JS scheduler |
adapters.cache.active |
MemoryCache |
Cache adapter |
adapters.sso.active |
DefaultSSOAdapter |
SSO adapter |
adapters.redirects.active |
FileStore |
Redirects store (FileStore or S3RedirectsStore) |
Logging
| Key | Default |
|---|---|
logging.level |
info |
logging.transports |
["stdout"] |
logging.rotation.enabled |
false |
logging.rotation.period |
1d |
logging.rotation.count |
10 |
logging.useLocalTime |
false |
Caching (selected maxAge, seconds)
| Resource | maxAge |
|---|---|
301 redirects |
31536000 (1 year) |
| Public assets | 31536000 |
| Admin assets | 31536000 |
| Theme | 31536000 |
| Favicon | 86400 |
| Sitemap | 3600 |
llms |
3600 |
robotstxt |
3600 |
| Content API | 0 |
| Frontend | 0 |
Members, payments & email
| Key | Default | Meaning |
|---|---|---|
sendWelcomeEmail |
true |
Email new members on signup |
stripeDirect |
false |
Use direct Stripe keys vs. Connect |
enableStripePromoCodes |
false |
Allow promo codes at checkout |
enableTipsAndDonations |
true |
One-time tips/donations |
bulkEmail.batchSize |
1000 |
Newsletter recipients per batch |
emailAnalytics.enabled |
true |
Open/click analytics |
emailAnalytics.batchProcessing |
false |
Batch analytics processing |
emailAnalytics.openedJobLagWarningMinutes |
30 |
Warn if analytics lag exceeds |
backgroundJobs.emailAnalytics |
true |
Schedule email-analytics jobs on boot |
backgroundJobs.clickTrackingLastSeenAtUpdater |
true |
Update member last-seen on click |
linkClickTrackingCacheMemberUuid |
false |
Cache member UUID for click tracking |
Image optimization (overrides)
| Key | Default |
|---|---|
imageOptimization.resize |
true |
imageOptimization.srcsets |
true |
imageOptimization.defaultMaxWidth |
2000 |
| Content image sizes | 600 / 1000 / 1600 / 2400 px |
Internal image sizes are defined for icons, signup-form icons, and several email image formats (header, latest-posts thumbnails, social, CTA).
Theme upload limits
| Key | Default (bytes) | Human |
|---|---|---|
theme.uploadLimits.compressedBytes |
1073741824 |
1 GB |
theme.uploadLimits.entryUncompressedBytes |
536870912 |
512 MB |
theme.uploadLimits.totalUncompressedBytes |
4294967296 |
4 GB |
Optimization & limits
| Key | Default |
|---|---|
optimization.maxLimit |
100 (max API page size) |
optimization.allowLimitAll |
false |
optimization.getHelper.timeout.threshold |
5000 ms |
optimization.themes.skipBootChecks |
false |
Security defaults
| Key | Default |
|---|---|
security.allowWebhookInternalIPs |
false |
security.staffDeviceVerification |
true |
privacy |
false (privacy features off by default) |
Update check
| Key | Default |
|---|---|
updateCheck.url |
https://updates.ghost.org |
updateCheck.forceUpdate |
false |
Widget CDN versions (defaults)
| Widget | URL template version |
|---|---|
portal.version |
2.68 |
sodoSearch.version |
1.8 |
announcementBar.version |
1.1 |
comments.version |
1.5 |
signupForm.version |
0.3 |
adminToolbar.version |
0.1 |
Baked-in overrides (cannot be changed by user config)
| Key | Value |
|---|---|
paths.corePath |
core/ |
paths.migrationPath |
core/server/data/migrations |
apps.internal |
["private-blogging"] |
slugs.protected |
["ghost", "rss", "amp"] |
uploads.members.extensions |
[".csv"] |
uploads.themes.extensions |
[".zip"] |
times.publishAPostBySchedulerToleranceInMinutes |
5 |
times.cannotScheduleAPostBeforeInMinutes |
-2 |
7. Data Layer
Knex + Bookshelf
Ghost uses Knex as its query builder and connection pool, and Bookshelf as its model/ORM layer on top. The server/data/db/connection module exposes the configured Knex instance; CLI tools and the data generator obtain it directly.
The models module (server/models/) is large — the single highest-priority models directory contains 72 model files — covering posts, pages, users, roles, permissions, members, subscriptions, tiers, newsletters, tags, labels, redirects, webhooks, settings, and more. A base/ model with plugins provides shared behaviour (events, serialisation, filtering).
Note
The
NUMERIC_IDSenvironment variable (referenced inmodels/base/plugins/events.js) toggles numeric-vs-ObjectId behaviour in the events plugin. Most installs never touch it.
Schema & migrations
- Schema is defined in
server/data/schema/with default settings underdefault-settings/. The schema enumerates every table and column (includingdateTimecolumns thetimetravelCLI shifts). - Migrations live in
server/data/migrations/versions/{x.y}/— hundreds of versioned migration folders spanning Ghost 2.x → 6.x. TheDatabaseStateManagerruns them to bring the DB to a ready state during boot Step 3. - Migrations are applied by knex-migrator; the repo exposes
pnpm knex-migrator(filtered to theghostpackage).
Important
Migrations run automatically on boot via
DatabaseStateManager.makeReady(). On production, an upgrade therefore applies pending migrations at startup. Always back up the database before upgrading a major version, and expect first boot after an upgrade to take longer.
Importer / Exporter
- Exporter (
server/data/exporter/) produces the canonical JSON export of content + settings. - Importer (
server/data/importer/) ingests Ghost JSON exports and member CSVs, with dedicated handlers for data and images. Member imports record attribution asImported/Member Importer.
Seeders & data generation
The server/data/seeders/ directory and the generate-data CLI build realistic datasets for development. Quantities and tables are configurable; a --seed makes runs reproducible.
Settings cache
Editable settings (site title, locale, timezone, labs flags, membership config) are cached in memory (shared/settings-cache) and kept fresh via events. Theme i18n, routing, and timezone behaviour react to settings changes through the bridge.
8. Service Catalogue
Services live under server/services/ and are the unit of feature cohesion. The table below maps the major services (initialised in the boot fan-out and background phase) to their responsibilities.
| Service | Responsibility |
|---|---|
settings |
Editable key/value settings, calculated settings, email-settings sync |
members |
Member accounts, signin (magic links), membership state |
members-api (within members) |
Repositories, controllers, services for the member domain |
stripe |
Stripe API wrapper + webhook registration/handling |
tiers |
Subscription products (free/paid tiers) |
offers |
Discount offers on tiers |
donations / gifts |
One-time tips and gift subscriptions |
email-service |
Renders posts to email, batches & sends newsletters |
email-analytics |
Ingests opens/clicks; recurring background jobs |
email-suppression-list |
Bounces/complaints suppression |
email-address |
Sender address validation/management |
newsletters |
Multiple newsletter definitions & subscriptions |
member-attribution |
Tracks signup source/referrer/UTM (Last Post Algorithm) |
members-events |
Member domain events |
link-tracking / link-redirection |
Click-tracking redirects (/r/{hash}) and analytics |
comments |
Member commenting |
mentions / mentions-jobs |
Webmentions (send/receive) and their jobs |
recommendations |
Site-to-site recommendations |
stats |
Member/MRR/email statistics aggregation |
tinybird |
Web-analytics integration |
themes |
Theme load/validate (gscan)/activate, inactive-theme loading |
route-settings |
routes.yaml parsing and reload |
custom-redirects |
User-defined URL redirects |
url |
Canonical URL computation for every resource (eager or lazy) |
permissions |
Role/permission model and checks |
staff |
Staff (user) lifecycle, invites, emails |
auth |
Sessions, API keys, member auth, SSO |
identity-tokens |
Signed identity tokens |
webhooks |
Outbound webhook registration & delivery |
slack / slack-notifications |
Slack publish notifications |
automations |
Event-driven automations (with scheduler + internal keys) |
post-scheduling |
Scheduled publish via the scheduling adapter |
media-inliner |
Inlines external media into content |
milestones |
Member/ARR milestone detection & notification |
update-check |
Phones updates.ghost.org for available updates |
explore / explore-ping |
Ghost Explore directory integration |
activitypub |
Fediverse federation (background) |
audience-feedback |
Post sentiment/feedback |
outbox |
Deprecated — slated for removal (see source TODO NY-1220) |
i18n |
Internationalisation |
limits |
Plan/host limits (booted before settings) |
jobs / mentions-jobs |
Background job queues (with test-mode variants) |
Note
The Stripe service wraps the Stripe API and a webhook manager/controller. It is designed for idempotent, order-independent webhook handling because Stripe events can arrive out of order or in parallel (
customer.subscription.*,invoice.payment_succeeded,checkout.session.completed).
9. HTTP & API Surface
Express app composition
The web layer (server/web/) is composed of multiple Express apps mounted under a parent app:
- parent/backend — admin UI + Admin API + Content API (served on the admin host).
- parent/frontend — the public site, members endpoints, comments, webmentions, gift previews, announcement.
- shared middleware — request IDs, locals, API middleware, brute-force protection, caching.
The root app (app.js) adds Sentry request/tracing handlers, an optional site-id guard (used by Ghost(Pro) to prevent cross-site request bleed via the x-site-id header vs. hostSettings.siteId), and the maintenance middleware.
The two REST APIs
| API | Base path | Auth | Audience |
|---|---|---|---|
| Content API | /ghost/api/content/ |
Content API key (read-only) | Public consumers, headless frontends |
| Admin API | /ghost/api/admin/ |
JWT (Admin API key) or staff session cookie | Tooling, automation, admin UI |
API endpoints are implemented in server/api/endpoints/ with a clean separation of controllers, input/output serializers, and input/output validators (endpoints/utils/serializers/{input,output}, endpoints/utils/validators/{input,output}). API versioning compatibility is handled by the api-version-compatibility service.
Note
AST-based extraction over this JS/TS monorepo surfaces few endpoints automatically because routes are assembled dynamically through Express routers and middleware factories rather than static decorators. The authoritative endpoint list is the Ghost API documentation; the structure (controllers → serializers → validators) is consistent across every resource.
Frontend routing
The public site uses dynamic routing driven by routes.yaml (parsed by route-settings, applied by the routing service's routerManager). This controls collections, channels, taxonomies, and URL patterns. The URL service precomputes canonical URLs for every resource (unless lazyRouting is enabled).
10. Adapters & Extensibility
Ghost's adapter pattern lets operators replace default subsystems without forking. Adapters are configured under adapters.* / storage.* / scheduling.* and discovered from content/adapters/{type}/.
| Adapter type | Default | Replace for |
|---|---|---|
| Storage (images/media/files) | LocalImagesStorage / LocalMediaStorage / LocalFilesStorage |
S3/GCS/object storage |
| Cache | MemoryCache |
Redis-backed cache |
| Scheduling | scheduling-default (in-process JS) |
External scheduler |
| SSO | DefaultSSOAdapter |
Enterprise SSO |
| Redirects store | FileStore |
S3RedirectsStore |
Tip
To override a default adapter, add a folder under
content/adapters/storageorcontent/adapters/schedulingand place your adapter there, then point the relevant*.activeconfig key at it. The defaultLocalFileStoragewrites tocontent/images; the default scheduler is pure JavaScript and needs no externalcron.
Feature flags (labs)
shared/labs.js exposes feature flags toggled in Settings → Labs. Some flags participate in environment identity for tests (e.g. themeTranslation, which the bridge watches to re-init theme i18n).
Internal frontend apps
apps.internal is fixed to ["private-blogging"] — Ghost's built-in app that password-protects an entire site. App routing is wired during initAppService.
11. Security
Authentication surfaces
| Surface | Mechanism |
|---|---|
| Staff (admin) | Session cookies; new-device email verification |
| Admin API | JWT signed with an Admin API key |
| Content API | Read-only Content API key |
| Members | Passwordless magic-link sign-in; member sessions |
| SSO | Pluggable SSO adapter |
Brute-force / spam protection
Ghost ships extensive rate-limiting (spam config block) keyed by action. Each entry defines freeRetries, minWait, maxWait, and lifetime. Selected defaults:
| Action | freeRetries | minWait | maxWait |
|---|---|---|---|
user_login |
4 | 10 min | 7 days |
user_reset |
4 | 1 hour | 1 hour |
member_login |
8 | 10 min | 12 hours |
content_api_key |
99 | 1 hour | 24 hours |
global_block / private_block |
99 | 1 hour | 1 hour |
send_verification_code |
4 | 1 hour | 1 hour |
otc_verification |
4 | 1 hour | 1 hour |
Important
security.staffDeviceVerificationis on by default — staff signing in from a new device may be challenged via email. Keep it enabled in production.
Webhook SSRF protection
security.allowWebhookInternalIPs defaults to false, blocking webhooks from targeting internal/private IP ranges — an SSRF mitigation. Only enable it in trusted, isolated networks.
Frame & site protections
adminFrameProtection(defaulttrue) blocks the admin from being embedded in frames.- The optional site-id middleware rejects requests whose
x-site-idheader doesn't matchhostSettings.siteId, preventing one site's requests from being served by another (multi-tenant safety).
Upload validation
Every upload type has an allow-list of extensions and content types (overrides.json → uploads). Images, media, files, themes (.zip only), members (.csv only), redirects, and routes each have explicit, restrictive lists. This is the first line of defence against malicious upload content.
Secrets & transport
- Database credentials, Stripe keys, and mail credentials are supplied via config/env and should be injected as environment variables in production.
- The
urlmust be HTTPS in production (enforced to include a protocol; usehttps://). - Sentry DSN and Prometheus are optional and config-gated.
Caution
Never commit
config.production.jsonwith live secrets to source control. PreferKEY__SUBKEYenvironment variables (double-underscore separator) so secrets stay out of files.
12. Email & Newsletter Pipeline
Two email channels
| Channel | Used for | Notes |
|---|---|---|
| Transactional | Magic links, staff invites, welcome emails | Configured mail transport (e.g. SMTP) |
| Bulk | Newsletters | High-volume provider (commonly Mailgun); batched |
Send flow
- A post is published with Publish & email or Email only.
email-servicerenders the post into email-safe HTML (the "EmailRenderer").- Recipients are resolved from the newsletter's subscriber list and segmented.
- Recipients are processed in batches of
bulkEmail.batchSize(1,000 by default). - When click tracking is on, links are rewritten to
/{site}/r/{hash}?m={member_uuid}.
Click & open analytics
The link-redirection + link-tracking services implement the redirect flow:
Member clicks /r/{hash}?m={uuid}
→ LinkRedirectsService looks up the redirect, emits RedirectEvent, responds 302
→ LinkClickTrackingService records a members_click_events row
→ emits MemberLinkClickEvent → LastSeenAtUpdater updates member.last_seen_at (once/day)
→ member.edited webhook fires with labels + newsletters included
email-analytics ingests opens/clicks (recurring jobs scheduled on boot when backgroundJobs.emailAnalytics is true) and surfaces them in stats. email-suppression-list handles bounces/complaints.
Note
A "golden post" fixture (
test/utils/fixtures/email-service/golden-post.json) contains one of every editor card and is used in tests to catch email-rendering regressions across clients (especially Outlook). When a new card type is added, the golden post must be updated.
13. Analytics Pipeline (Tinybird)
Ghost's web analytics are implemented on Tinybird Forward. The datafiles live in server/data/tinybird/ (datasources, pipes, endpoints, fixtures, scripts).
Session model
Hits are aggregated into sessions. The pipeline distinguishes:
- Session-level attributes (
source,utm_source/medium/campaign/term/content) — captured from the first hit viaargMinState(field, timestamp)in the_mv_session_datamaterialised view (anAggregatingMergeTree). Constant for the whole session. - Hit-level attributes (
pathname,post_uuid,member_status) — can vary across pageviews in a session.
Filtering architecture
filtered_sessions.pipe implements a multi-stage filter:
- Query filters (hit-level) — sessions where any hit matches
pathname/post_uuid/member_status. - Session-attribute filters (session-level) —
-Mergecombinators (e.g.argMinMerge(source)) over_mv_session_dataforsource/utm_*. - Final output — session IDs matching all criteria.
Endpoints join _mv_hits with filtered_sessions, returning all hits from matching sessions — answering "what pages did users visit when they arrived from X?" rather than "which hits had X in the URL?".
Running analytics in development
# Recommended: Docker
docker compose --profile analytics up -d # start Tinybird local + analytics service
docker compose --profile split up # start Ghost wired to local analytics
# Or the pnpm flow
pnpm dev:analytics
pnpm data:analytics:generate 5000 # seed 5,000 analytics events
pnpm data:analytics:clear
Configuration is supplied under a tinybird config block (workspace ID, admin token, tracker endpoint, stats endpoint, optional local override).
Note
The Docker analytics manager connects to MySQL at
localhost:3306(overridable viaMYSQL_HOST/PORT/USER/PASSWORD/DATABASE) and reads Tinybird tokens from the Docker volume (overridable viaTINYBIRD_ADMIN_TOKEN,TINYBIRD_TRACKER_TOKEN,TINYBIRD_HOST).
14. Deployment
Production install (Ghost-CLI)
Self-hosted production deployments are managed by Ghost-CLI (a separate package), not this repository's tooling:
npm install ghost-cli@latest -g
mkdir /var/www/ghost && cd /var/www/ghost
ghost install # provisions app, MySQL, systemd, nginx, SSL
ghost start
Ghost-CLI configures a MySQL database, a systemd service, an nginx reverse proxy, and Let's Encrypt SSL. Lifecycle: ghost start|stop|restart|ls|update|doctor.
Production requirements
- [ ] Node.js
^22.13.1 - [ ] MySQL 8 with a dedicated database & user
- [ ] A reverse proxy terminating TLS (Ghost binds
127.0.0.1:2368by default) - [ ] Persistent
content/directory (images, media, files, themes, logs) - [ ] SMTP transport for transactional mail; a bulk provider (e.g. Mailgun) for newsletters
- [ ] Outbound access to
updates.ghost.org(update check) and your CDN for widgets - [ ] Optional: Stripe account, Tinybird workspace, Redis cache adapter, object storage adapter
Container deployment notes
- Inject config via
KEY__SUBKEYenvironment variables (double-underscore separator, values parsed). - Mount a persistent volume for
content/. - Ensure
urlis the public HTTPS address andpaths.contentPathexists in the image/volume. - The dev
compose.dev.yamlprofiles (analytics,split, storage, stripe) are for development, not production.
Upgrades
# Ghost-CLI install
ghost update # pulls latest, runs migrations on restart
# Development repo
pnpm main # checkout latest main + reinstall
Warning
Database migrations run automatically on boot (
DatabaseStateManager.makeReady()). For major-version upgrades: back up the database andcontent/first, expect a longer first boot (migrations + URL cache rebuild), and verify boot completes (watch for thebootedlog line) before sending traffic.
Tip
A blue/green or maintenance-window upgrade is safest. The root app serves a 503 maintenance page until the full app mounts, so brief unavailability during boot is expected and graceful.
15. Operations
Health, readiness & maintenance
- The server starts in maintenance mode and returns 503 until services are ready, then disables maintenance.
maintenance.enabledin config forces maintenance mode for planned downtime.- The URL service finishing its cache build gates readiness on large sites.
Logging
- Logs go to
stdoutby default atinfolevel; production file logs land incontent/logs/. - Optional rotation (
logging.rotation) — disabled by default,1dperiod, keep10. - Set
DEBUG=ghost:boot*for boot timings;DEBUG=ghost:*for verbose internals.
Background jobs
| Job | Gate | Cadence |
|---|---|---|
| Email analytics | backgroundJobs.emailAnalytics |
Recurring (scheduled on boot) |
| Click-tracking last-seen updater | backgroundJobs.clickTrackingLastSeenAtUpdater |
On click events |
| Update check | always (non-test) | Recurring |
| Milestones | always (non-test) | On boot + run |
| ActivityPub | always (non-test) | On boot |
| Mentions | mentions-jobs | Recurring |
| Post scheduling | scheduler adapter | At scheduled times |
Note
Background services are skipped under test (
NODE_ENVstarting withtest) and have test-mode variants (jobService.initTestMode()), so they don't interfere with the suite.
Backups & DR
A complete backup = database + content/ directory. For SQLite installs the database file lives in content/data/ (do not overwrite it manually). For MySQL installs, back up the MySQL server and the content/ volume separately.
Observability hooks
- Sentry — request handler, tracing handler (config-gated), and query tracing wired in boot.
- Prometheus —
prometheus.enabledstarts the client early and instruments Knex/connection pool. - Metrics — boot emits
theme-service-initandboot-timemetrics.
Scaling
- The
bulkEmail.batchSize(1,000) and email-analytics batching tune newsletter throughput. - The
optimization.maxLimit(100) caps API page sizes;allowLimitAllis off by default. - Swap
MemoryCachefor a Redis cache adapter and local storage for object storage to scale horizontally. - The repo ships a 2M-member data generator (
pnpm reset:data:xxl) for load testing.
16. Development Workflow
Initial setup
git clone --recurse-submodules https://github.com/TryGhost/Ghost.git
cd Ghost
corepack enable pnpm
pnpm run setup # install deps + init submodules
pnpm dev # Docker backend + frontend dev servers
Everyday commands
| Command | Purpose |
|---|---|
pnpm dev |
Full dev stack (Nx orchestrated) |
pnpm dev:sqlite |
Dev with SQLite |
pnpm dev:stripe |
Dev with local Stripe listener |
pnpm dev:analytics |
Dev with Tinybird analytics |
pnpm build |
Build all packages (nx run-many -t build) |
pnpm lint |
Lint all packages |
pnpm test |
Run tests (excludes e2e + admin) |
pnpm test:unit |
Unit tests across packages |
pnpm knip |
Dead-code / unused-dependency analysis |
pnpm fix |
Prune + reinstall to fix dependency issues |
pnpm main |
Update to latest main (+ submodules) |
pnpm knex-migrator |
Run DB migrations (ghost package) |
Internal CLI (node index.js)
| Command | Purpose | Permitted environments |
|---|---|---|
generate-data |
Seed members/posts/etc. | all (development, local, staging, production) |
repl |
Interactive shell with knex/models |
development, local |
timetravel |
Shift all DB dates to present | (destructive; testing only) |
Caution
repl(and most internal commands) refuse to run whenNODE_ENV=production— they checkpermittedEnvironments()and abort with "Command … is not permitted in {env}".generate-datais the exception (permitted everywhere) but is destructive with--clear-database.
Nx & dependency graph
Nx orchestrates builds/tests with explicit dependsOn chains (e.g. apps depend on ^build of shared libraries like @tryghost/shade, admin-x-design-system, admin-x-framework). The package manager is enforced via a preinstall hook (enforce-package-manager.js).
17. Testing Strategy
Ghost has an extensive, multi-tier test suite (the briefing is dominated by test directories — a strong signal of test investment).
| Tier | Location | Tooling |
|---|---|---|
| Unit | ghost/core/test/unit/, per-app test/unit/ |
Mocha (core), Vitest (apps) |
| Integration | ghost/core/test/integration/ |
Mocha + real DB |
| E2E API | ghost/core/test/e2e-api/, e2e-server/, e2e-webhooks/ |
Mocha + booted Ghost |
| E2E browser | e2e/ |
Playwright (own Docker infra harness) |
| Legacy | ghost/core/test/legacy/ |
Older Mocha specs |
| Visual regression | e2e/visual-regression/ |
Playwright snapshots |
App testing utilities
@tryghost/admin-x-framework provides a shared Vitest config factory, MSW (Mock Service Worker) server with realistic Ghost API fixtures, renderWithProviders/renderHookWithProviders, test data factories (post(), member(), user(), newsletter()), timer mocking, and console filtering. Apps are encouraged to use @src/@test path aliases and the centralised config.
E2E harness
The Playwright suite (e2e/) boots real Ghost against MySQL/Redis/Mailpit/Tinybird via Docker. It supports dev mode (mounts source, proxies assets to host dev servers) and build mode (prebuilt image). Isolation is configurable per file or per test; config/labs participate in per-file environment identity, while stripeEnabled forces per-test isolation. Tests use the Page Object Model and data-testid selectors.
# From e2e/
pnpm infra:up # start MySQL/Redis/Mailpit/Tinybird
pnpm test # run main project
PRESERVE_ENV=true pnpm test # keep containers for debugging
Note
Retry-sensitive tests exist; the suite uses a mocha retry reporter and CI uploads Playwright traces on failure. CI runs unit/integration on every PR and runs the Playwright suite inside the official Playwright container.
18. Code Patterns & Conventions
- Boot-as-narrative.
boot.jsis intentionally one linear file with densedebug()statements so the startup sequence reads top-to-bottom and timings are observable. Don't scatter boot logic. - Service modules with
init(). Most services export an object with an idempotentinit(); the boot fan-outPromise.alls them after ordered prerequisites. - The bridge for cross-layer events. Server→frontend communication is funneled through
bridge.js; it is the sanctioned place to require acrossshared/server/frontend. - Adapter pattern everywhere. Storage, cache, scheduling, SSO, and redirects are all swappable via config +
content/adapters/. - Config helpers bound on nconf. Path resolution, privacy checks, and mount-path computation are methods on the config object, not free functions.
- Serializer/validator separation. API endpoints split input/output serialization and validation into discrete
utils/{serializers,validators}/{input,output}trees. - Centralised dependency versions. pnpm catalogs (
"catalog:") pin shared dependency versions across the monorepo; React 17 vs 18 are tracked via separate catalog channels (catalog:react17). - Filename linting. A custom
eslint-plugin-ghostenforces conventions (e.g.filenames/match-regex^[a-z0-9.-]+$). - Events plugin on models. Bookshelf models emit domain events via a base plugin; many services subscribe rather than calling each other directly (event-driven decoupling).
- TypeScript adoption is incremental. Newer apps are TS-first (strict mode,
erasableSyntaxOnly); the backend core is largely JS with growing.ts/.d.tscoverage.
Tip
When adding a feature to the backend, the idiomatic path is: a model (if it needs persistence) → a service with
init()(wired into the boot fan-out) → API endpoint controllers/serializers/validators → optional webhook/event emission. Mirror an existing service (e.g.recommendations) as a template.
19. Potential Improvements
These observations are drawn from signals visible in the source at this commit. They are suggestions for maintainers and operators, not defects.
- Outbox deprecation. The
outboxservice carries a source TODO (NY-1220) marking it deprecated and slated for removal. Operators should avoid building on it; maintainers can plan its removal. - API route discoverability. Endpoints are assembled dynamically, which makes static analysis (and onboarding) harder. A generated, machine-readable route manifest would aid tooling, docs, and the kind of automated extraction this guide relied on.
- Privacy defaults.
privacyisfalseby default andsecurity.allowWebhookInternalIPsisfalse(good), but operators in regulated environments should review the fullprivacyblock (includinguseTinfoil) and document their chosen posture. - Email-rendering fragility. The "golden post" mechanism exists precisely because email-client rendering (Outlook) is brittle. Teams adding editor cards must keep the golden post current or risk silent newsletter regressions.
- First-boot latency on large sites. The eager URL service cache build dominates first-boot time. Sites with very large content sets should evaluate
lazyRouting(lazyRouting: falseby default) and measure the trade-off. - Config sprawl. The layered config + many feature flags ("labs") give great flexibility but a large surface area; a single generated "effective config" dump (beyond
DEBUG=ghost-config) would help operators audit production settings. - Monorepo build coupling. Heavy
dependsOn: ['^build']chains mean a clean build rebuilds many shared libraries; Nx caching mitigates this, but CI cold-starts are costly. Keep the Nx cache warm.
20. Quick Reference
Key files
| File | Purpose |
|---|---|
ghost/core/index.js |
Process entry point |
ghost/core/core/boot.js |
Boot sequence |
ghost/core/core/app.js |
Root Express app (maintenance/site-id) |
ghost/core/core/bridge.js |
Server→frontend events |
ghost/core/core/shared/config/loader.js |
Config assembly |
ghost/core/core/shared/config/defaults.json |
Default configuration |
ghost/core/core/shared/config/overrides.json |
Immutable overrides |
ghost/core/core/server/data/db/connection |
Knex instance |
ghost/core/core/server/data/migrations/ |
Versioned migrations |
Default network & paths
| Item | Value |
|---|---|
| Bind | 127.0.0.1:2368 |
| Site URL | http://localhost:2368 |
| Admin | /ghost/ |
| Content API | /ghost/api/content/ |
| Admin API | /ghost/api/admin/ |
| Content dir | content/ (images, media, files, themes, adapters, logs, data, settings, public) |
Environment variables (selected, from source)
| Variable | Effect |
|---|---|
NODE_ENV |
Selects config env (default development) |
KEY__SUBKEY |
Override any nested config (double-underscore) |
GHOST_DEV_IS_DOCKER |
Loads the docker dev config layer |
GHOST_CI_SHUTDOWN_AFTER_BOOT |
Exit after successful boot (CI) |
DEBUG=ghost:boot* |
Boot timing diagnostics |
NUMERIC_IDS |
Toggle numeric IDs in the events plugin |
REQUIRE_INFILE_STREAM |
Set by generate-data for streamed imports |
Lifecycle commands
| Goal | Command |
|---|---|
| Install (prod) | ghost install |
| Start / stop (prod) | ghost start / ghost stop |
| Update (prod) | ghost update |
| Diagnose (prod) | ghost doctor |
| Dev environment | pnpm dev |
| Build all | pnpm build |
| Run migrations | pnpm knex-migrator |
| Seed data | node index.js generate-data --quantities members:1000,posts:100 --seed 123 |
| REPL | node index.js repl |
Default adapters
| Type | Default | Alternative |
|---|---|---|
| Image/media/file storage | Local*Storage |
Object storage adapter |
| Cache | MemoryCache |
Redis adapter |
| Scheduling | scheduling-default |
External scheduler |
| Redirects store | FileStore |
S3RedirectsStore |
| SSO | DefaultSSOAdapter |
Enterprise SSO |