Ghost is an open-source publishing platform built for modern journalism, newsletters, and professional blogging. It uses a clean JavaScript stack, supports memberships and paid subscriptions out of the box, and delivers a fast editorial experience. Teams choose Ghost to own their content, avoid platform fees, and integrate with any SMTP or payment provider. This guide walks through a production-ready Ghost deployment on Ubuntu using Docker Compose, Caddy for automatic HTTPS, MySQL for durable storage, and scheduled backups. The outcome is a secure, observable publication engine that you can scale as your audience grows without per-seat costs.
Architecture and flow overview
The stack runs three containers orchestrated by Docker Compose. Caddy sits at the edge, terminating TLS with automatically managed certificates and proxying traffic to the Ghost application container. Ghost persists posts, users, and settings in MySQL, while uploaded themes and images live in a bind-mounted volume on the host. Caddy also handles gzip and zstd compression along with security headers. Because Ghost stores configuration in environment variables, the entire stack is portable and easy to recreate on a new host during disaster recovery. Keeping MySQL and Ghost on the same Docker network minimizes latency and simplifies access control.
Prerequisites
- Ubuntu 22.04 or 24.04 LTS server with at least 2 vCPU, 4 GB RAM, and 40 GB SSD.
- A DNS A record pointing your domain to the server IP address.
- Ports 80 and 443 open on the host firewall and cloud security group.
- Docker Engine 24.x and Docker Compose plugin installed.
- A non-root user with sudo privileges and a working SSH key pair.
- At least 10 GB of free space for media uploads and automated backups.
Step-by-step deployment
Prepare the server and directories
Create the host directory structure, add a dedicated user for file ownership, and set restrictive permissions. Using a dedicated user reduces blast radius if a container process escapes. Open the required ports on the local firewall so Caddy can serve traffic.
sudo mkdir -p /opt/ghost/{data,mysql-data,caddy-config,caddy-data}
sudo useradd -r -s /usr/sbin/nologin -d /opt/ghost ghost || true
sudo chown -R ghost:ghost /opt/ghost/data
sudo chmod 750 /opt/ghost
sudo chmod 700 /opt/ghost/mysql-data
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw reload
Create environment variables
Store secrets in a .env file that is never committed to version control. Replace the passwords, domain, and mail credentials before continuing.
cat > /opt/ghost/.env <<'EOF'
GHOST_DOMAIN=ghost.example.com
MYSQL_ROOT_PASSWORD=ChangeMeTo32RandomChars!!
MYSQL_DATABASE=ghost
MYSQL_USER=ghost
MYSQL_PASSWORD=Another32CharRandomSecret!!
GHOST_DB_PASSWORD=${MYSQL_PASSWORD}
GHOST_URL=https://${GHOST_DOMAIN}
[email protected]
MAIL_HOST=smtp.mailgun.org
MAIL_PORT=587
[email protected]
MAIL_PASS=MailgunApiKeyOrPasswordHere
EOF
chmod 600 /opt/ghost/.env
Create the Docker Compose manifest
The Compose file defines MySQL, Ghost, and Caddy services. Ghost depends on MySQL being healthy before it starts. The application port is bound to localhost only so that Caddy is the sole public entry point.
cat > /opt/ghost/docker-compose.yml <<'EOF'
version: "3.9"
services:
mysql:
image: mysql:8.0
restart: unless-stopped
env_file: .env
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
MYSQL_DATABASE: ${MYSQL_DATABASE}
MYSQL_USER: ${MYSQL_USER}
MYSQL_PASSWORD: ${MYSQL_PASSWORD}
volumes:
- ./mysql-data:/var/lib/mysql
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u${MYSQL_USER}", "-p${MYSQL_PASSWORD}"]
interval: 10s
timeout: 5s
retries: 5
ghost:
image: ghost:5-alpine
restart: unless-stopped
env_file: .env
environment:
database__client: mysql
database__connection__host: mysql
database__connection__port: 3306
database__connection__user: ${MYSQL_USER}
database__connection__password: ${GHOST_DB_PASSWORD}
database__connection__database: ${MYSQL_DATABASE}
url: ${GHOST_URL}
mail__transport: SMTP
mail__from: ${MAIL_FROM}
mail__options__host: ${MAIL_HOST}
mail__options__port: ${MAIL_PORT}
mail__options__auth__user: ${MAIL_USER}
mail__options__auth__pass: ${MAIL_PASS}
ports:
- "127.0.0.1:2368:2368"
volumes:
- ./data:/var/lib/ghost/content
depends_on:
mysql:
condition: service_healthy
caddy:
image: caddy:2-alpine
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- ./caddy-data:/data
- ./caddy-config:/config
depends_on:
- ghost
EOF
Create the Caddy reverse proxy configuration
Caddy obtains and renews TLS certificates automatically. The configuration also sets security headers and compresses responses with gzip and zstd.
cat > /opt/ghost/Caddyfile <<'EOF'
{
email [email protected]
}
{$GHOST_DOMAIN} {
encode zstd gzip
reverse_proxy ghost:2368
header {
X-Content-Type-Options nosniff
X-Frame-Options SAMEORIGIN
Referrer-Policy strict-origin-when-cross-origin
}
}
EOF
Launch the stack and finish setup
Pull images, start the services, and watch the logs. The first boot creates database tables and prepares the content directory. After the containers are healthy, open the HTTPS URL, create the first owner account, and configure the publication title and language. Send a test email immediately to confirm SMTP delivery, then publish a draft post to verify the editor and front end render correctly.
cd /opt/ghost
docker compose pull
docker compose up -d
docker compose ps
docker compose logs -f --tail=100 ghost mysql
Configuration and secrets handling best practices
After installation, open the Ghost administration panel and configure the publication title, timezone, and default language. Enable two-factor authentication for the owner account and create separate staff users with appropriate roles instead of sharing the owner login. Store the .env file with mode 600, back it up through an encrypted secret store, and rotate passwords when an operator leaves. Do not commit .env or the Caddyfile into a public repository. Restrict MySQL access to the internal Docker network and avoid exposing port 3306 on the host.
For email, use a transactional mail provider with verified SPF, DKIM, and DMARC records so that newsletter and password-reset messages reach inboxes. If you plan to offer paid memberships, connect Stripe in test mode first, verify the flow end-to-end, and only then switch to live keys. Keep Stripe webhook secrets in the same encrypted vault as database passwords. Review Ghost logs after each configuration change to catch mail or payment errors early.
Use a small backup script that dumps MySQL, archives the Ghost content directory, and prunes backups older than two weeks. Schedule it with cron so backups run automatically every night. Test restores quarterly on a separate host to confirm that both the database dump and uploaded images are intact. Verify that the cron job emails or logs success so silent failures do not go unnoticed.
cat > /opt/ghost/backup.sh <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
stamp=$(date -u +%Y%m%dT%H%M%SZ)
mkdir -p /opt/ghost/backups/${stamp}
docker compose exec -T mysql mysqldump -ughost -p"${MYSQL_PASSWORD}" ghost \
> /opt/ghost/backups/${stamp}/ghost.sql
tar -C /opt/ghost -czf /opt/ghost/backups/${stamp}/ghost-files.tgz \
data Caddyfile docker-compose.yml .env
find /opt/ghost/backups -mindepth 1 -maxdepth 1 -type d -mtime +14 -exec rm -rf {} +
EOF
chmod +x /opt/ghost/backup.sh
Verification checklist
- Open the HTTPS URL and confirm the certificate is valid.
- Complete the Ghost owner setup and log in to the dashboard.
- Send a test email and confirm it reaches the inbox.
- Upload a theme or image and verify it persists after a container restart.
- Run the backup script and verify the resulting archive is not empty.
- Check container health with
docker compose psand confirm all services are healthy.
curl -I https://ghost.example.com
docker compose ps
docker compose logs --tail=120 ghost
./backup.sh
ls -lah /opt/ghost/backups/*/
Common issues and fixes
Caddy cannot obtain a certificate. Verify that the DNS A record resolves to the server and that ports 80 and 443 are reachable from the public internet. Stale AAAA records and cloud firewalls are frequent causes.
Ghost shows a database connection error. Ensure the MySQL healthcheck passes before Ghost starts, and confirm that the database password in the .env file matches between MySQL and Ghost. Mismatched credentials are the most common restart failure.
Email delivery fails or lands in spam. Verify SPF, DKIM, and DMARC records for the sending domain. Use a reputable transactional provider and warm up the domain before sending bulk newsletters.
Large image uploads fail. Increase the client_max_body_size equivalent in Caddy and verify that the host disk has enough free space for the content volume.
The site feels slow. Enable Caddy compression, monitor MySQL query latency, and consider adding a CDN in front of static assets if your audience is global.
Container logs fill the disk. Configure Docker log rotation with the json-file driver and a maximum size limit so that container output does not exhaust the host filesystem.
FAQ
Can this run on a single small VPS?
Yes for small publications and moderate traffic. Monitor memory during large image imports and scale CPU or disk I/O before audience growth creates contention.
Should I use SQLite instead of MySQL?
SQLite is fine for local development, but MySQL is strongly recommended for production because Ghost optimizes its schema and migrations for MySQL, and concurrency is safer.
How do I enable paid memberships?
Connect a Stripe account in the Ghost settings, configure your price tiers, and enable members in the labs section. Test the entire flow in Stripe test mode before going live.
Where should backups be stored?
The backup script captures both the database dump and the content directory. Store the resulting archives on a separate server or object storage bucket, and test restores before you need them.
How do I upgrade Ghost safely?
Stop the stack, take a database and file backup, read the release notes, update the image tag in docker-compose.yml, pull the new image, and restart. Run migrations from the admin panel if prompted.
What should be monitored in production?
Monitor HTTPS availability, container restart counts, disk usage, MySQL backup freshness, failed login attempts, email bounce rates, and page load latency. Alert on certificate expiration at least seven days before renewal. Set up log aggregation so you can investigate anomalies without logging into the server.
Internal links
- Deploy Bytebase with Docker Compose + Caddy + PostgreSQL
- Deploy SonarQube with Docker Compose + Caddy + PostgreSQL
- Deploy Wiki.js with Docker Compose + Caddy + PostgreSQL
Talk to us
If you want this implemented with hardened defaults, observability, and tested recovery playbooks, our team can help.