Self-host Ghost with MySQL
Follow this on your own server. Start free, no card needed
Ghost’s own one-line installer gets you a working blog fast, but it quietly assumes SQLite and a single machine you’ll never move. The moment you want MySQL, a second server for background jobs, or just a Docker Compose file you can read and back up yourself, you’re on your own. This guide builds that setup end to end: Ghost and MySQL as two containers, Caddy in front for HTTPS, real backups, and an upgrade path that doesn’t involve crossing your fingers. By the end you’ll have a publication running on infrastructure you understand, plus the one-click version if you’d rather skip the YAML.
This guide uses Ghost’s current stable release line (Ghost 6.x) for the manual setup below, and MySQL 8.0.
What you’ll need
- A server. A DigitalOcean Basic Droplet with 2 vCPUs and 2 GB of RAM runs Ghost and MySQL comfortably at $18/month; the 1 vCPU / 1 GB droplet at $6/month is too tight once MySQL and Node are both resident. Any Ubuntu 22.04 or 24.04 box works the same way.
- A domain name, with its DNS A record pointed at the server’s IP before you get to the HTTPS step.
- An SMTP provider (Postmark, SES, Mailgun, or similar) for transactional email. Ghost needs this for staff invites, password resets and member sign-in links; it’s unrelated to newsletter sending, which Ghost handles itself once mail is configured.
Step 1: Point DNS and prepare the server
Create the A record for your domain first, since certificate issuance later depends on it already resolving:
dig +short blog.example.com
You should see your server’s IP address once propagation finishes, typically within minutes for most registrars.
SSH in and install Docker Engine and the Compose plugin:
curl -fsSL https://get.docker.com | sh
sudo usermod -aG docker $USER
Log out and back in so the group change takes effect, then confirm:
docker compose version
You should see a Compose version string (v2.x), not a “command not found” error.
Step 2: Write the Docker Compose file
Create a project directory and a docker-compose.yml with three services: Ghost, MySQL, and Caddy as the reverse proxy.
services:
ghost:
image: ghost:6-alpine
restart: unless-stopped
depends_on:
db:
condition: service_healthy
environment:
NODE_ENV: production
url: https://${DOMAIN:?set DOMAIN in .env}
database__client: mysql
database__connection__host: db
database__connection__port: "3306"
database__connection__user: ghost
database__connection__password: ${DATABASE_PASSWORD:?set DATABASE_PASSWORD in .env}
database__connection__database: ghost
mail__transport: SMTP
mail__options__host: ${SMTP_HOST}
mail__options__port: "465"
mail__options__secure: "true"
mail__options__auth__user: ${SMTP_USER}
mail__options__auth__pass: ${SMTP_PASSWORD}
mail__from: ${MAIL_FROM}
volumes:
- ghost_content:/var/lib/ghost/content
networks:
- ghost_net
db:
image: mysql:8.0.44
restart: unless-stopped
environment:
MYSQL_DATABASE: ghost
MYSQL_USER: ghost
MYSQL_PASSWORD: ${DATABASE_PASSWORD:?set DATABASE_PASSWORD in .env}
MYSQL_ROOT_PASSWORD: ${DATABASE_ROOT_PASSWORD:?set DATABASE_ROOT_PASSWORD in .env}
volumes:
- mysql_data:/var/lib/mysql
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-p${DATABASE_ROOT_PASSWORD}"]
interval: 5s
start_period: 30s
retries: 20
networks:
- ghost_net
caddy:
image: caddy:2
restart: unless-stopped
depends_on:
- ghost
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile
- caddy_data:/data
- caddy_config:/config
networks:
- ghost_net
networks:
ghost_net:
volumes:
ghost_content:
mysql_data:
caddy_data:
caddy_config:
The double-underscore environment variables (database__connection__host) are Ghost’s own convention for overriding its nested JSON config without touching a file, documented at Ghost’s configuration reference. url has to be the real https:// address from the start: Ghost bakes it into generated links and themes, and changing it later needs a full ghost config and cache rebuild rather than a quick edit.
Next to the Compose file, create a .env with real values:
DOMAIN=blog.example.com
DATABASE_PASSWORD=a-long-random-string
DATABASE_ROOT_PASSWORD=a-different-long-random-string
SMTP_HOST=smtp.postmarkapp.com
SMTP_USER=your-smtp-username
SMTP_PASSWORD=your-smtp-password
MAIL_FROM="Your Blog <hello@blog.example.com>"
Generate the two passwords instead of typing them by hand:
openssl rand -hex 24
You should get back a 48-character hex string each time you run it; use one for DATABASE_PASSWORD and a second one for DATABASE_ROOT_PASSWORD.
Step 3: Write the Caddyfile
Caddy requests and renews the certificate automatically once it can answer on ports 80 and 443 for your domain:
blog.example.com {
reverse_proxy ghost:2368
}
That’s the entire file. Caddy’s ACME challenge needs port 80 open to the public internet even though the final traffic moves to HTTPS, so don’t block it at the firewall.
Step 4: Bring the stack up
docker compose up -d
docker compose logs -f ghost
You should see MySQL’s health check pass, then Ghost log a line like Ghost is running in production... followed by the URL it’s serving on. Visit https://blog.example.com/ghost to land on the setup wizard and create your first admin account.
Step 5: Back up MySQL and the content volume
Ghost’s actual data lives in two places: MySQL (posts, members, settings) and the ghost_content volume (themes, images, uploads). Back up both together so a restore is consistent:
#!/usr/bin/env bash
set -euo pipefail
STAMP=$(date +%Y%m%d-%H%M%S)
mkdir -p backups
docker compose exec -T db mysqldump -u root -p"$DATABASE_ROOT_PASSWORD" ghost | gzip > "backups/ghost-db-$STAMP.sql.gz"
docker run --rm -v ghost_content:/content -v "$(pwd)/backups:/backup" alpine \
tar czf "/backup/ghost-content-$STAMP.tar.gz" -C /content .
Run it from the same directory as your Compose file (so ghost_content resolves to the right project volume) and put it on a daily cron job. Copy the backups/ directory off the server itself, a cron’d rsync to another machine or a small object storage bucket is enough; a backup that only lives on the server you’re protecting against isn’t one.
You should see two new files in backups/ after each run, and their sizes should roughly track your content size over time, not stay at zero.
Step 6: Upgrades
Pull the new image and recreate just the Ghost container; MySQL and its data volume are untouched:
docker compose pull ghost
docker compose up -d ghost
Ghost runs its own database migrations on boot, so check the logs for a line confirming the migration finished before you consider the upgrade done:
docker compose logs --tail=50 ghost
Take a backup (Step 5) before every upgrade, especially across a major version like 5 to 6, since that’s exactly the kind of change a migration can fail partway through.
The shortcut: do it with Railyard
All of the above, the two containers, the wiring between them, the reverse proxy and the certificate, is what Railyard’s Ghost + MySQL template sets up for you from the templates catalog. It runs ghost:5.130.6-alpine with a mysql:8.0.39 sidecar: RAILYARD_URL and the MySQL connection string are wired automatically, so there’s no database__connection__host to type in by hand, and a ghost-content volume plus a ghost-db-data volume are created and backed up without a cron job to write yourself.
Open https://app.railyard.run/deploy?stack=ghost-mysql and pick Ghost + MySQL in the catalog. You still add your own SMTP credentials for mail (Railyard doesn’t supply an email transport), but the database, the volumes and the HTTPS certificate are already handled. See Deploy to Railyard button if you’d rather publish the same one-click flow from your own README, and Databases and backups for how scheduled backups and restores work once it’s running.
Troubleshooting
Port 80 or 443 already in use. Something else (often a default Apache or nginx install) is already bound. sudo ss -tlnp | grep -E ':80|:443' shows you what, and either stop it or move it aside before Caddy can bind those ports.
Certificate never issues. Caddy logs will show repeated ACME failures. The near-universal cause is DNS: dig +short blog.example.com has to return the server’s IP before Caddy can prove ownership to Let’s Encrypt. Also check that nothing upstream (a cloud firewall, a provider’s default security group) blocks inbound port 80.
Ghost keeps restarting and the logs show a MySQL connection error. database__connection__host has to match the Compose service name (db in the file above), and the password in Ghost’s environment has to be the exact same string as MYSQL_PASSWORD on the db service. A password with a $ or # in it needs to be quoted consistently in both places, or escaped, since Compose and the shell both try to interpret those characters.
Permission denied writing to the content volume. This shows up after restoring a backup taken with tar as root into a volume Ghost’s container expects to own as its own user. Restore into a fresh named volume with docker run --rm -v ghost_content:/content alpine tar xzf /backup/ghost-content-TIMESTAMP.tar.gz -C /content rather than copying files in with your own user account.
MySQL runs out of memory on a 1 GB server. MySQL’s buffer pool defaults scale with the box, and on a 1 GB droplet it leaves too little for Ghost and Node. This is why the $18/month 2 GB droplet above is the real minimum, not the cheapest listed size.
FAQ
Can I use SQLite instead of MySQL? Yes, Ghost’s default CLI install still uses SQLite, and it’s genuinely fine for a single low-traffic site. MySQL earns its complexity once you want to run more than one Ghost instance against the same data, or you’re moving from a host that already gives you managed MySQL.
Does this support Ghost’s newsletter and membership features? Yes, once SMTP (for transactional mail) and a bulk email provider (Ghost integrates with Mailgun for newsletter sending specifically) are both configured. The two are separate settings in Ghost’s admin, easy to conflate because they’re both “email.”
Can I run Ghost without Docker? Ghost supports a native Node.js install on Ubuntu documented at docs.ghost.org, with MySQL installed as a system package instead of a container. The Docker route in this guide is easier to back up and move between servers, which is why we used it here.
How do I move an existing Ghost site into this setup? Use Ghost’s own export/import (Settings → Migration options) for content, or restore a mysqldump directly into the db container’s database if you’re moving from another MySQL-backed install, then copy the old content/images directory into the new ghost_content volume.
What happens if the server reboots? restart: unless-stopped on every service means Docker brings the containers back up automatically once the Docker daemon itself starts, which happens on boot by default on a systemd-based distribution like Ubuntu.
Either path gets you a Ghost site you actually control: the manual one if you want to see every moving part, the template if you’d rather start writing today.
Why people pick Railyard for this
- Your own cloud account. Deploy to DigitalOcean, Hetzner, Vultr, AWS, OVHcloud, or any Linux server over SSH, billed directly by your provider with no markup.
- One flat price per team. Free is free forever: one server plus a database server, three apps, no card. Pro is $19 a month for up to five servers and unlimited apps. Team is $49 a month with unlimited servers. No per-app, per-deploy, per-seat or per-gigabyte charges.
- One-click templates. Deploy open-source apps, like databases, analytics and dev tools, to your own server in one click.
- Managed databases. Postgres, MySQL and Redis with scheduled, restore-verified backups and point-in-time recovery.
- Push-to-deploy. Deploy from GitHub, GitLab or the CLI, with automatic HTTPS and zero-downtime releases.