Ghost docs
Self-hosting

Self-hosting configuration

Every setting a self-hosted Ghost reads, how the containers fit together, and what to check when something fails.

The setup script writes docker/.env. Every value in this page lives there. Edit it and apply the change from the docker directory:

docker compose up -d --build

Values in .env are single-quoted, so $ and spaces are taken literally. A value cannot contain a single quote.

Containers

ServiceImageRole
webapps/webNext.js app on port 3000
apiapps/apiAPI and git over HTTP on 3001, git over SSH on GIT_SSH_PORT
docsapps/docsThese docs on 3003
deliveryapps/deliverySends webhooks, and emails when DELIVERY_EMAIL=queue
zoektapps/zoektCode search. Only the API can reach it
importerapps/importerImports repositories from GitHub (profile importer). Only the API can reach it
postgrespostgres:18-alpineEverything except git objects
rustfsrustfs/rustfsBundled S3 storage (profile rustfs)
caddycaddy:2HTTPS on 80/443 for a domain (profile https)
caddy-httpcaddy:2Plain HTTP on 3000, 3001 and 3003 (profile http)
migrateapps/apiRuns database migrations, then exits
bucketamazon/aws-cliCreates the bucket if it is missing, then exits

COMPOSE_PROFILES in .env turns the optional services on. https,rustfs is a domain with bundled storage; http is plain HTTP with an external bucket; add importer for GitHub imports. One of https or http is always on: the apps publish no ports of their own, and Caddy is what tells the API each visitor's address. Without it every visitor would share one sign-in rate limit.

Addresses

VariableMeaning
DOMAINThe domain Caddy serves. Empty in IP mode
WEB_APP_URL, API_URL, DOCS_URLPublic origins of the three apps
SSH_CLONE_HOSThost:port shown in SSH clone URLs
BETTER_AUTH_URLSame as API_URL
AUTH_TRUSTED_ORIGINSOrigins allowed to sign in. Same as WEB_APP_URL
AUTH_COOKIE_DOMAIN.<DOMAIN>, so the web app and the API share the session. Empty in IP mode

API_URL, WEB_APP_URL, DOCS_URL and SSH_CLONE_HOST are compiled into the web and docs bundles. Changing them needs --build, not a restart.

Your own reverse proxy

To terminate TLS in nginx, Traefik or a load balancer, set up the instance with its IP (the http profile), then set the three URL variables to your HTTPS origins and AUTH_COOKIE_DOMAIN to .<your domain>, and rebuild. Proxy:

  • DOMAIN to port 3000
  • api.DOMAIN to port 3001
  • docs.DOMAIN to port 3003

Set X-Forwarded-For to the client address, as a single value, and do not limit the request body size on the API: a git push is one request.

Email

Set one of the two routes. The Docker setup requires one of them.

Running the API outside Docker with neither set, Ghost falls back to the local sendmail binary: SENDMAIL_PATH, or the first sendmail on PATH, /usr/sbin or /usr/lib. Windows has no built-in sendmail; a drop-in sendmail.exe on PATH is found the same way. With no route at all, email is dropped and verification has to stay off.

VariableMeaning
MAIL_HOST, MAIL_PORTSMTP server
MAIL_USER, MAIL_PASSWORDSMTP credentials
MAIL_SECUREtrue for implicit TLS (port 465). false uses STARTTLS when the server offers it
EMAIL_PROXYURL of an HTTP relay that receives each message as JSON
EMAIL_PROXY_SECRETShared secret sent to the relay
SENDMAIL_PATHA sendmail-compatible binary, used when neither SMTP nor a relay is set
EMAIL_SENDERThe From address, such as Ghost <noreply@example.com>
EMAIL_VERIFICATION_ENABLEDtrue makes new accounts verify their address before they sign in
DELIVERY_EMAILqueue hands issue and pull request emails to delivery, which retries them. Only works with EMAIL_PROXY

Storage

VariableMeaning
S3_ENDPOINThttp://rustfs:9000 for the bundled storage, or your provider's endpoint
S3_BUCKETBucket name. Created on start if missing and the key is allowed to
S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEYCredentials
STORAGE_QUOTA_BYTESMost each account may store outside forks, Git LFS objects and release assets aside, such as 10gb. Empty is unlimited
FORK_STORAGE_QUOTA_BYTESMost each account may store in forks, their Git LFS objects and release assets included. Empty is unlimited
LFS_STORAGE_QUOTA_BYTESMost each account may store in Git LFS objects outside forks. Empty is unlimited
ASSET_STORAGE_QUOTA_BYTESMost each account may store in release assets outside forks, plus, for a user, the files they attach to issues and comments. Empty is unlimited
RELEASE_ASSET_MAX_BYTESLargest release asset. 2gb unless set, never above 5gb
PULL_REF_MAX_BYTESMost one update of a pull request's refs/pull/<n>/head may copy into the base repository. Empty is unlimited
PULL_REF_UNMERGED_MAX_BYTESMost one author's unmerged pull requests may hold in base repositories altogether. Empty is unlimited

The bucket is the source of truth for git data. Repository working copies in the API container are a cache rebuilt from it, so the API needs no volume. Ghost uses path-style addressing and region auto; any provider that accepts those works.

Secrets

VariableMeaningGenerate by hand
BETTER_AUTH_SECRETSigns sessions. Changing it signs everyone outopenssl rand -base64 32
WEBHOOK_SECRET_KEYEncrypts webhook signing secrets. Changing it breaks every existing webhookopenssl rand -base64 32
GIT_SSH_HOST_KEYThe server's SSH identity, base64 of an OpenSSH private key. Empty turns SSH offsee below
COMMIT_SIGNING_KEYSigns the commits Ghost writes: pull request merges, squashes and rebases, and applied suggestions. Base64 of an armored OpenPGP private key with no passphrase. Empty leaves them unsigned. Changing it unverifies every commit the old key signedsee below
POSTGRES_PASSWORDDatabase password. Also inside DATABASE_URLopenssl rand -hex 24

The setup script never replaces a secret that is already in .env.

An SSH host key by hand:

ssh-keygen -t ed25519 -N '' -f ghost_host_key
base64 -w0 ghost_host_key   # macOS: base64 -i ghost_host_key

Publish its fingerprint (ssh-keygen -lf ghost_host_key.pub) so people can check it on their first connection.

A commit signing key by hand:

gpg --batch --pinentry-mode loopback --passphrase '' --quick-gen-key Ghost ed25519 sign never
gpg --armor --export-secret-keys Ghost | base64 | tr -d '\n'

Ghost trusts only the key in COMMIT_SIGNING_KEY; old keys are not kept, so generate it once and back it up with the rest of .env. Commits GitHub signed, such as the merges in an imported repository, show as verified without any setup.

Git over SSH

VariableMeaning
GIT_SSH_PORTPort the API listens on and the host publishes. Default 1031

Port 22 usually belongs to the server's own SSH daemon. To use it for git, move the daemon first.

Webhooks

VariableMeaning
WEBHOOK_ALLOW_PRIVATE_NETWORKStrue lets webhooks reach private and loopback addresses, for receivers on your own network. Cloud metadata addresses stay blocked
WEBHOOK_HOST_CONCURRENCYDeliveries in flight to one host at a time. Default 4

See Receiving webhooks for the payload and signature format.

GitHub sign-in and imports

With GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET set, the sign-in and sign-up forms offer Continue with GitHub. Someone new gets an account named after their GitHub login, with hyphens turned into underscores and a number added when the name is taken. Signing in with GitHub never takes over an existing account that shares the address: link GitHub from Settings → Security instead. The web app reads the client ID at build time, so rebuild web after setting it.

Imports are off unless all of these are set. With them, the new repository form offers Import from GitHub: people link their GitHub account, and the importer container copies the repository's branches, tags, releases, issues, pull requests and comments into Ghost. Imported issues and comments are authored by the ghost-importer account and credit the GitHub author in the text.

VariableMeaning
GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRETA GitHub OAuth app, for signing in and for the token an import reads with
IMPORTER_URLWhere the API sends import jobs: http://importer:3004
IMPORTER_SECRETShared by the API and the importer to authenticate each other
IMPORTER_CONCURRENCYImports the importer runs at once. Default 2; more wait and are retried

Create the OAuth app at github.com/settings/applications/new with:

  • Homepage URL: WEB_APP_URL
  • Authorization callback URL: <API_URL>/api/auth/callback/github

Generate IMPORTER_SECRET with openssl rand -hex 32, and add importer to COMPOSE_PROFILES. The setup script does both when you turn imports on.

The importer clones each repository to its container's temporary directory before pushing it, so the container needs disk for the largest repository you import. Release assets, Git LFS objects and line comments on pull requests are not imported.

Telemetry

Off by default. Set OTEL_EXPORTER_OTLP_ENDPOINT to an OTLP HTTP endpoint, such as a Grafana Alloy or grafana/otel-lgtm container, to export traces and metrics from the API.

Running without the script

Copy the variables in this page into docker/.env by hand, then:

cd docker
docker compose up -d --build

DATABASE_URL has to be postgres://ghost:<POSTGRES_PASSWORD>@postgres:5432/ghost, and ZOEKT_URL http://zoekt:6070.

Troubleshooting

Logs for one service:

cd docker
docker compose logs -f api

Sign-in does nothing, or signs you out on reload. The web app and API are not sharing a cookie. With a domain, check AUTH_COOKIE_DOMAIN is .<DOMAIN> and both are served over HTTPS. In IP mode, open the web app on the same host name as API_URL, not localhost on one and an IP on the other.

Caddy cannot get a certificate. DNS for all three names has to reach the server, and ports 80 and 443 have to be open. docker compose logs caddy names the failing domain.

migrate exits with an error. The API does not start until migrations succeed. docker compose logs migrate shows the SQL error.

bucket fails with an external bucket. The key cannot see the bucket. Check the endpoint, the bucket name and the key's permissions.

SSH clone times out. The SSH port is closed in a firewall, or SSH_CLONE_HOST names a different host.

An import stays at "Importing" or fails with "The import could not be started". The API cannot reach the importer, or the two disagree on IMPORTER_SECRET. Check COMPOSE_PROFILES includes importer, then docker compose logs importer api. Failed imports retry on their own a few times; after that a repository admin can retry from the banner.

Linking GitHub fails with a redirect error. The OAuth app's callback URL has to be exactly <API_URL>/api/auth/callback/github.

Code search returns nothing. Repositories are indexed on push. Push once after the instance starts, then check docker compose logs zoekt.

On this page