All articles
TutorialsJun 18, 2026 · 15 min read · By The RDP.sh Team

Self-Host Ghost on a VPS with Docker and Caddy

Self-Host Ghost on a VPS with Docker and Caddy

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.

Ghost officially supports MySQL 8 only. Do not swap in MariaDB or SQLite for a real install - newsletter sending, member imports, and migrations all assume MySQL 8 behavior. The compose file below pins `mysql:8.0` on purpose.

TL;DR

  • Install Docker and Docker Compose on a fresh VPS
  • Point a domain like blog.example.com at your server
  • Run Ghost + MySQL 8 + Caddy with one docker-compose.yml
  • Configure SMTP so signup and newsletter email works
  • Visit /ghost to create your admin account before anyone else does
  • Back up the MySQL database and the content directory daily

Total time: around 15 minutes.

What You Need

  • A VPS with at least 1 GB RAM (2 GB recommended once you add members and images) running Ubuntu 22.04 or 24.04
  • A domain or subdomain you can add DNS records to
  • Ports 80 and 443 open to the internet
  • An SMTP provider for outbound mail - Mailgun, Postmark, Amazon SES, or Resend all work
  • Root or sudo access

Ghost 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.

Step 1: Point Your Domain at the VPS

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.

Step 2: Install Docker and Docker Compose

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

Step 3: Open the Firewall

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.

Step 4: Create the Project Directory

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.

Step 5: Generate Secrets

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.

Step 6: Write the Environment File

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.

Step 7: Write the Compose File

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:

  • Ghost's internal port 2368 never leaves the Docker network. Caddy reaches it by container name.
  • The 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.

Step 8: Write the Caddyfile

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.

Step 9: Start the Stack

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.

Step 10: Claim the Admin Account

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.

There is no signup gate on a fresh Ghost install - whoever loads `/ghost` first becomes the site owner. If you set up DNS hours before claiming the account, a bot scanning new certificate-transparency logs could beat you to it. Claim it the moment the site responds.

Step 11: Send a Test Email

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.

Step 12: Back Up the Database and Content

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.

Step 13: Upgrade Ghost Safely

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.

Troubleshooting

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.

Going Further

  • Add a CDN like Cloudflare in front of blog.example.com to cache images and absorb traffic spikes.
  • Turn on paid memberships by connecting Stripe in the admin settings.
  • Browse the Ghost theme marketplace or build a custom theme with the Handlebars-based engine.
  • Put the admin panel behind a VPN if you never edit on the go - pair this with our Tailscale guide so only your devices can reach /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.