Ghost is a clean, fast publishing platform built for blogs, newsletters, and paid memberships. The hosted version (Ghost Pro) is excellent but starts at $9/month and climbs quickly with subscriber count. The software itself is MIT-licensed, so you can run the exact same application on a $5 VPS and keep every cent of your membership revenue.
This tutorial covers a production-ready install: Docker Compose, MySQL 8 for the content store, Caddy for automatic HTTPS, and a working SMTP setup so transactional email and newsletters actually send. Plus a backup strategy, because a blog you can't restore is a liability.
blog.example.com at your serverdocker-compose.yml/ghost to create your admin account before anyone else doesTotal time: around 15 minutes.
80 and 443 open to the internetGhost is a Node.js app and is light on CPU, but image-heavy blogs appreciate fast disk. NVMe storage makes the admin editor feel instant.
In your DNS provider, add an A record:
blog.example.com → YOUR_VPS_IPV4
Add an AAAA record too if your VPS has IPv6. Verify it resolves before going further:
dig +short blog.example.com
DNS must resolve to your server before Caddy can complete the Let's Encrypt challenge.
On a fresh Ubuntu server:
sudo apt update
sudo apt install -y ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg \
-o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] \
https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo $VERSION_CODENAME) stable" | \
sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo apt update
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
Confirm both are present:
docker --version
docker compose version
sudo ufw allow 22/tcp
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enable
Caddy needs port 80 for the ACME HTTP challenge and 443 for HTTPS traffic.
sudo mkdir -p /opt/ghost
cd /opt/ghost
sudo mkdir -p ghost-content mysql-data caddy-data caddy-config
Everything persistent lives under /opt/ghost. The ghost-content directory holds your themes, uploaded images, and settings - it is just as important to back up as the database.
You need two passwords before writing the compose file:
# MySQL root password
openssl rand -base64 24
# Ghost database user password
openssl rand -base64 24
Save both somewhere safe, like a password manager. If you self-host one, our Vaultwarden guide walks through it.
Create /opt/ghost/.env. Fill in the database passwords from the previous step and your SMTP provider's credentials:
# Public URL - must match your domain and include https
url=https://blog.example.com
# Database
MYSQL_ROOT_PASSWORD=REPLACE_WITH_MYSQL_ROOT_PASSWORD
GHOST_DB_PASSWORD=REPLACE_WITH_GHOST_DB_PASSWORD
# Outbound mail (example uses Mailgun SMTP)
mail__transport=SMTP
mail__options__service=Mailgun
mail__options__host=smtp.mailgun.org
mail__options__port=587
[email protected]
mail__options__auth__pass=REPLACE_WITH_SMTP_PASSWORD
mail__from=Your Blog <[email protected]>
Lock the file down so only root can read it:
sudo chmod 600 /opt/ghost/.env
The double-underscore keys (mail__transport) are how Ghost maps environment variables onto its nested config. They are not typos.
Create /opt/ghost/docker-compose.yml:
services:
mysql:
image: mysql:8.0
container_name: ghost-mysql
restart: unless-stopped
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
MYSQL_DATABASE: ghost
MYSQL_USER: ghost
MYSQL_PASSWORD: ${GHOST_DB_PASSWORD}
volumes:
- ./mysql-data:/var/lib/mysql
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-p${MYSQL_ROOT_PASSWORD}"]
interval: 10s
timeout: 5s
retries: 5
networks:
- ghost-net
ghost:
image: ghost:5-alpine
container_name: ghost
restart: unless-stopped
depends_on:
mysql:
condition: service_healthy
environment:
url: ${url}
database__client: mysql
database__connection__host: mysql
database__connection__port: "3306"
database__connection__user: ghost
database__connection__password: ${GHOST_DB_PASSWORD}
database__connection__database: ghost
mail__transport: ${mail__transport}
mail__options__service: ${mail__options__service}
mail__options__host: ${mail__options__host}
mail__options__port: ${mail__options__port}
mail__options__auth__user: ${mail__options__auth__user}
mail__options__auth__pass: ${mail__options__auth__pass}
mail__from: ${mail__from}
NODE_ENV: production
volumes:
- ./ghost-content:/var/lib/ghost/content
networks:
- ghost-net
caddy:
image: caddy:2
container_name: ghost-caddy
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- ./caddy-data:/data
- ./caddy-config:/config
networks:
- ghost-net
networks:
ghost-net:
A few notes:
2368 never leaves the Docker network. Caddy reaches it by container name.ghost:5-alpine tag tracks the latest Ghost 5.x release. Pin a specific version (for example ghost:5.110-alpine) if you want fully reproducible deploys.database__connection__host is mysql - the service name - not localhost.Create /opt/ghost/Caddyfile:
blog.example.com {
encode zstd gzip
reverse_proxy ghost:2368
}
That is the entire reverse-proxy config. Caddy issues and auto-renews the TLS certificate with zero extra flags. If you want to understand what it is doing under the hood, our Caddy reverse proxy guide goes deeper.
cd /opt/ghost
sudo docker compose up -d
sudo docker compose logs -f
MySQL initializes on first boot, which takes 20-30 seconds, so Ghost may restart a couple of times while it waits for the healthcheck to pass. That is expected. Once the Caddy logs show a certificate was obtained, open https://blog.example.com in your browser.
Go straight to:
https://blog.example.com/ghost
The first visitor to /ghost sets up the owner account, so do this immediately after the site is live. Pick a strong password and store it in your password manager.
In the Ghost admin, go to Settings - Email newsletter and send yourself a test. If it lands, your SMTP config is correct. Member signups, password resets, and newsletters all rely on this working.
If the test never arrives, jump to the troubleshooting section below - email is the single most common thing to misconfigure on a self-hosted Ghost.
A Ghost backup has two halves: the MySQL database (posts, members, settings) and the ghost-content directory (images, themes). You need both.
Create /usr/local/bin/ghost-backup.sh:
#!/usr/bin/env bash
set -euo pipefail
BACKUP_DIR="/var/backups/ghost"
DATE="$(date +%F)"
mkdir -p "$BACKUP_DIR"
# Read the MySQL root password from the env file
source /opt/ghost/.env
docker exec ghost-mysql \
mysqldump -u root -p"${MYSQL_ROOT_PASSWORD}" --single-transaction ghost \
| gzip > "$BACKUP_DIR/ghost-db-$DATE.sql.gz"
tar czf "$BACKUP_DIR/ghost-content-$DATE.tar.gz" \
-C /opt/ghost ghost-content
find "$BACKUP_DIR" -name "ghost-*-*.gz" -mtime +14 -delete
Make it executable and schedule it:
sudo chmod +x /usr/local/bin/ghost-backup.sh
echo "20 3 * * * root /usr/local/bin/ghost-backup.sh" | \
sudo tee /etc/cron.d/ghost-backup
For off-site safety, push /var/backups/ghost to object storage. Our restic to S3 guide covers encrypted, deduplicated backups on the same cron schedule.
Ghost ships updates often. The safe path:
cd /opt/ghost
sudo docker compose pull
sudo docker compose up -d
Ghost runs any required database migrations automatically on boot. Always take a fresh backup before upgrading across minor versions, and never skip from one major version to another - upgrade through each major release in order if you are far behind.
Caddy fails to obtain a certificate. DNS has not propagated yet, or ports 80/443 are blocked upstream by your provider's firewall.
Ghost keeps restarting on first boot. It is waiting for MySQL. Give it a minute. If it never settles, run docker compose logs mysql and check the database actually initialized - a wrong MYSQL_ROOT_PASSWORD in .env is the usual culprit.
Email never sends. Most providers require a verified sending domain with SPF and DKIM DNS records before they accept mail. Confirm mail__from uses a domain you have verified, and that the SMTP user and password are correct.
"Email address not allowed" on signup. Your url value does not match the address visitors actually use. It must be the exact public URL, including https:// and no trailing slash.
Images upload but return 404. The ghost-content volume is not mounted, or its permissions are wrong. Confirm the volumes line in the compose file and that the directory is owned correctly.
blog.example.com to cache images and absorb traffic spikes./ghost.Self-hosted Ghost gives you a professional publishing platform - newsletters, memberships, and a fast editor - without a per-subscriber bill eating into your revenue.
Need a VPS for your blog or newsletter? Our Linux plans include NVMe storage, IPv6, and snapshots as standard. See the options.