How to Deploy Ghost on a VPS
Updated Oct 2026
verified on Ubuntu 26.04 · Oct 2026Self-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.
- 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
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.
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:
- 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.
- Under Settings → Membership, decide whether signups are open, invite only or off, before you announce the site.
- Connect Stripe under Settings → Membership only after HTTPS and the mail setup below work, since members and payments depend on both.
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.