Skip to content

Ghost

Ghost is a publishing platform: posts, newsletters sent to subscribers, and paid memberships. Two containers — the application and its MySQL database. Fediverse federation is available as an optional overlay.

Last run on a host 8 Nov 2026 · data restored from a backup

Pinned at 6.75.0-alpine (released 4 Nov 2026) · the newest upstream release

In the upstream images: 2 CRITICAL · 45 HIGH with a published fix — per image

To try it: Docker, nothing else. See Try it locally.

To deploy it behind the proxy: Traefik running, a domain, and SMTP credentials.

On your own machine, with no proxy, domain or certificate. From apps/ghost/:

Terminal window
cp .env.local.example .env.local
# The secrets ship empty; generate the ones this stack needs
{
echo "DB_PASSWORD=$(openssl rand -hex 32)"
echo "DB_ROOT_PASSWORD=$(openssl rand -hex 32)"
} >> .env.local
mkdir -p volumes/local/content
docker compose -f docker-compose.local.yml --env-file .env.local up -d

Then open http://localhost:2368. Stop it again with the same command and down.

From apps/ghost/:

  1. Copy the environment file:

    Terminal window
    cp .env.example .env

    Set APP_TRAEFIK_HOST, TZ, and the mail block — GHOST_MAIL_HOST, GHOST_MAIL_PORT, GHOST_MAIL_USER, GHOST_MAIL_FROM. Port 587 with GHOST_MAIL_SECURE=false is the common combination; 465 needs GHOST_MAIL_SECURE=true.

  2. Generate the secrets, including the SMTP password:

    Terminal window
    mkdir -p .secrets
    openssl rand -base64 32 | tr -d '\n' > .secrets/db_pwd.txt
    openssl rand -base64 32 | tr -d '\n' > .secrets/db_root_pwd.txt
    printf '%s' 'YOUR-SMTP-PASSWORD' > .secrets/mail_pwd.txt

    Ghost has no _FILE form for these, so the stack’s entrypoint reads the files at start and exports the values before handing off.

  3. Start it:

    Terminal window
    docker compose up -d

    First start runs database migrations and can take 30 to 60 seconds. The healthcheck allows for it; on slow storage the container may show unhealthy briefly before recovering.

  4. Over the VPN, open https://<APP_TRAEFIK_HOST>/ghost/ and complete the setup wizard. Ghost mails a verification code for every login from a new device, including this one, so mail has to work before the wizard can be finished.

  5. Open the blog to the internet once the owner exists:

    Terminal window
    sed -i 's/^APP_TRAEFIK_ACCESS=.*/APP_TRAEFIK_ACCESS=access-public/' .env
    docker compose up -d
Terminal window
docker compose ps # two services, healthy
docker compose logs ghost-app --tail 50 # migrations completed
docker compose logs db --tail 20 # ready for connections

Then register a test member on the public site and confirm the verification mail arrives. That is the check that matters: signups fail silently when mail is broken, so a working site tells you nothing about it.

The setup page answers until the owner exists, and there is no setup secret — whoever reaches it first becomes the owner. The stack therefore ships access-vpn, and the blog becomes public in step 5, by your decision. A public blog then runs access-public with limit-standard and headers-standard.

Mail that arrives rather than mail that sends

Section titled “Mail that arrives rather than mail that sends”

Ghost uses the Mail-From domain as written. SPF, DKIM and DMARC for the domain in GHOST_MAIL_FROM have to exist at your DNS provider, or member mail lands in spam while the send itself reports success.

activitypub.yml adds two services — the federation backend and a one-shot migration runner. Enable it with COMPOSE_FILE=docker-compose.yml:activitypub.yml in .env.

On a deployment where MySQL already holds data, create the ActivityPub database once before enabling the overlay:

Terminal window
docker exec ghost-db sh /docker-entrypoint-initdb.d/01-activitypub-db.sh

Then docker compose up -d. Check the migration ran and exited cleanly:

Terminal window
docker compose logs activitypub-migrate
docker compose logs ghost-activitypub --tail 30
Database MySQL, container ghost-db, database ghost, user ghost_user
Password .secrets/db_pwd.txt
State volumes/mysql (database) · volumes/content (themes, images, member data)
Quiescing Not needed — the dump is consistent on its own.

Posts reference images in volumes/content by path, so the database and the content directory come back from the same backup run.

For borgmatic:

mysql_databases:
- name: ghost
container: ghost-db
username: ghost_user
password: "{credential file /srv/docker/apps/ghost/.secrets/db_pwd.txt}"

volumes/content holds the active theme, not only uploads. A database-only restore brings every post back and renders them with the default theme, which looks like a broken site rather than a partial restore.

Restore order: database, then the application.

  1. Read the release notes — Ghost’s major versions carry migrations.

  2. Back up the database and the content directory together.

  3. Raise APP_TAG in .env, then:

    Terminal window
    docker compose pull && docker compose up -d
    docker compose logs ghost-app --follow
  4. Load the public site and the administration interface. A theme incompatible with the new version shows up on the front page, not in the log.

The login code never arrives, and there is no way in. Mail is not working. Check GHOST_MAIL_* in .env and the password in .secrets/mail_pwd.txt, then recreate the app container. This blocks administration entirely, so treat it as the first thing to fix.

Member signups do nothing. The same cause. The signup form reports success and the confirmation mail never leaves.

Member mail arrives in spam. SPF, DKIM and DMARC for the GHOST_MAIL_FROM domain are missing at the DNS provider.

The container is unhealthy for the first minute. First-start migrations. It recovers on its own; if it does not, read docker compose logs ghost-app.

Federation shows redirect errors after a configuration change. Browsers cache redirect chains. Confirm in a private window before changing anything else.