Skip to content

How to Deploy Ghost on a VPS

Updated Oct 2026

verified on Ubuntu 26.04 · Oct 2026
We earn commissions when you shop through the links below. Full disclosure →

Self-host Ghost on a small VPS — the Node.js publishing platform for blogs and paid newsletters, run with MySQL in Docker Compose behind Caddy HTTPS, with a restore-tested backup, a mail setup and a pinned-image upgrade path.

Before you start
  • A VPS with 1 GB+ RAM (Ghost's own floor is 1 GB; MySQL, Docker and the reverse proxy share that, so 2 GB is more comfortable)
  • A fresh Ubuntu 24.04 or 26.04 server with root/sudo SSH access
  • A domain or subdomain you can point at the server
Need a box for this guide? Kamatera's free tier lets you spin one up now.Start free on Kamatera → (opens in new tab)

What Ghost is

Ghost is an open-source publishing platform for blogs and paid newsletters. It has a fast editor, themes, built-in member signups, email newsletters and Stripe-backed subscriptions, all in one Node.js application with a MySQL database. It is MIT-licensed and rated 2 / 5 to deploy. If you want a WordPress alternative that is built around writing and membership rather than plugins, this is the one to look at.

Ghost's own tooling (ghost-cli) installs it directly on a server, and its Docker image is the other supported route. This guide uses Docker Compose: three containers (Ghost, MySQL, Caddy), named volumes for state, and no packages installed on the host beyond Docker. It picks up from "you have a server" and ends with the site on HTTPS, a tested backup and an upgrade routine. For help choosing a provider and tier, see Best VPS for self-hosting.

Server sizing

Ghost's documented floor is 1 GB of RAM, and that is what the catalog lists. In this guide's verification run (a 2-vCPU, 8 GB test box with an empty site, idle after setup), docker stats showed roughly 220 MB for the Ghost container, 480 MB for MySQL and 15 MB for Caddy, about 0.7 GB in total before the operating system. Your numbers will differ with traffic, theme and the number of members: MySQL is the larger share, and Node.js grows with the size of your publication. A 1 GB server works for a small site if you add a swap file; 2 GB is the comfortable choice, and you should move up if docker stats shows the box under memory pressure.

On disk, the three images came to about 2.3 GB and the volumes about 0.5 GB on that same empty site. Disk then grows with the images and media you upload, which land in Ghost's content volume, plus the database. Size the disk for your media library, not for RAM.

Prepare the server

Update the system and install the few tools this guide uses:

sudo apt-get update && sudo apt-get -y upgrade
sudo apt-get install -y curl openssl ufw dnsutils

If you're logged in as root, create a non-root sudo user and continue as that user:

adduser deploy
usermod -aG sudo deploy

Then open only SSH and the two ports the reverse proxy needs:

sudo ufw allow OpenSSH
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw --force enable

Ghost will be reachable only through the reverse proxy. That matters on a Docker host: a published container port goes around ufw entirely, so the Compose file below binds Ghost to 127.0.0.1, and Caddy becomes the only public door.

Where to host itaffiliate disclosure
Hetzner Cloudrun it on
2 vCPU · 4 GB RAM · 80 GB SSD · $23.59/mo
Get Hetzner Cloud (opens in new tab)
Kamaterafree trial
1 vCPU · 1 GB RAM · 20 GB SSD · $4.00/mo
Start free on Kamatera → (opens in new tab)
DigitalOceanalso works on
1 vCPU · 1 GB RAM · 25 GB SSD · $6.00/mo
Deploy on DigitalOcean → (opens in new tab)

Paid link — we earn a commission if you shop through it.

Install Docker

If Docker isn't on the server yet, the convenience script installs Docker Engine and the Compose plugin (the Docker and Compose on Ubuntu guide covers the details):

command -v docker >/dev/null || curl -fsSL https://get.docker.com | sudo sh
sudo usermod -aG docker "$USER"
sudo docker compose version

The group change applies on your next login; until then the commands below use sudo docker.

Install Ghost

Create a project directory and generate the database password and your hostname into a .env file that Compose reads automatically. Set your hostname first (this guide uses blog.example.com; replace it with yours). The guard keeps the step safe to re-run: regenerating the password on an existing install would lock Ghost out of its own database.

mkdir -p ~/ghost && cd ~/ghost
DOMAIN=blog.example.com
[ -f .env ] || printf 'DB_PASS=%s\nDOMAIN=%s\n' "$(openssl rand -hex 16)" "$DOMAIN" > .env
chmod 600 .env
grep -c DB_PASS .env

Now the Compose file. It follows the Docker Hub example for the official Ghost image, with four changes: the image tags are pinned (so an upgrade is a deliberate edit, not a surprise), Ghost connects with its own database user instead of MySQL's root account, the url is your real HTTPS address, and Ghost binds to loopback with Caddy in front. The url setting matters more than usual: Ghost builds every link, RSS entry and email from it, so it must be the public address you will actually use.

cd ~/ghost
cat > docker-compose.yml <<'EOF'
services:
  ghost:
    image: ghost:6.69.0-alpine
    restart: always
    ports: ["127.0.0.1:2368:2368"]
    depends_on:
      db:
        condition: service_healthy
    environment:
      database__client: mysql
      database__connection__host: db
      database__connection__user: ghost
      database__connection__password: ${DB_PASS}
      database__connection__database: ghost
      url: https://${DOMAIN}
    volumes: ["content:/var/lib/ghost/content"]
  db:
    image: mysql:8.4
    restart: always
    environment:
      MYSQL_DATABASE: ghost
      MYSQL_USER: ghost
      MYSQL_PASSWORD: ${DB_PASS}
      MYSQL_RANDOM_ROOT_PASSWORD: "1"
    volumes: ["db:/var/lib/mysql"]
    healthcheck:
      test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -u$$MYSQL_USER -p$$MYSQL_PASSWORD --silent"]
      start_period: 30s
      interval: 10s
      timeout: 5s
      retries: 10
  caddy:
    image: caddy:2
    restart: always
    ports: ["80:80", "443:443"]
    environment:
      DOMAIN: ${DOMAIN}
    volumes: ["./Caddyfile:/etc/caddy/Caddyfile:ro", "caddy_data:/data"]
    depends_on: [ghost]
volumes:
  content:
  db:
  caddy_data:
EOF
cat > Caddyfile <<'EOF'
{$DOMAIN} {
    encode zstd gzip
    reverse_proxy ghost:2368
}
EOF

Start it and wait for Ghost to answer. The first boot runs Ghost's database migrations, which takes a minute or two, so the loop below is bounded rather than open-ended:

cd ~/ghost
sudo docker compose up -d
code=000
for i in $(seq 1 60); do
  code=$(curl -s -o /dev/null -w '%{http_code}' -H 'X-Forwarded-Proto: https' http://127.0.0.1:2368/ghost/api/admin/site/)
  [ "$code" = "200" ] && break
  sleep 3
done
echo "Ghost answered HTTP $code"
sudo docker compose ps

The X-Forwarded-Proto header stands in for Caddy: Ghost redirects any plain http request to the https address in url (a 301), unless the request says it already arrived over HTTPS. HTTP 200 and the db container healthy means the stack works. If it never answers, jump to Troubleshooting.

HTTPS + domain

Create an A record for your site's hostname (say blog.example.com) pointing at the server's public IP, and confirm it resolves before Caddy tries to get a certificate:

dig +short blog.example.com

Caddy is already running. It requests the certificate as soon as the name resolves and renews it on its own (see Automatic HTTPS with Caddy for how that works). Check that HTTPS answers, retrying while the certificate is issued:

. ~/ghost/.env
for i in $(seq 1 20); do
  out=$(curl -s -o /dev/null -w '%{http_code} %{ssl_verify_result}' "https://$DOMAIN/ghost/api/admin/site/") && [ "${out%% *}" = "200" ] && break
  sleep 5
done
echo "HTTPS status and certificate check: $out"

A second number of 0 means the certificate verified.

First login and hardening

Ghost has no default account. The first person to open the setup page creates the owner, so do it immediately after HTTPS works, before anyone else finds the address. Open https://blog.example.com/ghost, enter the site title, your name and email, and a long random password. Ghost reports whether setup is still open, which you can check from the terminal:

. ~/ghost/.env
curl -s "https://$DOMAIN/ghost/api/admin/authentication/setup/"; echo

"status":false means no owner exists yet; after you finish setup it reads true. Then:

  1. Turn on two-factor authentication for staff logins under Settings → Staff, and invite other writers as Authors or Editors rather than sharing the owner login.
  2. Under Settings → Membership, decide whether signups are open, invite only or off, before you announce the site.
  3. Connect Stripe under Settings → Membership only after HTTPS and the mail setup below work, since members and payments depend on both.

Mail

Ghost needs outgoing email for staff invitations, password resets, member sign-in links and newsletters. Without it, the site works but those flows fail. Add a transactional mail provider's SMTP details to the ghost service environment in docker-compose.yml (the values below are placeholders for your provider's):

      mail__transport: SMTP
      mail__options__host: smtp.example.com
      mail__options__port: 587
      mail__options__secure: "false"
      mail__options__auth__user: your-smtp-user
      mail__options__auth__pass: your-smtp-password
      mail__from: "Your Site <noreply@example.com>"

Run sudo docker compose up -d to apply it. Newsletter bulk email to members uses a separate setting (Mailgun) in Ghost's admin; Ghost's documentation covers it. The mail configuration was not tested in this guide's verification run, because it needs a live provider account.

Backups

State lives in two named volumes: the MySQL data and the content volume (images, media, themes and settings files). Back up both. Start with a database dump, which is portable across MySQL versions:

cd ~/ghost
sudo docker compose exec -T db sh -c \
  'mysqldump --single-transaction --no-tablespaces -u"$MYSQL_USER" -p"$MYSQL_PASSWORD" "$MYSQL_DATABASE"' \
  > ~/ghost-db-$(date +%F).sql
chmod 600 ~/ghost-db-$(date +%F).sql
test -s ~/ghost-db-$(date +%F).sql && ls -lh ~/ghost-db-*.sql

Then archive the content volume and the Compose configuration. The configuration archive holds .env, which contains the database password, so the commands also restrict both archives to their owner. The volume name is the project directory name plus the volume name, here ghost_content:

cd ~/ghost
sudo docker run --rm -v ghost_content:/data -v "$HOME":/backup alpine \
  tar czf "/backup/ghost-content-$(date +%F).tar.gz" -C /data .
sudo tar czf ~/ghost-config-$(date +%F).tar.gz docker-compose.yml Caddyfile .env
sudo chmod 600 ~/ghost-content-*.tar.gz ~/ghost-config-*.tar.gz
ls -lh ~/ghost-content-*.tar.gz ~/ghost-config-*.tar.gz

Copy these files off the server, to object storage, another machine, anywhere that survives this VPS dying, and run the set from cron. A backup you have never restored is a guess, so prove the dump loads. This starts a throwaway MySQL container, imports the dump and counts the rows in Ghost's users table:

cd ~
sudo docker rm -f ghost-restore-test >/dev/null 2>&1
sudo docker run -d --name ghost-restore-test -e MYSQL_ROOT_PASSWORD=scratch mysql:8.4 >/dev/null
M="sudo docker exec -e MYSQL_PWD=scratch ghost-restore-test"
for i in $(seq 1 40); do
  $M mysqladmin ping -h 127.0.0.1 -uroot --silent && break
  sleep 3
done
$M mysql -uroot -e "CREATE DATABASE ghost"
sudo docker exec -i -e MYSQL_PWD=scratch ghost-restore-test mysql -uroot ghost < ~/ghost-db-$(date +%F).sql
$M mysql -uroot -N -e "SELECT COUNT(*) FROM ghost.users"
sudo docker rm -f ghost-restore-test >/dev/null

It should print a count of at least 1. The check proves the dump loads and the tables are populated; it does not prove the content archive restores. For a real restore onto a new server, install Docker, unpack the config archive into ~/ghost, start only the database (sudo docker compose up -d db), pipe the dump into it with sudo docker compose exec -T db sh -c 'mysql -u"$MYSQL_USER" -p"$MYSQL_PASSWORD" "$MYSQL_DATABASE"', unpack the content archive into the content volume, then start everything. Ghost's admin also offers a JSON export under Settings → Labs, which is handy for moving posts but does not include images or member data on its own.

Upgrades

Because the image tag is pinned, an upgrade is an edit. Check Ghost's release notes, take a backup (previous section), change the Ghost tag in docker-compose.yml, then pull and recreate:

cd ~/ghost
sudo docker compose pull
sudo docker compose up -d
sudo docker compose logs --tail 20 ghost

Ghost runs its database migrations automatically on the first start of a new version, and those are one-way, which is why the backup comes first. Move one major version at a time and read the notes for that major before you do. Keep the mysql tag pinned too; a new MySQL major is a dump-and-restore job, not a tag bump, and Ghost only supports the MySQL versions its documentation lists. Check your theme against the new version in the admin's theme screen after the core update.

Troubleshooting

Ghost returns 502 through Caddy, or never answers. Check sudo docker compose ps: if ghost is restarting, read sudo docker compose logs ghost. The usual cause is a database connection error, which happens when .env was regenerated after the database volume already existed with the old password. Restore the original .env, or remove the volumes if the install holds no data yet.

Links, images or emails point at the wrong address. Ghost builds them from url. Fix DOMAIN in .env, run sudo docker compose up -d, and clear your browser cache. A trailing mismatch between http and https is the classic cause of a redirect loop.

Caddy never gets a certificate. Confirm dig +short blog.example.com returns the server's IP and that your provider's network-level firewall, not just ufw, allows inbound 80 and 443. A blocked port 80 silently breaks the ACME challenge.

Staff invitations and member sign-in emails never arrive. Mail is not configured, or the provider rejects the sender. Check sudo docker compose logs ghost for SMTP errors, and verify the sending domain with your provider.

Uploads and themes disappear after recreating the stack. The volumes were removed (docker compose down -v). Plain docker compose down keeps them; the -v flag deletes them.

Verification + next steps

You're done when https://blog.example.com loads with a valid certificate, /ghost signs you in as the owner you created, docker compose ps shows every service up after a reboot (restart: always brings them back), and you have restored a backup onto a scratch box at least once.

From there, pick or upload a theme, write your first post, set up the newsletter and a custom domain for email, and connect Stripe if you want paid memberships. If you are weighing Ghost against a plugin-driven CMS, compare it with the WordPress guide in this series, and see Best VPS for self-hosting and the Ghost entry for the ranked server picks and catalog details.

Next steps

How to self-host Ghost →More self-hosted cms & blogging tools →Automatic HTTPS with Caddy →Run Claude Code with Ollama on Your Own VPS →Deploy Coolify on a VPS →How to Deploy Actual Budget on a VPS →How to Deploy AFFiNE on a VPS →How to Deploy AnythingLLM on a VPS →How to Deploy Appwrite on a VPS →How to Deploy Audiobookshelf on a VPS →How to Deploy Authelia on a VPS →How to Deploy authentik on a VPS →How to Deploy Baserow on a VPS →How to Deploy Beszel on a VPS →How to Deploy Bitwarden on a VPS →How to Deploy BookStack on a VPS →How to Deploy CapRover on a VPS →How to Deploy Checkmate on a VPS →How to Deploy copyparty on a VPS →How to Deploy Directus on a VPS →How to Deploy Discourse on a VPS →How to Deploy docker-mailserver on a VPS →How to Deploy Docmost on a VPS →How to Deploy Dokku on a VPS →How to Deploy Dokploy on a VPS →How to Deploy Firefly III on a VPS →How to Deploy Flarum on a VPS →How to Deploy Forgejo on a VPS →How to Deploy Gatus on a VPS →How to Deploy Ghostfolio on a VPS →How to Deploy Gitea on a VPS →How to Deploy a GitHub Actions Self-Hosted Runner on a VPS →How to Deploy GitLab on a VPS →How to Deploy GlitchTip on a VPS →How to Deploy Grafana on a VPS →How to Deploy Graylog on a VPS →How to Deploy Headscale on a VPS →How to Deploy Healthchecks on a VPS →How to Deploy Home Assistant on a VPS →How to Deploy Immich on a VPS →How to Deploy Infisical on a VPS →How to Deploy Invoice Ninja on a VPS →How to Deploy Jan on a VPS →How to Deploy Jellyfin on a VPS →How to Deploy Kanboard on a VPS →How to Deploy Karakeep on a VPS →How to Deploy Keycloak on a VPS →How to Deploy Leantime on a VPS →How to Deploy LibreChat on a VPS →How to Deploy Linkwarden on a VPS →How to Deploy LocalAI on a VPS →How to Deploy Mailcow on a VPS →How to Deploy Mailu on a VPS →How to Deploy Matomo on a VPS →How to Deploy Mattermost on a VPS →How to Deploy Meilisearch on a VPS →How to Deploy Memos on a VPS →How to Deploy a Minecraft Server on a VPS →How to Deploy n8n on a VPS →How to Deploy Navidrome on a VPS →How to Deploy NetBird on a VPS →How to Deploy Netdata on a VPS →How to Deploy Nextcloud on a VPS →How to Deploy Next.js to a VPS →How to Deploy Nginx Proxy Manager on a VPS →How to Deploy NocoDB on a VPS →How to Deploy ntfy on a VPS →How to Deploy Odoo on a VPS →How to Deploy Ollama on a VPS →How to Deploy ONLYOFFICE Docs on a VPS →How to Deploy Open WebUI on a VPS →How to Deploy OpenHands on a VPS →How to Deploy OpenObserve on a VPS →How to Deploy OpenProject on a VPS →How to Deploy Outline on a VPS →How to Deploy Pangolin on a VPS →How to Deploy Paperless-ngx on a VPS →How to Deploy Passbolt on a VPS →How to Deploy Plane on a VPS →How to Deploy Plausible Analytics on a VPS →How to Deploy Plex Media Server on a VPS →How to Deploy Pocket ID on a VPS →How to Deploy PocketBase on a VPS →How to Deploy Prometheus on a VPS →How to Deploy Psono on a VPS →How to Deploy Radarr on a VPS →How to Deploy Rocket.Chat on a VPS →How to Deploy SigNoz on a VPS →How to Deploy Sonarr on a VPS →How to Deploy Stalwart on a VPS →How to Deploy Stirling-PDF on a VPS →How to Deploy Supabase on a VPS →How to Deploy Synapse on a VPS →How to Deploy Taiga on a VPS →How to Deploy TeamPass on a VPS →How to Deploy Tinyauth on a VPS →How to Deploy Traefik on a VPS →How to Deploy Trilium on a VPS →How to Deploy Twenty CRM on a VPS →How to Deploy Umami on a VPS →How to Deploy Uptime Kuma on a VPS →How to Deploy Vaultwarden on a VPS →How to Deploy Vikunja on a VPS →How to Deploy wg-easy on a VPS →How to Deploy Wiki.js on a VPS →How to Deploy WordPress on a VPS →How to Deploy Zabbix on a VPS →How to Deploy Zitadel on a VPS →How to Deploy Zulip on a VPS →Docker & Compose on Ubuntu 26.04 →Building AI Workflows with n8n →Install Open WebUI with Ollama →Adding AI-Powered Insights to Plausible Analytics →Building AI-Powered Apps with Supabase and pgvector →

We use analytics cookies (Google Analytics, PostHog) to see which guides are useful. No ad networks, no cross-site tracking. See our privacy policy.