Can I self-host Ghost(Pro)?
YES · ONE EVENING— setup effort 2 of 4YES — it's called Ghost. It takes one prompt, a 2048 MB VPS, and about 90 minutes. That is $18 a month you stop paying Ghost(Pro) — $216 a year on the Starter plan.
Why people pay for Ghost(Pro)
Stated as the vendor would want it stated. A replacement you pick without knowing what the subscription actually buys is a replacement you abandon in a fortnight.
Ghost(Pro) is the company that writes Ghost running Ghost for you, and the money goes back into the software everyone else self-hosts. What you rent is the boring half: a database somebody else backs up, a CDN in front of your images, deliverability for a newsletter that has to land in inboxes rather than spam folders, and upgrades that happen without you reading migration notes. The editor is identical either way. The pager is not.
| Plan | List price | What it buys |
|---|---|---|
| Starterthe plan this page prices against | $18/mo | $180 a year on the annual plan, shown on the page as $15/month billed yearly. Covers 1,000 members and one staff user, on official themes only. |
| Publisher | $35/mo | The $35 figure is the 1,000-member tier; the member slider raises it to $55 at 2,500, $105 at 10,000 and $329 at 100,000. $348 a year on the annual plan, shown as $29/month billed yearly. Adds custom themes and paid subscriptions. |
| Business | $239/mo | The $239 figure holds to 10,000 members, then rises to $319 at 25,000 and $479 at 100,000. $2,388 a year on the annual plan, shown as $199/month billed yearly. Adds priority support and higher usage limits. |
| Custom | quote only | Quote only. The page lists unlimited members and staff, a dedicated IP address and a 99.9% uptime SLA. |
Vendor list prices in USD, read from the pricing page on 2026-08-05 · confidence: medium
Replaced by Ghost
One project, named before the prompt, so you know what you are about to install.
The same publishing platform Ghost(Pro) hosts, on your own box, with the editor, the themes and the members list intact.
The cleanest answer on this site, because it is not an alternative: it is the same software. Ghost is MIT-licensed and Ghost(Pro) runs the identical codebase, so the editor, the themes, the members list, the Stripe integration and the newsletter engine all come with you. What does not come with you is the part you were paying for. You now operate a MySQL 8 you have to dump, you pick and pay an SMTP provider before a single newsletter sends, and you decide which morning to apply a major version that migrates its own database.
The swap
You'd run
Ghost
ONE EVENING · ~90 min to running · 2048 MB RAM
Ghost(Pro) Starter · vendor list price · checked 2026-08-05 · source · confidence: medium
Before you start
- RAM floor
- 2048 MBfloor from upstream docs — not measured by us yet
- Disk
- 10 GBthe app, its data, and room for one backup
- Domain needed
- yes, one A recorda hostname pointed at the box before you start — TLS needs it on the cloud path, and the local path needs none
- Time budget
- ~90 min1–3 hours, through the first backup
The prompt
Two paths to the same Ghost: the cloud one assumes Prompt Zero is done on a server you rent, the local one assumes nothing but a computer that can run Docker Desktop. Read whichever you pick before you paste it, which is the whole reason both are on the page instead of behind a download.
Where it runs
326 lines · 14,803 bytes
What this prompt will do
- Preflight
- Layout
- Secrets
- compose.yml
- Caddy and TLS
- Firewall
- Start and verify
- First backup and restore
- Updating later
- What will probably go wrong
- Out of scope
Read out of the prompt’s own step headings at build time — if the prompt changes, this list changes with it.
You are Claude Code on the user's machine. The user has completed Prompt Zero: `ssh vps` works,
Docker and Caddy are installed, the firewall is default-deny.
Run every command in this prompt on the server over `ssh vps` unless the step says otherwise.
Install Ghost 6.56.0 on that server, reachable at https://<DOMAIN>, behind the existing Caddy
with automatic TLS.
## 1. Preflight
If `<DOMAIN>` is still literal, ask the user for the hostname once and stop until they answer.
Its A record must already point at this server. Say this when you ask: the hostname becomes
Ghost's `url`, and every canonical link, RSS item and newsletter footer is built from it, so
moving the publication later means editing two files and reissuing a certificate.
Ghost plus MySQL 8 needs 2048 MB of RAM available and 10 GB free on /srv. Upstream's own
prerequisites ask for at least 1 GB, their floor for one machine running everything; two
containers, one of them MySQL 8, is where the OOM killer starts arriving mid-upload, so this
prompt asks for double. Both images publish amd64 and arm64. Measure all four first:
```bash
free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}'
df -BG --output=avail /srv | tail -1
dpkg --print-architecture
dig +short <DOMAIN>
```
If available RAM is under 2048 MB or free disk is under 10 GB, print both numbers and stop. Do
not install and hope. If `dig +short` prints nothing, print that and stop: Caddy cannot get a
certificate for a name that does not resolve.
## 2. Layout
```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/ghost /srv/ghost/backups
sudo install -d -m 750 /srv/ghost/content
sudo install -d -m 700 /srv/ghost/mysql
ls -la /srv/ghost
```
Assert: `ls -la` shows `backups` owned by the login user, and `content` and `mysql` owned by
root. Leave both alone. The Ghost image's entrypoint starts as root, chowns its content
directory to the `node` user it runs as, then drops privileges; the MySQL image does the same
for its data directory. One you have already chowned to yourself makes MySQL refuse to
initialise.
## 3. Secrets
Two secrets: the MySQL root password and the password for the `ghost` database user. Generate
both on the server. Do not print either, do not repeat them in your summary, and do not put them
in any log line. Hex rather than base64, because both travel inside connection strings and one
is read back by a shell in the backup step.
```bash
umask 077
cat > /srv/ghost/.env <<EOF
GHOST_URL=https://<DOMAIN>
MYSQL_ROOT_PASSWORD=$(openssl rand -hex 32)
GHOST_DB_PASSWORD=$(openssl rand -hex 32)
EOF
chmod 600 /srv/ghost/.env
umask 022
ls -l /srv/ghost/.env
```
Assert: the file exists with mode `-rw-------`. Replace `<DOMAIN>` on the first line with the
real hostname before writing it. Compose reads this file on its own for both services, but only
when it is run from /srv/ghost, so every docker command below is preceded by a `cd`. Tell the
user they can read either value with `sudo grep MYSQL_ROOT_PASSWORD /srv/ghost/.env`, and that
neither is a login they will type into a browser: the account they write with comes in step 7.
## 4. compose.yml
```bash
cat > /srv/ghost/compose.yml <<'EOF'
# Ghost · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
# docker install ..... https://docs.ghost.org/install/docker/
# image reference .... https://hub.docker.com/_/ghost
# config reference ... https://docs.ghost.org/config/
# supported databases https://docs.ghost.org/faq/supported-databases/
# mysql image ........ https://hub.docker.com/_/mysql
#
# Two services: Ghost and the MySQL 8 it keeps posts, members and settings in.
# Upstream states MySQL 8 is the only database it supports in production, so the
# SQLite the image can run under NODE_ENV=development is not an option. Ghost
# speaks plain http on 2368 and the host's Caddy terminates TLS in front of it,
# which is why `url` carries the https address: every canonical link, RSS item
# and email footer is built from that one value. Both services read secrets from
# /srv/ghost/.env, which Compose picks up when run from /srv/ghost and nowhere
# else. MySQL 8.4 is the current long-term release and the line Ghost's own
# development compose file runs against. Tags and digests read from the
# registries on 2026-08-05; both images publish amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
services:
mysql:
image: mysql:8.4.11@sha256:b3b90af2a6552ae30c266fdb7d5dd55f3afb72404bb78d37fe8a23eb857fd3fb
container_name: ghost-db
restart: unless-stopped
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
MYSQL_DATABASE: ghost
MYSQL_USER: ghost
MYSQL_PASSWORD: ${GHOST_DB_PASSWORD}
volumes:
- /srv/ghost/mysql:/var/lib/mysql
healthcheck:
# `$$` sends a literal dollar to the container instead of interpolating.
test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -u root -p$$MYSQL_ROOT_PASSWORD --silent"]
interval: 10s
retries: 30
start_period: 60s
# No `ports:`: 3306 is reachable only from the other container.
ghost:
image: ghost:6.56.0-alpine@sha256:57cd95050d3ca05a098c9ae1275c8d62ace1c844aa653494204d1c0e77c0900a
container_name: ghost
restart: unless-stopped
environment:
NODE_ENV: production
url: ${GHOST_URL}
# Two underscores separate nested config levels. Documented mapping.
database__client: mysql
database__connection__host: mysql
database__connection__user: ghost
database__connection__password: ${GHOST_DB_PASSWORD}
database__connection__database: ghost
volumes:
# Posts live in MySQL. Images, themes and uploads live here.
- /srv/ghost/content:/var/lib/ghost/content
ports:
# Loopback only: the host's Caddy is the only thing that reaches 8100.
- "127.0.0.1:8100:2368"
depends_on:
mysql:
condition: service_healthy
EOF
cd /srv/ghost && docker compose config >/dev/null && echo "compose OK"
```
Assert: that prints `compose OK`. A warning that `MYSQL_ROOT_PASSWORD` is not set means the
`cd` did not happen and Compose never found .env; run it again from /srv/ghost.
## 5. Caddy and TLS
Append the block below to the Caddyfile Prompt Zero installed, with `<DOMAIN>` replaced by the
real hostname. Copy the file first: a syntax error takes down every other site on the box.
```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-ghost
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# Ghost · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://docs.ghost.org/config/,
# https://hub.docker.com/_/ghost,
# https://caddyserver.com/docs/caddyfile/directives/reverse_proxy and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed,
# with <DOMAIN> replaced by the hostname pointed at this box. That hostname is
# also the `url` value in .env, and Ghost builds every canonical link, RSS item
# and newsletter footer from it, so the two must always say the same thing.
<DOMAIN> {
# Ghost serves HTML, JSON feeds and theme assets, all of which compress well.
encode zstd gzip
# Upstream asks the proxy in front of Ghost for X-Forwarded-For,
# X-Forwarded-Host and X-Forwarded-Proto. Caddy's reverse_proxy sets all
# three itself and ignores whatever the client sent, so there is nothing to
# add here and nothing a visitor can spoof.
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options "nosniff"
Referrer-Policy "strict-origin-when-cross-origin"
-Server
}
# No frame-blocking header: the Ghost editor previews posts and the members
# portal renders its signup form in same-origin iframes, and a blanket DENY
# breaks both.
# 8100 is the loopback port compose publishes on this host. It is not a
# container port and it is not open in the firewall.
reverse_proxy 127.0.0.1:8100
}
EOF
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```
Assert: `caddy validate` exits 0 and the reload exits 0. If validate fails, restore
/etc/caddy/Caddyfile.before-ghost, reload, and report what it objected to. Caddy requests the
certificate on the first request to the hostname and renews it on its own, so there is nothing
to schedule, and it puts no ceiling on request body size, so theme and image uploads need no
setting here.
## 6. Firewall
Two ports open, both Caddy's. Idempotent, so on a box Prompt Zero configured they change nothing:
```bash
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw status verbose
```
80/tcp redirects to HTTPS and answers the ACME challenge, 443/tcp is the only way in, 443/udp
is HTTP/3. 8100 stays closed because it is bound to 127.0.0.1, and 3306 stays closed because
compose never publishes it: the database has no host port to firewall. Assert:
`ufw status verbose` prints `Status: active`, shows 80, 443/tcp and 443/udp, and no rule for
8100 or 3306.
## 7. Start and verify
Ghost runs its own migrations on first boot against an empty MySQL, and that takes longer than
the container takes to appear in `docker ps`.
```bash
cd /srv/ghost
docker compose pull
docker compose up -d
for i in $(seq 1 40); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/ghost/api/admin/authentication/setup); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS https://<DOMAIN>/ghost/api/admin/authentication/setup
```
Assert both, and print what you received. The loop ends printing `200`. The second command
prints exactly `{"setup":[{"status":false}]}`, upstream's way of saying the site exists and has
no owner yet. If either misses, stop, run `docker compose logs --tail 40 ghost` and
`docker compose logs --tail 20 mysql`, and name the likely cause: a MySQL container that never
reports healthy points at step 2, a `502` that never clears points at step 4, a certificate
error points at the A record from step 1. A running container is not success.
That `status` of `false` is a standing open door: until the first account exists, whoever loads
the setup page owns the publication. Do this now, not tomorrow.
STOP: tell the user to open https://<DOMAIN>/ghost/ and create their account, and wait. Do not
continue until they confirm. The first screen carries the heading `Welcome to Ghost.` above a
form asking for a site title, full name, email address and a password of at least 10 characters.
Tell them the email address is only a login here, because no mail is configured, and the
password goes in their password manager before they submit.
Once they confirm, prove the door is shut:
```bash
curl -sS https://<DOMAIN>/ghost/api/admin/authentication/setup
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/
```
Assert: the first prints exactly `{"setup":[{"status":true}]}` and the second prints `200`. Both
must pass before you report success.
## 8. First backup and restore
Two artifacts. MySQL holds the posts, pages, tags, members and settings. The content archive
holds the images, the themes and the files that rebuild the service around them.
```bash
cd /srv/ghost
docker compose exec -T mysql sh -c 'exec mysqldump -u root -p"$MYSQL_ROOT_PASSWORD" --single-transaction --routines --triggers ghost' | gzip > /srv/ghost/backups/ghost-db-$(date +%F).sql.gz
sudo tar -C /srv/ghost -czf /srv/ghost/backups/ghost-content-$(date +%F).tar.gz content compose.yml .env -C /etc/caddy Caddyfile
ls -lh /srv/ghost/backups/
```
Assert: both files exist and both are non-empty. Print both sizes. The password expands in a
shell inside the container, so it never reaches this machine's history; mysqldump still prints
one warning line about passwords on the command line, and that line is expected. Nothing goes
offline: `--single-transaction` snapshots a running InnoDB database consistently.
A backup on the same disk is not a backup, so run this from the user's machine:
```bash
mkdir -p ~/backups/ghost
scp vps:/srv/ghost/backups/* ~/backups/ghost/
```
To restore: `cd /srv/ghost`, `docker compose down`, `sudo rm -rf /srv/ghost/mysql`, recreate it
as in step 2, untar the content archive back into /srv/ghost, `docker compose up -d mysql`, wait
for healthy, pipe `gunzip -c` on the `.sql.gz` into
`docker compose exec -T mysql sh -c 'exec mysql -u root -p"$MYSQL_ROOT_PASSWORD" ghost'`, then
`docker compose up -d`. The order matters: the archive carries .env, and MySQL takes its
passwords from that file the moment it initialises an empty data directory.
## 9. Updating later
New versions are listed at https://github.com/TryGhost/Ghost/releases and the matching digest
is on https://hub.docker.com/_/ghost. Take both backups first, then edit the image line in
/srv/ghost/compose.yml to the new tag and its digest:
```bash
cd /srv/ghost
docker compose pull
docker compose up -d
docker compose logs --tail 30 ghost
```
Ghost migrates its own database on the way up, so watch that log until it settles, then re-run
the setup check from step 7 before calling the update done. Reach the last release of a major
version before crossing to the next: upstream states that skipping ahead is where database
errors come from.
## 10. What will probably go wrong
The first `docker compose up -d` looks like a failed install for about a minute. I watched
`docker ps` show both containers up while https://<DOMAIN> returned `502 Bad Gateway`, and had
the Caddy config open hunting for a typo before it cleared on its own. Nothing was wrong: MySQL
spends its first 30 to 60 seconds initialising an empty data directory and refuses connections
until it has finished, and Ghost then runs its whole migration set before it binds 2368. That is
what the 40-iteration loop in step 7 is for. Do not restart anything, do not edit the Caddyfile,
and do not conclude the digest is wrong. Watch `docker compose logs -f ghost` and let it finish.
## 11. Out of scope
- Do not configure SMTP. Ghost publishes and serves the site without it, and mail is a provider
choice with its own DNS records, made after the user has written something.
- Do not enable the analytics or ActivityPub profiles from Ghost's own compose repository. Each
adds containers and an outside account, and this prompt installs the publication.
- Do not switch the database to SQLite. Upstream supports it in development mode only, and the
image rejects it under NODE_ENV=production.
- Do not run Ghost-CLI commands inside the container. The official image documents that most of
them are not designed to work there, and `ghost update` fights the digest pin.No terminal agent? Use the chat fallback — slower, you paste the commands
For ChatGPT or Claude in a browser. The model cannot touch your server, so it hands you one command at a time and you run each one. Same install, more of your evening.
This path is slower: you paste every command yourself, and there is nobody watching the
output but you. If you can run Claude Code, use the other tab.
You are installing Ghost 6.56.0 on a VPS where Prompt Zero is done: `ssh vps` works, Docker
and Caddy are installed, the firewall is default-deny. Run everything over `ssh vps` unless a
step says otherwise, and replace `<DOMAIN>` with the hostname whose A record already points at
the box.
Read this before step 1. `<DOMAIN>` becomes Ghost's `url`, and Ghost builds every canonical
link, RSS item and newsletter footer from that one value. Moving the publication later means
editing two files and reissuing a certificate, so pick the hostname you intend to keep.
## 1. Preflight
```bash
free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}'
df -BG --output=avail /srv | tail -1
dpkg --print-architecture
dig +short <DOMAIN>
```
You should see: at least `2048` MB available, at least `10` G free, `amd64` or `arm64`, and
your server's IP on the last line.
If you do not: an empty last line means the A record does not exist yet. Add it, wait a
minute, run `dig +short <DOMAIN>` again. Caddy cannot get a certificate for a hostname that
does not resolve, and failed attempts count against a rate limit you cannot see. On memory,
Ghost's own prerequisites ask for 1 GB, which is their figure for one machine running
everything; two containers with MySQL 8 among them is where the OOM killer arrives during an
upload, so 2 GB is the floor here. A 1 GB box will appear to work and then fail on the day you
upload a batch of images or install a theme.
## 2. Layout
```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/ghost /srv/ghost/backups
sudo install -d -m 750 /srv/ghost/content
sudo install -d -m 700 /srv/ghost/mysql
ls -la /srv/ghost
```
You should see: `backups` owned by you, `content` at mode `drwxr-x---` owned by root, and
`mysql` at mode `drwx------` owned by root.
If you do not: leave `content` and `mysql` owned by root on purpose. The Ghost image's
entrypoint starts as root, chowns its content directory to the `node` user it runs as, and
then drops privileges; the MySQL image does the same for its data directory. One you have
already chowned to yourself makes MySQL refuse to initialise.
## 3. Secrets
Two secrets: the MySQL root password and the password for the `ghost` database user. Both are
generated here, on the server, and both go straight into a file only you can read. Hex rather
than base64, because both travel inside connection strings.
```bash
umask 077
cat > /srv/ghost/.env <<EOF
GHOST_URL=https://<DOMAIN>
MYSQL_ROOT_PASSWORD=$(openssl rand -hex 32)
GHOST_DB_PASSWORD=$(openssl rand -hex 32)
EOF
chmod 600 /srv/ghost/.env
umask 022
ls -l /srv/ghost/.env
```
You should see: mode `-rw-------`, your own username twice, and the path. Replace `<DOMAIN>` on
the first line with your real hostname before you paste. Neither value is a login you will ever
type into a browser: the account you write with is created in step 7.
If you do not: a mode of `-rw-r--r--` means `umask 077` did not take effect, which happens if
you pasted the lines separately in different shells. Run `chmod 600 /srv/ghost/.env` and carry
on. If the file already existed from an earlier attempt, this block has now overwritten both
passwords, which is fine before the database exists and a problem afterwards: MySQL keeps the
passwords it was created with, so a changed value on an existing data directory shows up as an
access-denied line in the Ghost log rather than anything about passwords.
Do not paste that file, either password, or any output containing them into this chat window.
Read a value with `sudo grep MYSQL_ROOT_PASSWORD /srv/ghost/.env` only when the chat window is
closed, and keep it in your password manager.
## 4. compose.yml
Paste the whole block at once, including the last two lines.
```bash
cat > /srv/ghost/compose.yml <<'EOF'
# Ghost · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
# docker install ..... https://docs.ghost.org/install/docker/
# image reference .... https://hub.docker.com/_/ghost
# config reference ... https://docs.ghost.org/config/
# supported databases https://docs.ghost.org/faq/supported-databases/
# mysql image ........ https://hub.docker.com/_/mysql
#
# Two services: Ghost and the MySQL 8 it keeps posts, members and settings in.
# Upstream states MySQL 8 is the only database it supports in production, so the
# SQLite the image can run under NODE_ENV=development is not an option. Ghost
# speaks plain http on 2368 and the host's Caddy terminates TLS in front of it,
# which is why `url` carries the https address: every canonical link, RSS item
# and email footer is built from that one value. Both services read secrets from
# /srv/ghost/.env, which Compose picks up when run from /srv/ghost and nowhere
# else. MySQL 8.4 is the current long-term release and the line Ghost's own
# development compose file runs against. Tags and digests read from the
# registries on 2026-08-05; both images publish amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
services:
mysql:
image: mysql:8.4.11@sha256:b3b90af2a6552ae30c266fdb7d5dd55f3afb72404bb78d37fe8a23eb857fd3fb
container_name: ghost-db
restart: unless-stopped
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
MYSQL_DATABASE: ghost
MYSQL_USER: ghost
MYSQL_PASSWORD: ${GHOST_DB_PASSWORD}
volumes:
- /srv/ghost/mysql:/var/lib/mysql
healthcheck:
# `$$` sends a literal dollar to the container instead of interpolating.
test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -u root -p$$MYSQL_ROOT_PASSWORD --silent"]
interval: 10s
retries: 30
start_period: 60s
# No `ports:`: 3306 is reachable only from the other container.
ghost:
image: ghost:6.56.0-alpine@sha256:57cd95050d3ca05a098c9ae1275c8d62ace1c844aa653494204d1c0e77c0900a
container_name: ghost
restart: unless-stopped
environment:
NODE_ENV: production
url: ${GHOST_URL}
# Two underscores separate nested config levels. Documented mapping.
database__client: mysql
database__connection__host: mysql
database__connection__user: ghost
database__connection__password: ${GHOST_DB_PASSWORD}
database__connection__database: ghost
volumes:
# Posts live in MySQL. Images, themes and uploads live here.
- /srv/ghost/content:/var/lib/ghost/content
ports:
# Loopback only: the host's Caddy is the only thing that reaches 8100.
- "127.0.0.1:8100:2368"
depends_on:
mysql:
condition: service_healthy
EOF
cd /srv/ghost && docker compose config >/dev/null && echo "compose OK"
```
You should see: `compose OK` and nothing else.
If you do not: a warning that `MYSQL_ROOT_PASSWORD` is not set means you ran the last line from
somewhere other than /srv/ghost, because that is the only directory where Compose finds the
.env file. `services must be a mapping` means the indentation was lost between the page and
your terminal: run `rm /srv/ghost/compose.yml` and paste again in one go.
## 5. Caddy and TLS
This appends one site block to the Caddy config Prompt Zero installed. Replace `<DOMAIN>` in
the block with your hostname before you paste. The first line takes a copy, because a syntax
error takes down every other site on the box.
```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-ghost
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# Ghost · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://docs.ghost.org/config/,
# https://hub.docker.com/_/ghost,
# https://caddyserver.com/docs/caddyfile/directives/reverse_proxy and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed,
# with <DOMAIN> replaced by the hostname pointed at this box. That hostname is
# also the `url` value in .env, and Ghost builds every canonical link, RSS item
# and newsletter footer from it, so the two must always say the same thing.
<DOMAIN> {
# Ghost serves HTML, JSON feeds and theme assets, all of which compress well.
encode zstd gzip
# Upstream asks the proxy in front of Ghost for X-Forwarded-For,
# X-Forwarded-Host and X-Forwarded-Proto. Caddy's reverse_proxy sets all
# three itself and ignores whatever the client sent, so there is nothing to
# add here and nothing a visitor can spoof.
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options "nosniff"
Referrer-Policy "strict-origin-when-cross-origin"
-Server
}
# No frame-blocking header: the Ghost editor previews posts and the members
# portal renders its signup form in same-origin iframes, and a blanket DENY
# breaks both.
# 8100 is the loopback port compose publishes on this host. It is not a
# container port and it is not open in the firewall.
reverse_proxy 127.0.0.1:8100
}
EOF
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```
You should see: `Valid configuration` from validate, and no output at all from reload.
If you do not: run `sudo cp /etc/caddy/Caddyfile.before-ghost /etc/caddy/Caddyfile`, reload,
and paste again. Caddy requests the certificate on the first request to the hostname and
renews it on its own, and it puts no ceiling on request body size, so theme and image uploads
need no setting here.
## 6. Firewall
```bash
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw status verbose
```
You should see: `Status: active`, rules for `80/tcp`, `443/tcp` and `443/udp`, and no rule
mentioning `8100` or `3306`.
If you do not: delete anything for `8100` or `3306` with `sudo ufw delete allow 8100`. 8100 is
bound to 127.0.0.1 by the compose file and 3306 is never published at all, so the database has
no host port a firewall rule could apply to. 80/tcp redirects to HTTPS and answers the ACME
challenge, 443/tcp is the only way in, and 443/udp is HTTP/3, which Caddy offers by default.
`Status: inactive` is a different problem: Prompt Zero left this firewall enabled, so something
has turned it off since, and `sudo ufw enable` puts it back before you go any further.
## 7. Start and verify
Ghost runs its own migrations on first boot against an empty MySQL, and that takes longer than
the containers take to appear in `docker ps`.
```bash
cd /srv/ghost
docker compose pull
docker compose up -d
for i in $(seq 1 40); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/ghost/api/admin/authentication/setup); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS https://<DOMAIN>/ghost/api/admin/authentication/setup
```
You should see, in order: the loop climbing through `502` and reaching `200`, then exactly
`{"setup":[{"status":false}]}`.
If you do not: a `502` that never clears means Ghost has not bound its port yet, so run
`docker compose logs --tail 20 mysql` first, because a database that never reports healthy is
step 2 done wrong, and `docker compose logs --tail 40 ghost` second. A certificate error points
back at the A record in step 1. A `404` where the JSON should be means Caddy is reaching
something other than Ghost: check `docker compose ps`.
That `status` of `false` is a standing open door: until the first account exists, whoever loads
the setup page owns the publication. Do this now, not tomorrow.
Open https://<DOMAIN>/ghost/ in a browser. The first screen carries the heading
`Welcome to Ghost.` above a form asking for a site title, your full name, an email address and
a password of at least 10 characters. The email address is only a login here, because no mail
is configured. Put the password in your password manager before you submit.
Then prove the door is shut:
```bash
curl -sS https://<DOMAIN>/ghost/api/admin/authentication/setup
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/
```
You should see: exactly `{"setup":[{"status":true}]}`, then `200`.
If you do not: a `status` still reading `false` means the form did not submit, and the browser
tab will be showing why. A `502` on the second command means Ghost restarted while you were
typing; wait 30 seconds and run it again.
## 8. First backup and restore
Two artifacts. MySQL holds the posts, pages, tags, members and settings. The content archive
holds the images, the themes and the files that rebuild the service around them.
```bash
cd /srv/ghost
docker compose exec -T mysql sh -c 'exec mysqldump -u root -p"$MYSQL_ROOT_PASSWORD" --single-transaction --routines --triggers ghost' | gzip > /srv/ghost/backups/ghost-db-$(date +%F).sql.gz
sudo tar -C /srv/ghost -czf /srv/ghost/backups/ghost-content-$(date +%F).tar.gz content compose.yml .env -C /etc/caddy Caddyfile
ls -lh /srv/ghost/backups/
```
You should see: two files, both non-empty, the content archive the larger of the two because
it carries the default theme, and one warning line from mysqldump about passwords on the
command line. That warning is expected: the password is expanded by a shell inside the
container, so it never lands in your own shell history. Nothing goes offline, because
`--single-transaction` snapshots a running InnoDB database.
If you do not: a `.sql.gz` of about 20 bytes is an empty dump, which means mysqldump failed and
the shell created the file anyway. Run the dump line without `| gzip` to read the error.
A backup on the same disk as the data is not a backup. Run this one on your own machine, not
the server:
```bash
mkdir -p ~/backups/ghost
scp vps:/srv/ghost/backups/* ~/backups/ghost/
```
You should see: two files copied, and both listed by `ls -lh ~/backups/ghost/`.
If you do not: `Permission denied (publickey)` means you ran it on the server. The `vps:` prefix
only means something on your own machine, where the `vps` alias Prompt Zero created lives.
Now prove the restore, today, while the only thing at risk is an empty site:
```bash
cd /srv/ghost
docker compose down
sudo rm -rf /srv/ghost/mysql
sudo install -d -m 700 /srv/ghost/mysql
docker compose up -d mysql
sleep 60
gunzip -c /srv/ghost/backups/ghost-db-$(date +%F).sql.gz | docker compose exec -T mysql sh -c 'exec mysql -u root -p"$MYSQL_ROOT_PASSWORD" ghost'
docker compose up -d
sleep 30
curl -sS https://<DOMAIN>/ghost/api/admin/authentication/setup
```
You should see: the same warning line about passwords, then `{"setup":[{"status":true}]}`,
which means your account survived a database that was deleted and rebuilt from the archive.
If you do not: `Access denied for user 'root'` means MySQL had not finished initialising, so
wait longer and run the `gunzip` line again. `Unknown database 'ghost'` means the same thing.
The order matters on a real restore: untar the content archive before any container starts,
because MySQL reads its passwords from .env the moment it initialises an empty data directory.
## 9. Updating later
New versions are listed at https://github.com/TryGhost/Ghost/releases and the matching digest
is on https://hub.docker.com/_/ghost. Take both backups first, then edit the `image:` line in
/srv/ghost/compose.yml to the new tag and its digest.
```bash
cd /srv/ghost
docker compose pull
docker compose up -d
docker compose logs --tail 30 ghost
```
You should see: migration output, then the server starting, and no repeating restart.
If you do not: put the old tag and digest back and run the same three commands. Then re-run the
setup check from step 7 before you call the update done. Reach the last release of a major
version before crossing to the next: upstream states that skipping ahead is where database
errors come from.
## 10. What will probably go wrong
The first `docker compose up -d` looks like a failed install for about a minute. I watched
`docker ps` show both containers up while https://<DOMAIN> returned `502 Bad Gateway`, and had
the Caddy config open hunting for a typo before it cleared on its own. Nothing was wrong: MySQL
spends its first 30 to 60 seconds initialising an empty data directory and refuses connections
until it has finished, and Ghost then runs its whole migration set before it binds 2368. That
is what the 40-iteration loop in step 7 is for. Do not restart anything, do not edit the
Caddyfile, and do not conclude the digest is wrong. Watch `docker compose logs -f ghost` and
let it finish.
## 11. Out of scope
- Do not configure SMTP. Ghost publishes and serves the site without it, and mail is a provider
choice with its own DNS records, made after you have written something.
- Do not enable the analytics or ActivityPub profiles from Ghost's own compose repository. Each
adds containers and an outside account, and this install gives you the publication.
- Do not switch the database to SQLite. Upstream supports it in development mode only, and the
image rejects it under NODE_ENV=production.
- Do not run Ghost-CLI commands inside the container. The official image documents that most of
them are not designed to work there, and `ghost update` fights the digest pin.321 lines · 14,999 bytes
What this prompt will do
- Preflight
- Docker
- Layout
- Secrets
- compose.yml
- Nothing is public
- Start and verify
- First backup and restore
- Updating later
- What will probably go wrong
- Out of scope
Read out of the prompt’s own step headings at build time — if the prompt changes, this list changes with it.
You are Claude Code on the user's own computer. There is no server and no Prompt Zero:
everything in this prompt runs on this machine and stays on it.
Run every command on this computer, in the shell you are already in. Nothing in this prompt
uses ssh.
Install Ghost 6.56.0, with the MySQL 8 it stores posts in, under ~/selfhost/ghost, answering at
http://localhost:8100.
## 1. Preflight
Say this to the user before step 2 runs; it decides whether they want this install at all.
Ghost is publishing software, and this copy publishes to an address only this computer can
open, so no reader, phone or colleague sees a word of it. They get the editor and the archive
on their own disk, not an audience.
Detect the OS and measure the machine:
```bash
uname -s
case "$(uname -s)" in
Darwin) vm_stat | awk '/page size/{p=$8} /free|inactive/{s+=$3} END {printf "%d MB available\n", s*p/1048576}' ;;
Linux) . /etc/os-release && echo "$ID $VERSION_CODENAME"; free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}' ;;
MINGW*|MSYS*) powershell -Command "(Get-CimInstance Win32_OperatingSystem).FreePhysicalMemory" | awk '$1+0 {printf "%d MB available\n", $1/1024}' ;;
esac
df -h ~
```
`Darwin` is macOS, `Linux` is Linux, `MINGW` or `MSYS` is Windows under Git Bash. On Linux the
distribution ID and codename print next, for step 2. Ghost plus MySQL 8 needs 2048 MB of RAM
available and 10 GB free on the home disk, and both images publish amd64 and arm64. On macOS
and Windows that figure is the host's, and Docker Desktop takes its allocation out of it. If
available RAM is under 2048 MB or free disk is under 10 GB, print both and stop.
## 2. Docker
Check before installing anything:
```bash
docker info >/dev/null 2>&1 && echo "docker OK" || echo "docker MISSING"
docker compose version 2>/dev/null || true
```
If that printed `docker OK` and a compose version, skip to step 3.
Otherwise, install Docker for the OS step 1 detected:
- macOS: if `command -v brew` succeeds, run `brew install --cask docker`. If there is no
Homebrew, STOP: tell the user to download Docker Desktop from
https://www.docker.com/products/docker-desktop/ and install it, and wait until they
confirm. Either way, then STOP: tell the user to open Docker Desktop once, accept its
terms, and wait for the whale icon to say it is running. Do not continue until they
confirm.
- Windows: run `winget install -e --id Docker.DockerDesktop`. If winget is missing or the
install fails, STOP: tell the user to download Docker Desktop from the URL above and
install it, and wait until they confirm. Docker Desktop configures WSL 2 itself and may
ask for a reboot; if it does, STOP and tell the user to reboot and come back, this
prompt resumes at this step. Then STOP: have the user open Docker Desktop, accept its
terms, and confirm it says running.
- Linux, Debian or Ubuntu: install Docker Engine from download.docker.com's apt
repository, with its signing key saved to a file first, never piped into a shell. The
fence is guarded, a no-op on anything but a Linux with apt:
```bash
if [ "$(uname -s)" = "Linux" ] && command -v apt-get >/dev/null 2>&1; then
sudo apt-get update
sudo apt-get install -y ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/$(. /etc/os-release && echo "$ID")/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/$(. /etc/os-release && echo "$ID") $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | sudo tee /etc/apt/sources.list.d/docker.list >/dev/null
sudo apt-get update
sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
sudo usermod -aG docker "$USER"
fi
```
Adding the user to the docker group is root-equivalent on this machine; say that to the
user in one sentence, and tell them the group change lands at their next login.
- Linux, anything else: STOP. Tell the user to install Docker Engine and the compose
plugin with their distribution's package manager, and to run this prompt again once
`docker info` works.
Assert: `docker info` exits 0 and `docker compose version` prints a version. Do not
continue without both.
## 3. Layout
```bash
mkdir -p ~/selfhost/ghost/content ~/selfhost/ghost/backups
ls -la ~/selfhost/ghost
```
Assert: `ls -la` shows `content` and `backups`, both owned by the user. Images, themes and
uploads land in `content`, a normal folder they can open in Finder or Explorer. Posts are rows
in MySQL, which step 5 keeps in a Docker-managed volume, so no ownership fix runs here.
## 4. Secrets
Two secrets: the MySQL root password and the `ghost` database user's password. Generate both
here, print neither, and keep both out of your summary and any log line.
```bash
umask 077
cat > ~/selfhost/ghost/.env <<EOF
GHOST_URL=http://localhost:8100
MYSQL_ROOT_PASSWORD=$(openssl rand -hex 32)
GHOST_DB_PASSWORD=$(openssl rand -hex 32)
EOF
chmod 600 ~/selfhost/ghost/.env
umask 022
ls -l ~/selfhost/ghost/.env
```
Assert: the file exists with mode `-rw-------`. Git Bash ships openssl, so these lines run the
same on all three systems. Compose reads this file for both services, but only from
~/selfhost/ghost, so every docker command below starts with a `cd`. Neither value is a browser
login: the writing account is created in step 7. On Windows those mode bits are advisory, NTFS
does not enforce them, and the boundary is the user's own account.
## 5. compose.yml
```bash
cat > ~/selfhost/ghost/compose.yml <<'EOF'
# Ghost · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
# docker install ..... https://docs.ghost.org/install/docker/
# image reference .... https://hub.docker.com/_/ghost
# config reference ... https://docs.ghost.org/config/
# supported databases https://docs.ghost.org/faq/supported-databases/
# mysql image ........ https://hub.docker.com/_/mysql
#
# Two services on your own computer, every path relative to ~/selfhost/ghost/ so
# one file works on macOS, Linux and Windows. SQLite is not an option: upstream
# states MySQL 8 is the only database it supports in production. MySQL's data
# directory is a named volume because the image chowns it to its own uid, which
# a home-directory bind mount cannot allow on Windows; Ghost's content stays a
# bind mount so images and themes show up in Finder or Explorer. Secrets come
# from ./.env, read when Compose runs from this folder. `url` is
# http://localhost:8100, so links resolve here and nowhere else. Digests read
# 2026-08-05; both images publish amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
services:
mysql:
image: mysql:8.4.11@sha256:b3b90af2a6552ae30c266fdb7d5dd55f3afb72404bb78d37fe8a23eb857fd3fb
container_name: ghost-db
restart: unless-stopped
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
MYSQL_DATABASE: ghost
MYSQL_USER: ghost
MYSQL_PASSWORD: ${GHOST_DB_PASSWORD}
volumes:
- ghost-mysql-data:/var/lib/mysql
healthcheck:
# `$$` sends a literal dollar to the container instead of interpolating.
test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -u root -p$$MYSQL_ROOT_PASSWORD --silent"]
interval: 10s
retries: 30
start_period: 60s
# No `ports:`: 3306 is reachable only from the other container.
ghost:
image: ghost:6.56.0-alpine@sha256:57cd95050d3ca05a098c9ae1275c8d62ace1c844aa653494204d1c0e77c0900a
container_name: ghost
restart: unless-stopped
environment:
NODE_ENV: production
url: ${GHOST_URL}
# Two underscores separate nested config levels. Documented mapping.
database__client: mysql
database__connection__host: mysql
database__connection__user: ghost
database__connection__password: ${GHOST_DB_PASSWORD}
database__connection__database: ghost
volumes:
# Posts live in MySQL. Images, themes and uploads live here.
- ./content:/var/lib/ghost/content
ports:
# Loopback only: no other device on the wifi can reach 8100.
- "127.0.0.1:8100:2368"
depends_on:
mysql:
condition: service_healthy
volumes:
ghost-mysql-data:
EOF
cd ~/selfhost/ghost && docker compose config >/dev/null && echo "compose OK"
```
Assert: that prints `compose OK` and nothing else.
## 6. Nothing is public
No reverse proxy, no certificate, no firewall rule. Each is a decision:
- No DNS. There is no hostname, so nothing to resolve and nothing to wait for.
- No TLS. A certificate attests a public name and nothing here has one. Browsers treat
http://localhost as a secure context anyway, so the editor's crypto works.
- No firewall rule. Nothing is published beyond loopback, so no port needs closing.
8100 is bound to 127.0.0.1, this computer only: not the user's phone, not a laptop on the same
wifi, not anyone on the internet. For publishing software that is the shape of the trade, and
the point of this path rather than a defect. Confirm it:
```bash
grep -n '127.0.0.1' ~/selfhost/ghost/compose.yml
```
Assert: two lines, the MySQL healthcheck host and `- "127.0.0.1:8100:2368"`. MySQL publishes
no host port, so 3306 cannot appear.
## 7. Start and verify
Ghost runs its own migrations on first boot, which takes longer than the container takes to
appear.
```bash
cd ~/selfhost/ghost
docker compose pull
docker compose up -d
for i in $(seq 1 40); do code=$(curl -sS -o /dev/null -w '%{http_code}' http://localhost:8100/ghost/api/admin/authentication/setup); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS http://localhost:8100/ghost/api/admin/authentication/setup
```
Assert both, and print what you received. The loop ends printing `200`. The second command
prints exactly `{"setup":[{"status":false}]}`, upstream's way of saying the site exists and has
no owner yet. If either misses, stop, run `docker compose logs --tail 40 ghost`, and name the
likely cause: a MySQL that never reports healthy points at step 4, where an empty
`MYSQL_ROOT_PASSWORD` leaves it refusing to start, and a ghost log still in migrations wants
more time. If `port is already allocated` came back, find what holds 8100
(`lsof -nP -iTCP:8100 -sTCP:LISTEN`, or `netstat -ano | findstr :8100` on Windows) and stop
until the user frees it: 8100 is inside `url` and every link.
A running container is not success.
STOP: tell the user to open http://localhost:8100/ghost/ and create their account, and wait.
Do not continue until they confirm. The first screen carries the heading `Welcome to Ghost.`
above a form asking for a site title, full name, email address and a password of at least 10
characters. The email address is only a login here; the password goes in their password manager
first.
Once they confirm, prove the door is shut:
```bash
curl -sS http://localhost:8100/ghost/api/admin/authentication/setup
curl -sS -o /dev/null -w '%{http_code}\n' http://localhost:8100/
```
Assert: the first prints exactly `{"setup":[{"status":true}]}` and the second prints `200`.
Both must pass before you report success.
## 8. First backup and restore
Two artifacts: a database dump with the posts, pages, tags and settings, and an archive with
the images, themes and the files that rebuild the service.
```bash
cd ~/selfhost/ghost
docker compose exec -T mysql sh -c 'exec mysqldump -u root -p"$MYSQL_ROOT_PASSWORD" --single-transaction --routines --triggers ghost' | gzip > ~/selfhost/ghost/backups/ghost-db-$(date +%F).sql.gz
tar -C ~/selfhost/ghost -czf ~/selfhost/ghost/backups/ghost-content-$(date +%F).tar.gz content compose.yml .env
ls -lh ~/selfhost/ghost/backups/
```
Assert: both files exist and both are non-empty. Print both sizes. The password expands in a
shell inside the container, so it never reaches this machine's history; mysqldump prints one
warning about passwords on the command line, which is expected. Nothing goes offline:
`--single-transaction` snapshots a running InnoDB database consistently.
Both archives sit on the same disk as the data, which is not a backup, and on a laptop the disk
and the machine fail together. Ask the user for a destination that leaves this computer, a
folder their sync service watches or a USB stick, and copy both there with `cp`. In Git Bash a
Windows drive is written `/d/Backups`, not `D:\Backups`. Assert: they confirm both files are
there. If they have neither, say plainly there is no backup.
To restore, in this order. `cd ~/selfhost/ghost`, untar the archive there first so compose.yml
and .env are back before any container starts: MySQL reads its passwords from .env the moment
it initialises an empty volume, and a missing .env means a blank password and a database that
will not start. Then `docker compose down -v`, the one place `-v` belongs,
`docker compose up -d mysql`, wait a minute for healthy, pipe `gunzip -c` on the `.sql.gz` into
`docker compose exec -T mysql sh -c 'exec mysql -u root -p"$MYSQL_ROOT_PASSWORD" ghost'`, then
`docker compose up -d`. Load http://localhost:8100/ and check a post is there.
## 9. Updating later
New versions are at https://github.com/TryGhost/Ghost/releases and the digest is on
https://hub.docker.com/_/ghost. Take both backups first, then edit the image line in
~/selfhost/ghost/compose.yml to the new tag and digest:
```bash
cd ~/selfhost/ghost
docker compose pull
docker compose up -d
docker compose logs --tail 30 ghost
```
Watch that log until it settles, then re-run step 7's setup check. Reach the last release of a
major version before crossing to the next: upstream says skipping ahead causes errors.
## 10. What will probably go wrong
I rebooted this machine, opened http://localhost:8100 to finish a draft, and got a connection
refused that reads like the whole site is gone. It was not: Docker Desktop had not started with
the session, so nothing was listening on 8100, and `restart: unless-stopped` acts only once the
Docker daemon is up. Turn on Docker Desktop's start-at-login setting, then after a reboot run
`cd ~/selfhost/ghost && docker compose up -d` and give MySQL its minute.
## 11. Out of scope
- Do not expose this to the internet.
- Do not configure port forwarding on the router.
- Do not add a reverse proxy or TLS.
- Do not change `url` to this machine's LAN address and do not rebind 8100 to 0.0.0.0 so a
phone can reach it. That puts an admin panel on every network the user joins.
- Do not configure SMTP, and do not enable the analytics or ActivityPub profiles from Ghost's
own compose repository. Each adds an outside account, and a newsletter with no public signup
address has nobody to send to.
- Do not run Ghost-CLI commands inside the container. The official image documents that most of
them are not designed to work there.compose.local.ymlthe services, pinned · local layout65 lines
# Ghost · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
# docker install ..... https://docs.ghost.org/install/docker/
# image reference .... https://hub.docker.com/_/ghost
# config reference ... https://docs.ghost.org/config/
# supported databases https://docs.ghost.org/faq/supported-databases/
# mysql image ........ https://hub.docker.com/_/mysql
#
# Two services on your own computer, every path relative to ~/selfhost/ghost/ so
# one file works on macOS, Linux and Windows. SQLite is not an option: upstream
# states MySQL 8 is the only database it supports in production. MySQL's data
# directory is a named volume because the image chowns it to its own uid, which
# a home-directory bind mount cannot allow on Windows; Ghost's content stays a
# bind mount so images and themes show up in Finder or Explorer. Secrets come
# from ./.env, read when Compose runs from this folder. `url` is
# http://localhost:8100, so links resolve here and nowhere else. Digests read
# 2026-08-05; both images publish amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
services:
mysql:
image: mysql:8.4.11@sha256:b3b90af2a6552ae30c266fdb7d5dd55f3afb72404bb78d37fe8a23eb857fd3fb
container_name: ghost-db
restart: unless-stopped
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
MYSQL_DATABASE: ghost
MYSQL_USER: ghost
MYSQL_PASSWORD: ${GHOST_DB_PASSWORD}
volumes:
- ghost-mysql-data:/var/lib/mysql
healthcheck:
# `$$` sends a literal dollar to the container instead of interpolating.
test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -u root -p$$MYSQL_ROOT_PASSWORD --silent"]
interval: 10s
retries: 30
start_period: 60s
# No `ports:`: 3306 is reachable only from the other container.
ghost:
image: ghost:6.56.0-alpine@sha256:57cd95050d3ca05a098c9ae1275c8d62ace1c844aa653494204d1c0e77c0900a
container_name: ghost
restart: unless-stopped
environment:
NODE_ENV: production
url: ${GHOST_URL}
# Two underscores separate nested config levels. Documented mapping.
database__client: mysql
database__connection__host: mysql
database__connection__user: ghost
database__connection__password: ${GHOST_DB_PASSWORD}
database__connection__database: ghost
volumes:
# Posts live in MySQL. Images, themes and uploads live here.
- ./content:/var/lib/ghost/content
ports:
# Loopback only: no other device on the wifi can reach 8100.
- "127.0.0.1:8100:2368"
depends_on:
mysql:
condition: service_healthy
volumes:
ghost-mysql-data:agent-readable mirror: /self-host/ghost-pro.md
The files, if you'd rather do it yourself
The cloud path with no agent involved: three files, in the order you'd use them. The cloud prompt above writes exactly these — if the two ever disagree, the files are the ones CI diffs. The local path ships its own compose file, collapsed under its own prompt.
compose.ymlthe services, pinned63 lines
# Ghost · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
# docker install ..... https://docs.ghost.org/install/docker/
# image reference .... https://hub.docker.com/_/ghost
# config reference ... https://docs.ghost.org/config/
# supported databases https://docs.ghost.org/faq/supported-databases/
# mysql image ........ https://hub.docker.com/_/mysql
#
# Two services: Ghost and the MySQL 8 it keeps posts, members and settings in.
# Upstream states MySQL 8 is the only database it supports in production, so the
# SQLite the image can run under NODE_ENV=development is not an option. Ghost
# speaks plain http on 2368 and the host's Caddy terminates TLS in front of it,
# which is why `url` carries the https address: every canonical link, RSS item
# and email footer is built from that one value. Both services read secrets from
# /srv/ghost/.env, which Compose picks up when run from /srv/ghost and nowhere
# else. MySQL 8.4 is the current long-term release and the line Ghost's own
# development compose file runs against. Tags and digests read from the
# registries on 2026-08-05; both images publish amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
services:
mysql:
image: mysql:8.4.11@sha256:b3b90af2a6552ae30c266fdb7d5dd55f3afb72404bb78d37fe8a23eb857fd3fb
container_name: ghost-db
restart: unless-stopped
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
MYSQL_DATABASE: ghost
MYSQL_USER: ghost
MYSQL_PASSWORD: ${GHOST_DB_PASSWORD}
volumes:
- /srv/ghost/mysql:/var/lib/mysql
healthcheck:
# `$$` sends a literal dollar to the container instead of interpolating.
test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -u root -p$$MYSQL_ROOT_PASSWORD --silent"]
interval: 10s
retries: 30
start_period: 60s
# No `ports:`: 3306 is reachable only from the other container.
ghost:
image: ghost:6.56.0-alpine@sha256:57cd95050d3ca05a098c9ae1275c8d62ace1c844aa653494204d1c0e77c0900a
container_name: ghost
restart: unless-stopped
environment:
NODE_ENV: production
url: ${GHOST_URL}
# Two underscores separate nested config levels. Documented mapping.
database__client: mysql
database__connection__host: mysql
database__connection__user: ghost
database__connection__password: ${GHOST_DB_PASSWORD}
database__connection__database: ghost
volumes:
# Posts live in MySQL. Images, themes and uploads live here.
- /srv/ghost/content:/var/lib/ghost/content
ports:
# Loopback only: the host's Caddy is the only thing that reaches 8100.
- "127.0.0.1:8100:2368"
depends_on:
mysql:
condition: service_healthyCaddyfilethe hostname and TLS36 lines
# Ghost · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://docs.ghost.org/config/,
# https://hub.docker.com/_/ghost,
# https://caddyserver.com/docs/caddyfile/directives/reverse_proxy and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed,
# with <DOMAIN> replaced by the hostname pointed at this box. That hostname is
# also the `url` value in .env, and Ghost builds every canonical link, RSS item
# and newsletter footer from it, so the two must always say the same thing.
<DOMAIN> {
# Ghost serves HTML, JSON feeds and theme assets, all of which compress well.
encode zstd gzip
# Upstream asks the proxy in front of Ghost for X-Forwarded-For,
# X-Forwarded-Host and X-Forwarded-Proto. Caddy's reverse_proxy sets all
# three itself and ignores whatever the client sent, so there is nothing to
# add here and nothing a visitor can spoof.
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options "nosniff"
Referrer-Policy "strict-origin-when-cross-origin"
-Server
}
# No frame-blocking header: the Ghost editor previews posts and the members
# portal renders its signup form in same-origin iframes, and a blanket DENY
# breaks both.
# 8100 is the loopback port compose publishes on this host. It is not a
# container port and it is not open in the firewall.
reverse_proxy 127.0.0.1:8100
}install.shthe same install, no agent157 lines
#!/usr/bin/env bash
# Ghost · the agent-free install.
#
# Everything prompt.md tells an agent to do, as a script you can read first.
# Run it on the VPS, as a non-root user who is in the docker group:
#
# DOMAIN_HOST=blog.example.com ./install.sh
#
# Authored by caniselfhostit from the upstream documentation:
# https://docs.ghost.org/install/docker/
# https://docs.ghost.org/config/
# https://docs.ghost.org/faq/supported-databases/
# https://hub.docker.com/_/ghost
# https://hub.docker.com/_/mysql
#
# Two secrets are generated here, on this machine: the MySQL root password and
# the password for the `ghost` database user. Both go into /srv/ghost/.env with
# mode 600 and neither is ever printed.
#
# DOMAIN_HOST becomes Ghost's `url`. Every canonical link, RSS item and
# newsletter footer is built from it, so choose it once.
#
# This script stops short of creating the first account. Only a human can fill
# in the setup form at https://<DOMAIN_HOST>/ghost/, and until they do, whoever
# loads that page owns the publication. Do it the minute this script finishes.
#
# NOT YET VERIFIED: no harness run has been recorded against this script.
set -euo pipefail
APP_DIR="${APP_DIR:-/srv/ghost}"
DOMAIN_HOST="${DOMAIN_HOST:-}"
die() { printf 'install.sh: %s\n' "$1" >&2; exit 1; }
# --- 1. Refuse to start on a machine that is not ready -----------------------
[ -n "$DOMAIN_HOST" ] || die "set DOMAIN_HOST to the hostname you pointed at this server, e.g. blog.example.com"
command -v docker >/dev/null 2>&1 || die "docker is not installed. Run Prompt Zero first."
docker compose version >/dev/null 2>&1 || die "the docker compose plugin is missing"
command -v caddy >/dev/null 2>&1 || die "caddy is not installed on the host. Run Prompt Zero first."
command -v openssl >/dev/null 2>&1 || die "openssl is not installed"
avail_mb="$(free -m | awk '/^Mem:/ {print $7}')"
[ "$avail_mb" -ge 2048 ] || die "only ${avail_mb} MB of RAM available; Ghost plus MySQL 8 wants 2048 MB"
avail_gb="$(df -BG --output=avail /srv | tail -1 | tr -dc '0-9')"
[ "$avail_gb" -ge 10 ] || die "only ${avail_gb} GB free on /srv; this install wants 10 GB"
resolved="$(getent hosts "$DOMAIN_HOST" | awk '{print $1; exit}' || true)"
[ -n "$resolved" ] || die "$DOMAIN_HOST does not resolve yet. Add the A record, wait a minute, run this again."
# --- 2. Lay the files out ----------------------------------------------------
#
# content/ and mysql/ stay owned by root. Both images start as root, chown their
# own directory to the uid they run as, and then drop privileges. One that has
# already been chowned to the login user makes MySQL refuse to initialise.
sudo install -d -m 750 -o "$(id -u)" -g "$(id -g)" "$APP_DIR" "$APP_DIR/backups"
sudo install -d -m 750 "$APP_DIR/content"
sudo install -d -m 700 "$APP_DIR/mysql"
install -m 0644 "$(dirname "$0")/compose.yml" "$APP_DIR/compose.yml"
install -m 0644 "$(dirname "$0")/Caddyfile" "$APP_DIR/Caddyfile"
# --- 3. Generate the two secrets, on the server ------------------------------
#
# Hex rather than base64 for both: they travel inside connection strings and
# neither wants escaping. Read them later with
# sudo grep -E 'MYSQL_ROOT_PASSWORD|GHOST_DB_PASSWORD' /srv/ghost/.env
if [ ! -f "$APP_DIR/.env" ]; then
umask 077
cat > "$APP_DIR/.env" <<-ENVFILE
GHOST_URL=https://${DOMAIN_HOST}
MYSQL_ROOT_PASSWORD=$(openssl rand -hex 32)
GHOST_DB_PASSWORD=$(openssl rand -hex 32)
ENVFILE
chmod 600 "$APP_DIR/.env"
umask 022
fi
cd "$APP_DIR"
docker compose config >/dev/null
# --- 4. Caddy site block, on the host ----------------------------------------
if ! sudo grep -qF "$DOMAIN_HOST {" /etc/caddy/Caddyfile; then
sudo cp /etc/caddy/Caddyfile "/etc/caddy/Caddyfile.before-ghost"
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sed "s|<DOMAIN>|${DOMAIN_HOST}|g" "$APP_DIR/Caddyfile" | sudo tee -a /etc/caddy/Caddyfile >/dev/null
fi
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
# --- 5. Ports: two open, and neither 8100 nor 3306 is one of them ------------
if command -v ufw >/dev/null 2>&1; then
echo "==> 80/tcp and 443/tcp for Caddy, 443/udp for HTTP/3; 8100 and 3306 stay closed"
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw status verbose
fi
# --- 6. Start it -------------------------------------------------------------
#
# MySQL spends its first half minute initialising an empty data directory, and
# Ghost then runs its whole migration set before it binds 2368. A 502 during
# that window is expected, not a fault.
docker compose pull
docker compose up -d
echo "==> waiting for https://${DOMAIN_HOST}/ghost/api/admin/authentication/setup"
for _ in $(seq 1 40); do
code="$(curl -sS -o /dev/null -w '%{http_code}' "https://${DOMAIN_HOST}/ghost/api/admin/authentication/setup" || true)"
[ "$code" = "200" ] && break
sleep 10
done
[ "${code:-}" = "200" ] || die "the setup endpoint answered ${code:-nothing}. Check: docker compose logs --tail 40 ghost"
# Upstream's setup endpoint reports status false while the site has no owner.
curl -sS "https://${DOMAIN_HOST}/ghost/api/admin/authentication/setup" | grep -q '"status":false' \
|| die "the setup endpoint answered 200 but not status false. An account may already exist. Stop and investigate."
home_code="$(curl -sS -o /dev/null -w '%{http_code}' "https://${DOMAIN_HOST}/" || true)"
[ "$home_code" = "200" ] || die "https://${DOMAIN_HOST}/ returned ${home_code}, not 200"
# --- 7. The first backup, before day one ends --------------------------------
STAMP="$(date +%Y%m%d-%H%M%S)"
docker compose exec -T mysql sh -c 'exec mysqldump -u root -p"$MYSQL_ROOT_PASSWORD" --single-transaction --routines --triggers ghost' \
| gzip > "$APP_DIR/backups/ghost-db-${STAMP}.sql.gz"
sudo tar -C "$APP_DIR" -czf "$APP_DIR/backups/ghost-content-${STAMP}.tar.gz" content compose.yml .env -C /etc/caddy Caddyfile
ls -lh "$APP_DIR/backups/"
[ -s "$APP_DIR/backups/ghost-db-${STAMP}.sql.gz" ] || die "the database dump is empty"
cat <<-DONE
Ghost is answering at https://${DOMAIN_HOST}/ and has no owner yet.
1. Open https://${DOMAIN_HOST}/ghost/ now. The first screen says
"Welcome to Ghost." and asks for a site title, your name, an email
address and a password of at least 10 characters. Until you fill it
in, anyone who loads that page becomes the owner of this site.
Then confirm the door is shut:
curl -sS https://${DOMAIN_HOST}/ghost/api/admin/authentication/setup
It must print {"setup":[{"status":true}]}
2. The email address there is only a login. No mail is configured, so
member signup links, newsletters and password resets will not send
until you set up an SMTP provider yourself.
3. Two passwords are in $APP_DIR/.env, mode 600, and neither was printed
here. Read them with
sudo grep -E 'MYSQL_ROOT_PASSWORD|GHOST_DB_PASSWORD' $APP_DIR/.env
4. First backup written to $APP_DIR/backups: a database dump and a
content archive. They are on the same disk as the data, which is not
a backup. Copy them somewhere else tonight.
DONEWhat you're signing up for
The part a vendor's comparison page leaves out. None of it is a reason not to do this; all of it is yours the moment you cancel Ghost(Pro).
- Mail is yours now, and this install configures none. The site publishes, the RSS feed works and readers can read it, but member signup links, newsletters and password resets stay silent until you sign up with an SMTP provider and add its DNS records. A single-author blog is complete without that step. A paid newsletter does not exist until you take it.
- You own a MySQL 8. Upstream supports nothing else in production, so this is a real database that has to be dumped rather than copied, and the dump has to leave the box on a schedule you set.
- Members and paid subscriptions are in the software, not in the plan. Neither Ghost(Pro) nor this install takes a cut of subscription revenue, so what you stop paying for is hosting, backups, the CDN and the person who notices at 3am. That person is now you.
- Ghost migrates its own database when it starts, and it ships breaking changes between major versions. A pinned digest means you choose the morning that happens, and also that nobody chooses it for you.
- No CDN, no image-resizing service in front of your uploads, no uptime commitment. Your publication is exactly as fast and as available as one VPS on one provider.
Where this came from
“MySQL 8 is the only supported database in production.”
- Upstream states MySQL 8 is the only database Ghost supports in production, which is why this install runs MySQL rather than the SQLite the image can use in development mode. source
- The official image exposes port 2368, keeps site files in /var/lib/ghost/content, defaults to NODE_ENV=production, and states that production needs MySQL 8, https and a reverse proxy. source
- Ghost reads nested configuration keys from environment variables with two underscores between the levels, and the url option is what a production site builds its public links from. source
- Caddy's reverse_proxy sets X-Forwarded-For, X-Forwarded-Proto and X-Forwarded-Host itself and ignores the values a client sent, which is exactly what the Ghost image asks a proxy in front of it to provide. source
- Ghost's own maintained Docker setup runs the app beside a MySQL container, supplies the database passwords from a .env file, and pins both images by digest. source
Questions people actually ask
Answered from this page's own data — the same numbers, in sentences.
Can I self-host Ghost(Pro)?
Not Ghost(Pro) itself — the vendor does not ship a version you can run on your own server. What you can self-host is the job people pay it for, and the answer to that is Ghost. The same publishing platform Ghost(Pro) hosts, on your own box, with the editor, the themes and the members list intact. The install is one evening: 2 containers behind Caddy with automatic TLS, secrets generated on the server rather than in a chat window, and a first backup taken before the agent says it is done, in about 90 minutes. The prompt on this page does it; the compose.yml, Caddyfile and install.sh below do the same install with no agent at all.
What replaces Ghost(Pro)?
Ghost. The same publishing platform Ghost(Pro) hosts, on your own box, with the editor, the themes and the members list intact. The cleanest answer on this site, because it is not an alternative: it is the same software. Ghost is MIT-licensed and Ghost(Pro) runs the identical codebase, so the editor, the themes, the members list, the Stripe integration and the newsletter engine all come with you. What does not come with you is the part you were paying for. You now operate a MySQL 8 you have to dump, you pick and pay an SMTP provider before a single newsletter sends, and you decide which morning to apply a major version that migrates its own database. Ghost is MIT-licensed and free; nothing on this page is a hosted service we sell you.
What does self-hosting cost compared to Ghost(Pro)?
2048 MB of RAM and 10 GB of disk — the smallest tier most VPS hosts sell, about $10 a month. Ghost itself is free and MIT-licensed; the bill is the server, plus a domain you probably already own. What you stop paying: Ghost(Pro) Starter, $18/mo — $216 a year.
How hard is it really?
ONE EVENING — 1–3 hours. The rule that produced that verdict: up to three containers and at most one outside integration. You will type more than one command and read a page of documentation, and it will be running before you go to bed. The tier is derived from seven countable facts about the Ghost install, not from anyone's impression of it, and the whole rubric is published on the methodology page.
Can I run Ghost on my own computer instead of a server?
Yes — that is the second path in the prompt box above. "On my computer" installs the same Ghost on the machine you are sitting at: no VPS, no domain, no DNS, and nothing exposed to the internet. It checks for Docker first and installs Docker Desktop if the machine does not have it — macOS, Windows and Linux each get their own step — then binds everything to loopback, so the app answers on http://localhost and only on that computer. The catch: Every link this Ghost builds starts with http://localhost:8100, so the site you publish is readable on this computer and nowhere else, which makes the local path a drafting desk rather than a publication. Same discipline as the cloud path: pinned images, secrets generated on the machine, and a first backup taken before the prompt says it is done.
Content last checked 2026-08-06. Verdicts are derived from the published rubric on /methodology; corrections go through the issue tracker.