docker compose up -d with environment variables for url, database__client, database__connection__host, and mail__transport.url env var must exactly match your public domain with https:// -- Ghost generates all links from this value, and a mismatch causes redirect loops or broken assets.url environment variable MUST match your public domain including https:// -- Ghost uses this for all internal links and redirectsDATABASE_xxx variables must NOT be changed after initial database creation -- doing so breaks the installationghost:5.113.2) -- never use :latest in production/var/lib/ghost/content) MUST be persisted -- it contains images, themes, and uploaded files| 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 |
| 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 |
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
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.
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.
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.
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)".
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.
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.
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}
Full script: docker-compose-nginx.yml (63 lines)
# 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
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
# BAD -- :latest can pull breaking changes on next docker compose pull
services:
ghost:
image: ghost:latest
# GOOD -- predictable deployments, controlled upgrades
services:
ghost:
image: ghost:5.113.2
# 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
# 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"
# BAD -- secrets committed to version control
environment:
database__connection__password: mysecretpassword123
MYSQL_ROOT_PASSWORD: rootpass456
# GOOD -- secrets in .env file (add to .gitignore)
environment:
database__connection__password: ${MYSQL_PASSWORD}
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
# BAD -- Ghost may start before MySQL is ready
services:
ghost:
depends_on:
- db
db:
image: mysql:8.0
# 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
url=http://... while accessing via HTTPS triggers infinite redirects. Fix: Always set url=https://your-domain.com and ensure your reverse proxy passes correct X-Forwarded-Proto: https headers. [src3]healthcheck to MySQL service and depends_on: condition: service_healthy to Ghost. [src6]/var/lib/ghost/content volume 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]smtp.mailgun.org, EU uses smtp.eu.mailgun.org. Using the wrong host causes silent email failures. Fix: Check your Mailgun dashboard region and set mail__options__host accordingly. [src5]MYSQL_ROOT_PASSWORD in .env after 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]docker exec ghost-app chown -R node:node /var/lib/ghost/content. [src7]# 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
| 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 |
| 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 |
ghost:5.113.2-alpine) is ~150 MB smaller than the Debian variant but may lack some native Node modules -- test your themes and integrationscaching_sha2_password -- Ghost supports both, but older MySQL client libraries may fail; add --default-authentication-plugin=mysql_native_password if needed