Run Ghost 6 on Cloudflare Workers with Cloudflare Containers. This project builds a Ghost image with an S3-compatible storage adapter for R2, and a minimal Worker that proxies all HTTP traffic to the container.
Example production domains used here:
- Public site: https://zero.adamwhite.work
- Single-container Ghost deployment managed by Cloudflare Containers
- Ephemeral disk by design; persistent data lives in external services
- MySQL (e.g., RDS) for the database
- Cloudflare R2 for media via an S3 adapter
- Minimal Worker in TypeScript that forwards requests and sets the correct proxy headers
- Sensible defaults for cold starts (
sleepAfter = 5m) and logs
- Worker (Durable Object) → forwards requests to the Ghost container on port 2368
- Ghost Docker image → based on
ghost:6-alpine, with theghos3S3 adapter baked intocontent/adapters/storage/s3 - MySQL (RDS) → external DB; Ghost connects over TLS
- R2 → S3-compatible storage for media
- Cloudflare account and Wrangler CLI (logged in)
- Docker (for local builds)
- Publicly reachable MySQL 8 instance (RDS is fine)
- Cloudflare R2 bucket + S3 API credentials
- SMTP provider (Mailgun/Postmark/SES/etc.)
- Install dependencies
npm install- Configure domains in
wrangler.jsonc
workers_devis disabled androutesare set to your domains:zero.adamwhite.work/*
varsincludesURLand (optionally)ADMIN_URL.
- Provide secrets (safest for credentials)
# Database
wrangler secret put DB_HOST
wrangler secret put DB_PORT
wrangler secret put DB_USER
wrangler secret put DB_PASSWORD
wrangler secret put DB_NAME
wrangler secret put DB_SSL_REJECT_UNAUTHORIZED # "false" for many hosted DBs
# Optional: provide a CA bundle if your provider requires it
# wrangler secret put DB_SSL_CA
# R2 / S3
wrangler secret put R2_ENDPOINT # e.g. https://<accountid>.r2.cloudflarestorage.com
wrangler secret put R2_BUCKET # e.g. ghost-bucket
wrangler secret put R2_ACCESS_KEY_ID # e.g. obtain through Cloudflare R2 Console
wrangler secret put R2_SECRET_ACCESS_KEY # e.g. obtain through Cloudflare R2 Console
# Optional
# wrangler secret put R2_REGION # default: auto
# wrangler secret put R2_FORCE_PATH_STYLE # default: true
# wrangler secret put R2_ASSET_HOST # optional CDN, e.g. https://r2.yourdomain.com
# wrangler secret put R2_PATH_PREFIX # optional bucket subdir
# SMTP
wrangler secret put SMTP_HOST
wrangler secret put SMTP_PORT # 465 or 587
wrangler secret put SMTP_SECURE # "true" or "false"
wrangler secret put SMTP_USER
wrangler secret put SMTP_PASS
# Optional from address
# wrangler secret put SMTP_FROM- Deploy
npm run deployFirst deployment may take several minutes while the image builds and the container boots.
Ghost configuration is injected via environment variables in src/index.ts using Ghost’s double-underscore syntax (e.g., server__port). Key items:
-
URL
URL(required): canonical site URLADMIN_URL(optional): separate admin domain; admin UI is at/ghost
-
Server
server__host = 0.0.0.0server__port = 2368PORT = 2368is also set to neutralize platforms that inject a differentPORT
-
Database (MySQL)
database__client = mysqldatabase__connection__host = DB_HOSTdatabase__connection__port = DB_PORTdatabase__connection__user = DB_USERdatabase__connection__password = DB_PASSWORDdatabase__connection__database = DB_NAME- TLS:
database__connection__ssl__rejectUnauthorized = DB_SSL_REJECT_UNAUTHORIZED- optionally
database__connection__ssl__ca = DB_SSL_CA
-
Storage (R2 via S3 adapter)
GHOST_STORAGE_ADAPTER_S3_ENDPOINT = R2_ENDPOINTGHOST_STORAGE_ADAPTER_S3_PATH_BUCKET = R2_BUCKETAWS_ACCESS_KEY_ID,AWS_SECRET_ACCESS_KEYAWS_DEFAULT_REGION = R2_REGION(defaultauto)GHOST_STORAGE_ADAPTER_S3_FORCE_PATH_STYLE = R2_FORCE_PATH_STYLE(defaulttrue)- Optional:
GHOST_STORAGE_ADAPTER_S3_ASSET_HOST,GHOST_STORAGE_ADAPTER_S3_PATH_PREFIX,GHOST_STORAGE_ADAPTER_S3_ACL
-
SMTP
mail__transport = SMTPmail__options__host = SMTP_HOSTmail__options__port = SMTP_PORTmail__options__secure = SMTP_SECUREmail__options__auth__user = SMTP_USERmail__options__auth__pass = SMTP_PASS- Optional:
mail__from = SMTP_FROM
npm run dev- The Worker runs locally and forwards to a Cloudflare-managed container instance.
- Default Ghost port is 2368; the Worker proxies all HTTP traffic.
- Admin is available at
/ghoston your domain. - Updates: Update the compatibility_date in wrangler.jsonc to the current date. Check the latest version of package.json for updates. Run
npm outdatedto check for updates. Runnpm installand/ornpm update, thenwrangler typesto update the types. Also update the Dockerfile if there's a newer Ghost version. Minor updates will be auto applied onwrangler deploy.
-
Logs
wrangler tail --format=pretty
-
Scaling and cold starts
sleepAfteris set to 5 minutes insrc/index.ts. Increase if you want fewer cold starts.- Consider a periodic ping (Cron Trigger) if you want to keep the instance warm.
-
MySQL reverse DNS warnings
- RDS may log warnings like "IP address 'x.x.x.x' could not be resolved". Attach a parameter group with
skip_name_resolve = 1and reboot to suppress these.
- RDS may log warnings like "IP address 'x.x.x.x' could not be resolved". Attach a parameter group with
Ghost 6 includes ActivityPub functionality behind a feature flag. No extra container is required.
- Prereqs:
- Public domain, valid HTTPS, correct canonical URL configured
- Ghost admin → Settings (or Labs) → enable ActivityPub
- Ensure
/.well-known/webfingerand related endpoints are reachable
- Verification:
- Test WebFinger and ActivityPub endpoints from an external checker
src/index.ts— Worker that proxies to the container and injects Ghost config via env varswrangler.jsonc— Worker, routes, Durable Object, Containers configDockerfile— Extendsghost:6-alpine, installs and activates the S3 storage adapterconfig.production.json— Minimal base config (local dev defaults); env overrides at runtime
- The project uses a single container instance (
max_instances: 1) to avoid duplicate scheduled jobs. - Disk is ephemeral; media must go to R2 (already configured by this repo).
- On container restart, the visitors see the "We’ll be right back." page. It does not auto redirect to the site. Regardless of your 'sleep after' setting, the container will eventually restart. Need to further test if caching helps.
- Despite disabling caching on the admin site, the post editor has problems saving the post.
- ActivityPub is showing "Loading interrupted" page.
- Analytics isn't enabled.
- SMTP configuration needs to be tested.