VPS Tutorials
Deploying a Ghost Blog on a VPS (2026): Docker Compose, MySQL, Caddy HTTPS, Email and Backups
Deploy a Ghost 6 blog with MySQL 8 on a VPS using Docker Compose, configure Caddy HTTPS, transactional email, content persistence, upgrades and backup recovery — suitable for personal blogs and small content teams.

If you want an independent blog that loads fast, has a clean admin panel and a smooth writing experience, Ghost is often less hassle than a WordPress stuffed with plugins. It ships with memberships, subscriptions, themes and a content API, and the front end doesn't carry a pile of unused admin scripts.
But Ghost isn't something you can just spin up in a container and safely put online. A production environment needs MySQL 8, a correct public URL, an HTTPS reverse proxy, a persistent content directory and recoverable database backups. If email isn't configured properly, member login links, password resets and invitations will also fail.
This tutorial uses the official Docker ghost:6-alpine image and MySQL 8, manages services with Docker Compose, and lets Caddy handle automatic HTTPS. Ghost's port 2368 only listens on 127.0.0.1, and MySQL is not mapped to the host's public network at all.
Ghost is more of a "content publishing tool" than an all-purpose site-building framework.
| Requirement | Better fit | Reason |
|---|---|---|
| Focus on writing articles, member subscriptions, newsletters | Ghost | Editor and membership system are integrated more directly |
| Store, forum, complex plugin ecosystem | WordPress | More plugin and theme choices |
| Pure documentation, extremely low cost, no admin panel | Hugo / Astro | Can generate static files, smaller attack surface |
| Need to expose content to an app or front end | Ghost | Built-in Content API, usable as a headless CMS |
Ghost's advantage is a short path from writing to publishing; its drawback is that extension isn't as free-form as WordPress. When you need complex forms, e-commerce or dozens of business plugins, forcing Ghost into an all-purpose CMS actually raises maintenance costs.
Ghost's official production stack requires MySQL 8, with a minimum memory baseline of 1GB. When Docker runs Ghost, MySQL and Caddy on the host at the same time, I'd recommend starting from 2 cores, 2GB RAM and 30GB SSD; with many images, growing membership or frequent email sending, 2 cores and 4GB will be much more comfortable.
| Scenario | Recommended specs |
|---|---|
| New blog, low traffic | 2 cores 2GB, 30GB SSD |
| Multiple authors, membership features, many images | 2-4 cores 4GB, 50GB SSD |
| Large existing content archive or high concurrency | 4 cores 8GB and up, with database and object storage evaluated separately |
The system used here is Ubuntu 24.04 LTS. Point the A / AAAA records of blog.example.com at the VPS in advance, and open only SSH, 80 and 443 in the security group and UFW.
Install Docker Engine and the Compose plugin from the official Docker repository. When done, confirm you're using Compose v2:
docker --version
docker compose version
If the command only works as the hyphenated docker-compose, you're still on the old Compose v1 — upgrade before continuing.
Create the deployment directory:
sudo mkdir -p /opt/ghost/backups
sudo chown -R "$USER":"$USER" /opt/ghost
cd /opt/ghost
Ghost uses a dedicated database account; don't let the application use the MySQL root password. Generate two different random values:
openssl rand -hex 32
openssl rand -hex 32
nano .env
chmod 600 .env
Write the two random values into .env respectively:
GHOST_URL=https://blog.example.com
MYSQL_ROOT_PASSWORD=替换成第一组随机值
MYSQL_PASSWORD=替换成第二组随机值
GHOST_URL must be the full HTTPS address readers will ultimately visit. It affects admin redirects, canonical URLs, email links and asset addresses; don't put an IP there first and then forget to change it after going live.
Create /opt/ghost/compose.yaml:
services:
db:
image: mysql:8.0
container_name: ghost-db
restart: unless-stopped
environment:
MYSQL_ROOT_PASSWORD: "${MYSQL_ROOT_PASSWORD:?set MYSQL_ROOT_PASSWORD in .env}"
MYSQL_DATABASE: ghost
MYSQL_USER: ghost
MYSQL_PASSWORD: "${MYSQL_PASSWORD:?set MYSQL_PASSWORD in .env}"
volumes:
- ghost_mysql:/var/lib/mysql
healthcheck:
test:
- CMD-SHELL
- >-
mysqladmin ping -h 127.0.0.1 -uroot
-p$$MYSQL_ROOT_PASSWORD --silent
interval: 10s
timeout: 5s
retries: 20
start_period: 30s
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
ghost:
image: ghost:6-alpine
container_name: ghost
restart: unless-stopped
depends_on:
db:
condition: service_healthy
ports:
- "127.0.0.1:2368:2368"
environment:
NODE_ENV: production
url: "${GHOST_URL:?set GHOST_URL in .env}"
database__client: mysql
database__connection__host: db
database__connection__port: 3306
database__connection__user: ghost
database__connection__password: "${MYSQL_PASSWORD:?set MYSQL_PASSWORD in .env}"
database__connection__database: ghost
volumes:
- ghost_content:/var/lib/ghost/content
healthcheck:
test:
- CMD
- node
- -e
- >-
require('http').get('http://127.0.0.1:2368/ghost/',
r=>process.exit(r.statusCode<500?0:1))
.on('error',()=>process.exit(1))
interval: 30s
timeout: 10s
retries: 10
start_period: 60s
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
volumes:
ghost_mysql:
name: ghost_mysql
ghost_content:
name: ghost_content
Here ghost:6-alpine is pinned, so updates follow Ghost 6 patches and minor versions but won't suddenly jump to the next major version when the container is rebuilt. MySQL is likewise pinned to the 8.0 major version, because Ghost 6's production environment only supports MySQL 8 — don't swap in MariaDB or PostgreSQL on your own.
Before starting, have Compose check the variables and YAML:
docker compose config >/dev/null
docker compose pull
docker compose up -d
docker compose ps
docker compose logs --tail=100 ghost
The first initialization of MySQL and the Ghost database migration will take a little time. running does not mean it's ready; wait until both services show healthy in docker compose ps.
After installing Caddy, edit /etc/caddy/Caddyfile:
blog.example.com {
encode zstd gzip
reverse_proxy 127.0.0.1:2368
request_body {
max_size 50MB
}
}
Caddy's reverse_proxy sets the X-Forwarded-For, X-Forwarded-Host and X-Forwarded-Proto that Ghost needs. If you switch to a hand-written Nginx config, missing X-Forwarded-Proto https easily causes an infinite redirect.
Check and load the configuration:
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
sudo systemctl status caddy --no-pager
If you're not yet familiar with certificates and multi-domain configuration, see the complete guide to using Caddy as a reverse proxy on a VPS.
Confirm the Ghost backend only listens locally:
sudo ss -lntp | grep ':2368'
curl -I http://127.0.0.1:2368
curl -I https://blog.example.com
The first line should show 127.0.0.1:2368, not 0.0.0.0:2368. MySQL has no ports configuration, and a public scan should not see 3306 either.
Open in your browser:
https://blog.example.com/ghost/
Follow the page prompts to create the site owner. When done, check three things:
- The site URL in Settings matches
GHOST_URL; - Upload a test image and confirm it still displays after a refresh;
- Publish a draft and confirm both the front-end link and canonical URL use the HTTPS domain.
Ghost's core content lives in MySQL, while images, themes, routes.yaml and other files live in /var/lib/ghost/content. If either side isn't persisted, the backup is incomplete.
Ghost's production environment needs working transactional email for staff invitations, password resets, member sign-ups and login links. Regular SMTP can handle these emails; bulk newsletters are a different matter — self-hosted Ghost currently only supports sending bulk email through the Mailgun API.
Append the SMTP configuration to .env:
MAIL_FROM='My Blog <[email protected]>'
MAIL_HOST=smtp.example.com
MAIL_PORT=465
MAIL_SECURE=true
MAIL_USER=替换成SMTP用户名
MAIL_PASSWORD=替换成SMTP密码
Then add the following fields to ghost.environment:
mail__transport: SMTP
mail__from: "${MAIL_FROM}"
mail__options__host: "${MAIL_HOST}"
mail__options__port: "${MAIL_PORT}"
mail__options__secure: "${MAIL_SECURE}"
mail__options__auth__user: "${MAIL_USER}"
mail__options__auth__pass: "${MAIL_PASSWORD}"
Rebuild the Ghost container and check the logs:
docker compose up -d ghost
docker compose logs --tail=100 ghost
Don't just test that "the SMTP port is reachable." Actually invite a test staff member, then go through a password reset or member login link. The sending domain also needs SPF, DKIM and DMARC configured, otherwise the app may report success while the email still lands in spam.
This Compose setup provides the core Ghost blog, MySQL and HTTPS. Ghost 6's full self-hosted Web Analytics, ActivityPub and similar features depend on the official new Docker Compose toolchain still marked as preview, which additionally deploys Caddy, Tinybird and related services.
If you're just writing a blog, using themes, memberships and the Content API, you don't need to cram in every component for the sake of "full features." When you genuinely want self-hosted analytics or the Social Web, evaluate resources, migration and rollback separately per Ghost's official Docker preview docs — don't mix the two orchestration stacks directly into one file.
First create a backup directory:
cd /opt/ghost
mkdir -p backups
Do a consistent dump with the MySQL root account and compress it on the host:
docker compose exec -T db sh -c \
'mysqldump --single-transaction --quick --routines --triggers \
--databases "$MYSQL_DATABASE" -uroot -p"$MYSQL_ROOT_PASSWORD"' \
| gzip > "backups/ghost-db-$(date +%F-%H%M%S).sql.gz"
Then back up the content volume holding images, themes and route configuration:
docker run --rm \
-v ghost_content:/source:ro \
-v "$PWD/backups:/backup" \
alpine sh -c \
'tar -czf /backup/ghost-content-$(date +%F-%H%M%S).tar.gz -C /source .'
Also save encrypted copies of compose.yaml and .env. If the backup directory stays on the same VPS, a disk failure loses everything with it; copy to another machine or object storage and set a retention period.
You can test recovery on a non-production VPS first. The database dump includes the create-database statement, so stop Ghost before importing:
docker compose stop ghost
gzip -dc backups/ghost-db-YYYY-MM-DD-HHMMSS.sql.gz \
| docker compose exec -T db sh -c \
'mysql -uroot -p"$MYSQL_ROOT_PASSWORD"'
docker compose start ghost
Restoring the content volume overwrites existing files, so you must confirm you're using the right backup and a test environment. After restoring, actually open the admin panel, article images and theme pages — don't just check that the import command returned 0. For a more complete cross-machine drill, see the VPS backup and recovery drill guide.
Back up the database and content volume before even minor updates:
cd /opt/ghost
docker compose pull ghost
docker compose up -d ghost
docker compose ps
docker compose logs --tail=200 ghost
Because the image is pinned to ghost:6-alpine, this set of commands only updates within Ghost 6. When preparing for a major version jump, first update the current major version to its latest minor, check Ghost's breaking changes, and validate custom themes with GScan. Don't just change 6 to latest and rebuild the production site.
Post-upgrade acceptance should at least cover: admin login, article editing, image upload, member login email, theme homepage and API integrations. A container without a restart loop only proves the process started, not that the blog is fully functional.
docker compose ps
docker compose logs --tail=200 db
docker compose exec db mysqladmin ping -h 127.0.0.1 -uroot -p
First check whether MySQL is healthy, then verify .env against Ghost's database account. Once the MySQL data volume has been initialized, changing MYSQL_PASSWORD won't automatically change the old password in the database; don't assume rebuilding the container recreates the account.
docker compose logs --tail=200 ghost
curl -I http://127.0.0.1:2368
sudo journalctl -u caddy --since '15 minutes ago' --no-pager
If port 2368 isn't reachable even locally, the problem is in Ghost or MySQL; if it works locally but the domain returns 502, check Caddy and the firewall. Don't reinstall the whole stack right away.
First confirm that GHOST_URL in .env uses https://, and check whether the reverse proxy passes X-Forwarded-Proto: https. Caddy handles this by default; with a second CDN layer or another proxy in front, keep checking the upstream protocol headers.
The Caddy example limits the request body to 50MB. If your file is larger, first confirm it's reasonable, then adjust the proxy and Ghost limits together. Don't set it to unlimited just for convenience — a public admin panel can still be exhausted of disk and memory by large requests.
- Both
dbandghostshow healthy; - 2368 only listens on
127.0.0.1, and 3306 has no public port; - HTTP automatically redirects to HTTPS, with no redirect loop in the admin panel;
- You can create an administrator, publish articles, upload images and switch themes;
- Transactional email actually arrives, and SPF, DKIM and DMARC checks pass;
- Ghost and MySQL recover automatically after a VPS reboot;
- The database and content volume have been copied to another machine and a recovery test has been completed.
For production health checks, log limits and rollback principles, continue with the VPS Docker Compose production configuration checklist.
If your goal is to focus on writing and running memberships, a 2-core 2GB VPS can run Ghost. What really determines whether this blog is usable long-term isn't how fast the homepage is, but whether URLs, email, MySQL, the image volume, upgrades and recovery all work together. Validate these links end to end before migrating your official domain and historical articles.