Docker Compose Reference: Ghost CMS
Docker Compose reference: Ghost CMS
TL;DR
- Bottom line: Ghost CMS runs in production with a three-service Docker Compose stack -- Ghost (Node.js app on port 2368), MySQL 8 or MariaDB for persistence, and Caddy or Nginx as a TLS-terminating reverse proxy.
- Key tool/command:
docker compose up -dwith environment variables forurl,database__client,database__connection__host, andmail__transport. - Watch out for: The
urlenv var must exactly match your public domain withhttps://-- Ghost generates all links from this value, and a mismatch causes redirect loops or broken assets. - Works with: Ghost 5.x/6.x, Docker Engine 20.10+, Docker Compose v2, MySQL 8.0+, MariaDB 10.5+, Node 18/20 (inside the container).
Constraints
- Ghost requires MySQL 8.0+ or MariaDB 10.5+ for production -- SQLite is development only
- The
urlenvironment variable MUST match your public domain includinghttps://-- Ghost uses this for all internal links and redirects DATABASE_xxxvariables must NOT be changed after initial database creation -- doing so breaks the installation- Ghost listens on port 2368 internally -- never expose this directly; always use a reverse proxy (Caddy/Nginx) for TLS termination
- Pin Ghost Docker image to a specific version tag (e.g.,
ghost:5.113.2) -- never use:latestin production - Ghost content volume (
/var/lib/ghost/content) MUST be persisted -- it contains images, themes, and uploaded files
Quick Reference
Service Configuration Summary
| Service | Image | Ports | Volumes | Key Env Vars |
|---|---|---|---|---|
| Ghost | ghost:5.113.2 | 2368 (internal) | /var/lib/ghost/content | url, database__client, database__connection__host, NODE_ENV=production |
| MySQL | mysql:8.0 | 3306 (internal) | /var/lib/mysql | MYSQL_ROOT_PASSWORD, MYSQL_USER, MYSQL_PASSWORD, MYSQL_DATABASE |
| MariaDB | mariadb:10.11 | 3306 (internal) | /var/lib/mysql | MYSQL_RANDOM_ROOT_PASSWORD=1, MYSQL_USER, MYSQL_PASSWORD, MYSQL_DATABASE |
| Caddy | caddy:2-alpine | 80:80, 443:443 | /data, /config, Caddyfile | Automatic TLS via Caddyfile |
| Nginx | nginx:1.27-alpine | 80:80, 443:443 | /etc/nginx/conf.d, /etc/letsencrypt | Manual cert paths in nginx.conf |
| Mailgun SMTP | (Ghost env) | -- | -- | mail__transport=SMTP, mail__options__host=smtp.mailgun.org, mail__options__port=587 |
| Stripe | (Ghost env) | -- | -- | Configure via Ghost Admin > Settings > Memberships |
| Content Backup | (docker exec) | -- | Dump to host | mysqldump -u root -p ghost > backup.sql |
Ghost Environment Variable Convention
| Config JSON Path | Environment Variable | Example Value |
|---|---|---|
url | url | https://blog.example.com |
database.client | database__client | mysql |
database.connection.host | database__connection__host | db |
database.connection.user | database__connection__user | ghost |
database.connection.password | database__connection__password | secretpass |
database.connection.database | database__connection__database | ghostdb |
mail.transport | mail__transport | SMTP |
mail.from | mail__from | "Blog <[email protected]>" |
mail.options.host | mail__options__host | smtp.mailgun.org |
mail.options.port | mail__options__port | 587 |
mail.options.auth.user | mail__options__auth__user | [email protected] |
mail.options.auth.pass | mail__options__auth__pass | mailgun-smtp-password |
server.host | server__host | 0.0.0.0 |
server.port | server__port | 2368 |
logging.level | logging__level | error |
privacy.useUpdateCheck | privacy__useUpdateCheck | false |
Decision Tree
START: What kind of Ghost Docker deployment do you need?
├── Development/local testing?
│ ├── YES → Use single-container Ghost with SQLite
│ │ docker run -d -p 2368:2368 -e NODE_ENV=development ghost:5.113.2
│ └── NO ↓
├── Production single-server deployment?
│ ├── YES → Use 3-service Compose: Ghost + MySQL/MariaDB + Caddy/Nginx
│ │ ├── Want automatic TLS (easiest)?
│ │ │ ├── YES → Use Caddy reverse proxy
│ │ │ └── NO → Use Nginx + Certbot/Let's Encrypt
│ │ ├── Need transactional email?
│ │ │ ├── YES → Add mail__transport=SMTP env vars
│ │ │ └── NO → Ghost works but admin invites and password resets fail
│ │ └── Need paid memberships?
│ │ ├── YES → Configure Stripe in Ghost Admin after deployment
│ │ └── NO → Skip Stripe setup
│ └── NO ↓
├── Theme development with live reload?
│ ├── YES → Mount local theme directory into Ghost container
│ └── NO ↓
└── DEFAULT → Use the standard 3-service production Compose stack
Step-by-Step Guide
1. Create the project directory and environment file
Set up a directory structure and .env file to store secrets outside the Compose file. [src1]
mkdir -p ~/ghost-blog && cd ~/ghost-blog
# Generate strong passwords
cat > .env << 'EOF'
GHOST_URL=https://blog.example.com
MYSQL_ROOT_PASSWORD=<generate-with-openssl-rand-hex-32>
MYSQL_PASSWORD=<generate-with-openssl-rand-hex-16>
GHOST_DB_USER=ghost
GHOST_DB_NAME=ghostdb
[email protected]
MAIL_PASS=your-mailgun-smtp-password
EOF
Verify: cat .env -- should show all environment variables with generated passwords.
2. Create docker-compose.yml with Ghost, MySQL, and Caddy
Production-ready Compose file with all three services, persistent volumes, and health checks. [src1] [src5]
Full script: docker-compose-production.yml (52 lines)
Verify: docker compose config -- validates syntax and variable substitution.
3. Configure Caddy for automatic TLS
Create a Caddyfile for automatic HTTPS with Let's Encrypt. [src5]
blog.example.com {
reverse_proxy ghost:2368 {
lb_try_duration 30s
}
}
www.blog.example.com {
redir https://blog.example.com{uri}
}
Verify: After deployment, curl -I https://blog.example.com -- should return HTTP/2 200 with valid TLS.
4. Deploy and initialize Ghost
Start the stack and create your admin account. [src1]
# Start all services
docker compose up -d
# Watch logs until Ghost reports "Ghost booted"
docker compose logs -f ghost
# Once running, visit https://blog.example.com/ghost to create admin account
Verify: docker compose ps -- all services should show status "Up (healthy)".
5. Configure email (Mailgun SMTP)
Add Mailgun SMTP environment variables for transactional emails. [src3] [src5]
# Add to ghost service environment in docker-compose.yml:
environment:
mail__transport: SMTP
mail__from: '"My Blog" <[email protected]>'
mail__options__host: smtp.mailgun.org
mail__options__port: 587
mail__options__secure: "false"
mail__options__auth__user: ${MAIL_USER}
mail__options__auth__pass: ${MAIL_PASS}
Verify: Ghost Admin > Settings > Email > Send test email -- should arrive in your inbox.
6. Set up automated backups
Create a backup script that dumps both the database and content volume. [src7]
Full script: ghost-backup.sh (28 lines)
Verify: ls ~/ghost-backups/$(date +%Y-%m-%d)/ -- should contain ghost_db.sql.gz and ghost_content.tar.gz.
Code Examples
Docker Compose: Production Stack with Caddy
Full script: docker-compose-production.yml (52 lines)
# Input: .env file with GHOST_URL, MYSQL_ROOT_PASSWORD, MYSQL_PASSWORD, GHOST_DB_USER, GHOST_DB_NAME
# Output: Running Ghost blog at https://your-domain.com with automatic TLS
services:
ghost:
image: ghost:5.113.2
restart: always
depends_on:
db:
condition: service_healthy
environment:
url: ${GHOST_URL}
database__client: mysql
database__connection__host: db
database__connection__user: ${GHOST_DB_USER}
database__connection__password: ${MYSQL_PASSWORD}
database__connection__database: ${GHOST_DB_NAME}
Docker Compose: Nginx + Certbot Alternative
Full script: docker-compose-nginx.yml (63 lines)
Bash: Theme Development with Live Reload
# Input: Local theme directory at ./my-theme
# Output: Ghost running in development mode with theme hot-reload
docker run -d --name ghost-dev \
-p 2368:2368 \
-e NODE_ENV=development \
-e url=http://localhost:2368 \
-v $(pwd)/my-theme:/var/lib/ghost/content/themes/my-theme \
ghost:5.113.2
# Restart Ghost to pick up theme changes
docker restart ghost-dev
Bash: Backup and Restore Commands
Full script: ghost-backup.sh (28 lines)
# --- BACKUP ---
docker exec ghost-db mysqldump -u root -p"$MYSQL_ROOT_PASSWORD" ghostdb | gzip > ghost_db.sql.gz
docker run --rm -v ghost_content:/data -v $(pwd):/backup alpine \
tar czf /backup/ghost_content.tar.gz -C /data .
# --- RESTORE ---
docker compose stop ghost
gunzip < ghost_db.sql.gz | docker exec -i ghost-db mysql -u root -p"$MYSQL_ROOT_PASSWORD" ghostdb
docker run --rm -v ghost_content:/data -v $(pwd):/backup alpine \
sh -c "rm -rf /data/* && tar xzf /backup/ghost_content.tar.gz -C /data"
docker compose start ghost
Anti-Patterns
Wrong: Using :latest tag in production
# BAD -- :latest can pull breaking changes on next docker compose pull
services:
ghost:
image: ghost:latest
Correct: Pin to specific version
# GOOD -- predictable deployments, controlled upgrades
services:
ghost:
image: ghost:5.113.2
Wrong: Exposing Ghost port directly to the internet
# BAD -- no TLS, no rate limiting, no security headers
services:
ghost:
image: ghost:5.113.2
ports:
- "2368:2368"
environment:
url: http://blog.example.com:2368
Correct: Use a reverse proxy for TLS termination
# GOOD -- Ghost is only reachable through the reverse proxy
services:
ghost:
image: ghost:5.113.2
# No ports exposed to host
environment:
url: https://blog.example.com
caddy:
image: caddy:2-alpine
ports:
- "80:80"
- "443:443"
Wrong: Hardcoding secrets in docker-compose.yml
# BAD -- secrets committed to version control
environment:
database__connection__password: mysecretpassword123
MYSQL_ROOT_PASSWORD: rootpass456
Correct: Use .env file or Docker secrets
# GOOD -- secrets in .env file (add to .gitignore)
environment:
database__connection__password: ${MYSQL_PASSWORD}
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
Wrong: No health check on database service
# BAD -- Ghost may start before MySQL is ready
services:
ghost:
depends_on:
- db
db:
image: mysql:8.0
Correct: Use depends_on with health check condition
# GOOD -- Ghost waits until MySQL passes health check
services:
ghost:
depends_on:
db:
condition: service_healthy
db:
image: mysql:8.0
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
interval: 10s
timeout: 5s
retries: 5
Common Pitfalls
- url mismatch causes redirect loops: Setting
url=http://...while accessing via HTTPS triggers infinite redirects. Fix: Always seturl=https://your-domain.comand ensure your reverse proxy passes correctX-Forwarded-Proto: httpsheaders. [src3] - Database not ready on first boot: Ghost starts before MySQL finishes initialization, causing "ECONNREFUSED" errors. Fix: Add
healthcheckto MySQL service anddepends_on: condition: service_healthyto Ghost. [src6] - Content volume not persisted: Omitting the
/var/lib/ghost/contentvolume mount means all images, themes, and uploads are lost on container recreation. Fix: Always mount a named volume or bind mount for this path. [src2] - Mailgun region mismatch: US Mailgun uses
smtp.mailgun.org, EU usessmtp.eu.mailgun.org. Using the wrong host causes silent email failures. Fix: Check your Mailgun dashboard region and setmail__options__hostaccordingly. [src5] - MySQL password change after init: Changing
MYSQL_ROOT_PASSWORDin.envafter the database volume is created has no effect -- MySQL only reads these on first initialization. Fix: Either recreate the volume (data loss) or change passwords via SQL inside the container. [src1] - Ghost upgrade skips major versions: Jumping from Ghost 4.x to 6.x directly can corrupt the database. Fix: Upgrade one major version at a time and backup before each upgrade. [src2]
- File permission errors after volume restore: Restored content may have wrong ownership (Ghost runs as UID 1000). Fix:
docker exec ghost-app chown -R node:node /var/lib/ghost/content. [src7] - Port conflict with existing services: Port 80 or 443 already in use by Apache or another web server. Fix: Stop conflicting services or change Caddy/Nginx port mappings. [src4]
Diagnostic Commands
# Check all service status
docker compose ps
# View Ghost startup logs
docker compose logs ghost | tail -30
# Check Ghost configuration
docker compose exec ghost ghost config
# Verify volume mounts
docker compose exec ghost ls -la /var/lib/ghost/content/
# Check MySQL health
docker compose exec db mysqladmin ping -u root -p"$MYSQL_ROOT_PASSWORD"
# Monitor resource usage
docker stats ghost-app ghost-db ghost-caddy
# Check Caddy TLS certificate status
docker compose exec caddy caddy list-certificates
# View Nginx error log (if using Nginx)
docker compose exec nginx tail -50 /var/log/nginx/error.log
Version History & Compatibility
| Ghost Version | Status | Docker Base | Breaking Changes | Notes |
|---|---|---|---|---|
| 6.x (6.19+) | Current | Node 20 / Alpine 3.23 | Official Docker Compose tooling, Caddy default | New: ActivityPub + Tinybird analytics |
| 5.x (5.113+) | LTS / Stable | Node 18 / Alpine 3.19 | None from 5.0 | Most widely deployed; recommended for stability |
| 4.x | EOL | Node 16 | Members API changes | Upgrade to 5.x first |
| 3.x | EOL | Node 14 | Major theme API changes | Not supported |
When to Use / When Not to Use
| Use When | Don't Use When | Use Instead |
|---|---|---|
| Self-hosting a blog or publication on your own server | You want zero server management | Ghost(Pro) managed hosting |
| Need full control over data, themes, and integrations | Traffic is under 100 visitors/month and budget is tight | Static site generator (Hugo, Eleventy) |
| Running multiple Ghost instances (staging + production) | You need complex page builder features | WordPress or Strapi |
| Want Docker-based reproducible deployments | Your server has less than 1 GB RAM | Ghost CLI on bare metal |
| Need headless CMS with Content API for Jamstack | You need e-commerce functionality | Shopify or WooCommerce |
Important Caveats
- Ghost 6.x introduced official Docker Compose tooling with Caddy as the default reverse proxy -- older guides using Nginx may need adaptation for 6.x-specific features (ActivityPub, Tinybird analytics)
- The Alpine-based Ghost image (
ghost:5.113.2-alpine) is ~150 MB smaller than the Debian variant but may lack some native Node modules -- test your themes and integrations - MySQL 8.0 changed the default authentication plugin to
caching_sha2_password-- Ghost supports both, but older MySQL client libraries may fail; add--default-authentication-plugin=mysql_native_passwordif needed - Docker Desktop on macOS/Windows has slower volume I/O than native Linux -- Ghost startup may take 30-60 seconds instead of 5-10 seconds
- Caddy automatically provisions TLS certificates via Let's Encrypt -- ensure port 80 and 443 are open and DNS A record points to your server before starting the stack
- Ghost's built-in image optimization (Sharp) requires the full Debian image for WebP conversion -- the Alpine variant may need additional packages