diff --git a/.github/renovate.json5 b/.github/renovate.json5 index cf212267..b132f7c7 100644 --- a/.github/renovate.json5 +++ b/.github/renovate.json5 @@ -8,6 +8,19 @@ ":separatePatchReleases", ], suppressNotifications: ["prIgnoreNotification"], + customManagers: [ + { + // Bump GHOST_VERSION in .env.example when the user pins an explicit tag. + // The line is commented by default; this only fires once uncommented. + customType: "regex", + fileMatch: ["^\\.env\\.example$"], + matchStrings: [ + "^GHOST_VERSION=(?\\S+)\\s*$", + ], + datasourceTemplate: "docker", + depNameTemplate: "ghost", + }, + ], packageRules: [ { description: "Group ActivityPub containers together", diff --git a/.github/scripts/patch.py b/.github/scripts/patch.py index 2e62263c..91666ad5 100644 --- a/.github/scripts/patch.py +++ b/.github/scripts/patch.py @@ -1,24 +1,234 @@ +#!/usr/bin/env python3 +"""Coolify compatibility patch for TryGhost/ghost-docker upstream. + +Runs after `git reset --hard upstream/main` in the daily sync workflow and +rewrites the upstream compose.yml to deploy cleanly on Coolify: + + - Removes the Caddy reverse proxy (Coolify's Traefik handles ingress) + - Switches Ghost URL / MySQL credentials to Coolify SERVICE_* magic vars + - Declares SERVICE_URL__ so Traefik discovers the right port + - Adds a Ghost healthcheck for Coolify's UI indicator + - Deletes caddy/ and strips Caddy refs from .env.example + - Overwrites README.md with the Coolify-specific README.coolify.md + +All edits are idempotent: re-running on already-patched input is a no-op. +Each substitution fails loudly when the anchor is missing, so upstream +restructuring surfaces as a nonzero exit rather than silent breakage. +""" + +from __future__ import annotations + +import pathlib import re +import shutil +import sys + + +def fail(msg: str) -> None: + print(f"patch.py: {msg}", file=sys.stderr) + sys.exit(1) + + +def swap(content: str, old: str, new: str, label: str) -> str: + """Single-occurrence replacement with idempotency and loud failure. + Checks `new` before `old` so that superset replacements (where `old` + is a substring of `new`) don't double-apply.""" + if new in content: + return content + if old in content: + n = content.count(old) + if n != 1: + fail(f"{label}: expected 1 occurrence of old anchor, found {n}") + return content.replace(old, new, 1) + fail(f"{label}: neither old nor new anchor present — upstream changed?") + + +def swap_all(content: str, old: str, new: str, label: str, expected: int) -> str: + """Multi-occurrence replacement with idempotency and loud failure.""" + n_new = content.count(new) + if n_new == expected and old not in content: + return content + if old in content: + n = content.count(old) + if n != expected: + fail(f"{label}: expected {expected} occurrences of old anchor, found {n}") + return content.replace(old, new) + fail(f"{label}: found {n_new} of new anchor, expected {expected} — upstream changed?") + + +def patch_compose() -> None: + path = pathlib.Path("compose.yml") + if not path.exists(): + fail("compose.yml not found — run from repo root") + c = path.read_text() + + # Caddy service block (Coolify's Traefik replaces it) + c = re.sub(r"^ caddy:.*?(?=^ [a-z])", "", c, flags=re.MULTILINE | re.DOTALL) + c = c.replace(" caddy_data:\n", "") + c = c.replace(" caddy_config:\n", "") + c = re.sub(r"^\s+- caddy\n", "", c, flags=re.MULTILINE) + + # Ghost URL → Coolify magic + c = swap( + c, + "url: https://${DOMAIN:?DOMAIN environment variable is required}\n", + "url: $SERVICE_URL_GHOST\n", + "ghost.url", + ) + + # MySQL credentials → Coolify magic + c = swap( + c, + "MYSQL_ROOT_PASSWORD: ${DATABASE_ROOT_PASSWORD:?DATABASE_ROOT_PASSWORD environment variable is required}", + "MYSQL_ROOT_PASSWORD: $SERVICE_PASSWORD_MYSQLROOT", + "MYSQL_ROOT_PASSWORD", + ) + c = swap_all( + c, + "MYSQL_USER: ${DATABASE_USER:-ghost}", + "MYSQL_USER: $SERVICE_USER_MYSQL", + "MYSQL_USER", + expected=2, + ) + c = swap( + c, + "database__connection__user: ${DATABASE_USER:-ghost}", + "database__connection__user: $SERVICE_USER_MYSQL", + "database__connection__user", + ) + c = swap_all( + c, + "MYSQL_PASSWORD: ${DATABASE_PASSWORD:?DATABASE_PASSWORD environment variable is required}", + "MYSQL_PASSWORD: $SERVICE_PASSWORD_MYSQL", + "MYSQL_PASSWORD", + expected=2, + ) + c = swap( + c, + "database__connection__password: ${DATABASE_PASSWORD:?DATABASE_PASSWORD environment variable is required}", + "database__connection__password: $SERVICE_PASSWORD_MYSQL", + "database__connection__password", + ) + c = swap( + c, + "MYSQL_DB: mysql://${DATABASE_USER:-ghost}:${DATABASE_PASSWORD:?DATABASE_PASSWORD environment variable is required}@tcp(db:3306)/activitypub", + "MYSQL_DB: mysql://$SERVICE_USER_MYSQL:$SERVICE_PASSWORD_MYSQL@tcp(db:3306)/activitypub", + "activitypub-migrate.MYSQL_DB", + ) + + # Tinybird tracker endpoint → analytics FQDN (optional profile) + c = swap( + c, + "tinybird__tracker__endpoint: https://${DOMAIN:?DOMAIN environment variable is required}/.ghost/analytics/api/v1/page_hit", + "tinybird__tracker__endpoint: $SERVICE_URL_ANALYTICS/api/v1/page_hit", + "tinybird__tracker__endpoint", + ) + + # ActivityPub image storage URL → Ghost origin (images must stay on primary domain) + c = swap( + c, + "LOCAL_STORAGE_HOSTING_URL: https://${DOMAIN}/content/images/activitypub", + "LOCAL_STORAGE_HOSTING_URL: $SERVICE_URL_GHOST/content/images/activitypub", + "activitypub.LOCAL_STORAGE_HOSTING_URL", + ) + + # Ghost healthcheck (inserted between env_file and environment) + ghost_env_anchor = ( + " # This is required to import current config when migrating\n" + " env_file:\n" + " - .env\n" + " environment:\n" + ) + ghost_with_hc = ( + " # This is required to import current config when migrating\n" + " env_file:\n" + " - .env\n" + " healthcheck:\n" + ' test: ["CMD", "wget", "-qO-", "http://localhost:2368/ghost/api/admin/site/"]\n' + " interval: 30s\n" + " timeout: 5s\n" + " retries: 5\n" + " start_period: 60s\n" + " environment:\n" + ) + c = swap(c, ghost_env_anchor, ghost_with_hc, "ghost.healthcheck") + + # SERVICE_URL__ declarations — Coolify uses these to wire Traefik + c = swap( + c, + " url: $SERVICE_URL_GHOST\n", + ' url: $SERVICE_URL_GHOST\n SERVICE_URL_GHOST_2368: ""\n', + "ghost.SERVICE_URL_GHOST_2368", + ) + c = swap( + c, + " environment:\n NODE_ENV: production\n PROXY_TARGET:", + ' environment:\n NODE_ENV: production\n SERVICE_URL_ANALYTICS_3000: ""\n PROXY_TARGET:', + "traffic-analytics.SERVICE_URL_ANALYTICS_3000", + ) + c = swap( + c, + " NODE_ENV: production\n MYSQL_HOST: db\n", + ' NODE_ENV: production\n SERVICE_URL_ACTIVITYPUB_8080: ""\n MYSQL_HOST: db\n', + "activitypub.SERVICE_URL_ACTIVITYPUB_8080", + ) + + path.write_text(c) + + +def patch_env_example() -> None: + path = pathlib.Path(".env.example") + if not path.exists(): + return + e = path.read_text() + + # DOMAIN is supplied by Coolify's SERVICE_URL_GHOST — no need to set here + e = re.sub( + r"# Ghost domain\n# Custom public domain Ghost will run on\nDOMAIN=example\.com\n\n", + "", + e, + ) + # Drop the Caddyfile reference in the ADMIN_DOMAIN comment + e = e.replace( + "# If you have Ghost Admin setup on a separate domain uncomment the line below and add the domain\n" + "# You also need to uncomment the corresponding block in your Caddyfile\n", + "# If Ghost Admin lives on its own domain, add it as a second FQDN on the\n" + "# `ghost` service in the Coolify UI, or uncomment the line below for local runs.\n", + ) + # Coolify owns ingress ports + e = re.sub( + r"# Ghost ports\n# Ports where Ghost will listen for HTTP traffic\.\n" + r"# Change these if the default ports are in use, or if Ghost is behind a reverse proxy\.\n" + r"HTTP_PORT=80\nHTTPS_PORT=443\n\n", + "", + e, + ) + # Coolify auto-generates DB credentials via SERVICE_USER_MYSQL / SERVICE_PASSWORD_MYSQL + e = re.sub( + r"# Database settings\n# All database settings must not be changed once the database is initialised\n" + r"DATABASE_ROOT_PASSWORD=reallysecurerootpassword\n" + r"# DATABASE_USER=optionalusername\n" + r"DATABASE_PASSWORD=ghostpassword\n\n", + "", + e, + ) -with open("compose.yml", "r") as f: - content = f.read() + path.write_text(e) -# Fix Coolify variable parsing -content = content.replace("${ADMIN_DOMAIN:-}", "${ADMIN_DOMAIN}") -content = content.replace("${ADMIN_DOMAIN:+https://${ADMIN_DOMAIN}}", "${ADMIN_DOMAIN}") -# Remove caddy service block entirely (Traefik replaces it) -content = re.sub(r"^ caddy:.*?(?=^ [a-z])", "", content, flags=re.MULTILINE | re.DOTALL) +def overwrite_readme() -> None: + src = pathlib.Path("README.coolify.md") + if src.exists(): + pathlib.Path("README.md").write_text(src.read_text()) -# Remove caddy volumes -content = content.replace(" caddy_data:\n", "") -content = content.replace(" caddy_config:\n", "") -# Remove caddy from any depends_on -content = re.sub(r"^\s+caddy:\s*\n\s+condition:.*\n", "", content, flags=re.MULTILINE) -content = re.sub(r"^\s+- caddy\n", "", content, flags=re.MULTILINE) +def main() -> None: + patch_compose() + shutil.rmtree("caddy", ignore_errors=True) + patch_env_example() + overwrite_readme() + print("Patched compose.yml successfully") -with open("compose.yml", "w") as f: - f.write(content) -print("Patched compose.yml successfully") +if __name__ == "__main__": + main() diff --git a/.github/scripts/test-patch.sh b/.github/scripts/test-patch.sh new file mode 100755 index 00000000..3086b708 --- /dev/null +++ b/.github/scripts/test-patch.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +# Test patch.py against a fresh upstream checkout. +# +# Mimics what the nightly sync workflow does: fetch upstream compose.yml + +# .env.example, apply patch.py, validate the output with `docker compose +# config`, then re-apply to assert idempotency. +# +# Run locally (from repo root) or in CI. Exits non-zero on any failure. + +set -euo pipefail + +REPO_ROOT="$(git rev-parse --show-toplevel)" +SCRATCH="$(mktemp -d)" +trap 'rm -rf "$SCRATCH"' EXIT + +cd "$SCRATCH" + +echo "→ fetching upstream" +curl -fsSL https://raw.githubusercontent.com/TryGhost/ghost-docker/main/compose.yml -o compose.yml +curl -fsSL https://raw.githubusercontent.com/TryGhost/ghost-docker/main/.env.example -o .env.example +mkdir -p caddy/snippets +touch caddy/Caddyfile.example caddy/snippets/Logging .env +cp "$REPO_ROOT/README.coolify.md" . +cp "$REPO_ROOT/.github/scripts/patch.py" . + +echo "→ run 1: apply patch" +python3 patch.py + +echo "→ validate: docker compose config" +docker compose -f compose.yml config --quiet + +echo "→ assert SERVICE_URL declarations present" +grep -q 'SERVICE_URL_GHOST_2368: ""' compose.yml +grep -q 'SERVICE_URL_ANALYTICS_3000: ""' compose.yml +grep -q 'SERVICE_URL_ACTIVITYPUB_8080: ""' compose.yml + +echo "→ assert Ghost healthcheck injected" +grep -q 'localhost:2368/ghost/api/admin/site' compose.yml + +echo "→ assert ADMIN_DOMAIN kept upstream :+ conditional" +# shellcheck disable=SC2016 # literal compose syntax, no shell expansion wanted +grep -qF '${ADMIN_DOMAIN:+https://${ADMIN_DOMAIN}}' compose.yml + +assert_absent() { + if grep -qE "$1" compose.yml; then + echo "unexpected match for /$1/ in compose.yml" >&2 + exit 1 + fi +} + +echo "→ assert DOMAIN and DATABASE_* refs gone from compose.yml" +assert_absent '\$\{DOMAIN(:[^}]*)?\}' +assert_absent 'DATABASE_PASSWORD' +assert_absent 'DATABASE_ROOT_PASSWORD' +assert_absent 'DATABASE_USER' + +echo "→ assert caddy/ deleted" +[ ! -e caddy ] + +echo "→ assert README.md overwritten with Coolify content" +head -1 README.md | grep -q 'Ghost on Coolify' + +echo "→ run 2: idempotency" +cp compose.yml compose.r1.yml +cp .env.example env.r1 +python3 patch.py +diff -u compose.r1.yml compose.yml +diff -u env.r1 .env.example + +echo "" +echo "✓ all checks passed" diff --git a/.github/workflows/patch-test.yml b/.github/workflows/patch-test.yml new file mode 100644 index 00000000..38f997b9 --- /dev/null +++ b/.github/workflows/patch-test.yml @@ -0,0 +1,15 @@ +name: Test patch.py + +on: + pull_request: + push: + branches: + - main + +jobs: + test-patch: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Apply patch to upstream + validate + check idempotency + run: bash .github/scripts/test-patch.sh diff --git a/.github/workflows/sync.yml b/.github/workflows/sync.yml index 48d76963..23f23fc1 100644 --- a/.github/workflows/sync.yml +++ b/.github/workflows/sync.yml @@ -28,10 +28,16 @@ jobs: - name: Patch compose.yml run: python3 .github/scripts/patch.py + - name: Validate patched compose.yml + run: | + touch .env + docker compose -f compose.yml config --quiet + rm -f .env + - name: Commit and push run: | git config user.name "GitHub Action" git config user.email "action@github.com" git add -A git diff --staged --quiet || git commit -m "Auto-patch compose.yml for Coolify compatibility" - git push --force + git push --force-with-lease diff --git a/CLAUDE.md b/CLAUDE.md index 3f23002e..1ce55e35 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,92 +4,123 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Overview -This is a comprehensive Docker Compose setup for running Ghost CMS in production with automatic HTTPS, optional analytics, and ActivityPub support. The repository orchestrates multiple services including Ghost, MySQL, Caddy (reverse proxy), and optional Tinybird analytics and ActivityPub federation. +Ghost 6 CMS packaged for one-shot deploys on [Coolify](https://coolify.io). +Forked from `TryGhost/ghost-docker` and synced nightly; Coolify-specific +edits live in [`.github/scripts/patch.py`](.github/scripts/patch.py) and are +re-applied on top of each upstream pull. + +Coolify's built-in Traefik handles HTTPS and routing, so the Caddy service +from upstream is stripped by the patch. Ghost URL and MySQL credentials use +Coolify's `SERVICE_URL_*` / `SERVICE_USER_*` / `SERVICE_PASSWORD_*` magic +variables, which Coolify auto-generates and wires through the UI. ## Architecture -The project uses Docker Compose to orchestrate these services: +Services in `compose.yml` after patching: -1. **Ghost** - The main CMS application (runs on internal port 2368) -2. **MySQL** - Database backend with health checks and support for multiple databases -3. **Caddy** - Reverse proxy handling HTTPS/SSL, routing, and external access -4. **Traffic Analytics** (optional profile) - Tinybird integration for web analytics -5. **ActivityPub** (optional profile) - Federated social networking support -6. **Supporting services** - Tinybird setup tools and ActivityPub migrations +1. **ghost** — Ghost CMS, port 2368 internal. Proxy wiring via + `SERVICE_URL_GHOST_2368` declaration; referenced as `$SERVICE_URL_GHOST`. +2. **db** — MySQL 8.0 (pinned, Renovate restricted to `~8.0`). Credentials + from `SERVICE_USER_MYSQL`, `SERVICE_PASSWORD_MYSQL`, `SERVICE_PASSWORD_MYSQLROOT`. +3. **traffic-analytics** (profile `analytics`) — Tinybird proxy on port 3000. + Proxy via `SERVICE_URL_ANALYTICS_3000`. +4. **activitypub** (profile `activitypub`) — Federation service on port 8080. + Proxy via `SERVICE_URL_ACTIVITYPUB_8080`. +5. Supporting one-shot services: `tinybird-login`, `tinybird-sync`, + `tinybird-deploy`, `activitypub-migrate`. -Services communicate internally via Docker networks. Caddy handles all external traffic routing including special paths for analytics (`/_tinybird`) and ActivityPub (`/.well-known/`, `/activitypub/`). +Internal DNS on the `ghost_network` bridge: `ghost` → `db:3306`, +`activitypub` → `db:3306`. External ingress is Coolify's responsibility; this +repo does not ship a reverse proxy. ## Common Commands ```bash -# Core operations -docker compose up -d # Start Ghost + MySQL + Caddy -docker compose down # Stop all services -docker compose logs -f [service] # View logs (e.g., ghost, mysql, caddy) -docker compose ps # Check service status -docker compose pull # Update all images -docker compose restart ghost # Restart just Ghost - -# With optional profiles -docker compose --profile=analytics up -d # Include analytics services -docker compose --profile=activitypub up -d # Include ActivityPub services -COMPOSE_PROFILES=analytics,activitypub docker compose up -d # Start everything - -# Tinybird analytics setup (if using analytics profile) -docker compose run --rm tinybird-login # Interactive Tinybird login -docker compose --profile=analytics up tinybird-sync # Sync datasources/pipes -docker compose --profile=analytics up tinybird-deploy # Deploy configuration - -# Development & debugging -docker compose exec ghost sh # Access Ghost container shell -docker compose exec mysql mysql -u root -p # Access MySQL CLI +# Core (Coolify runs these under the hood; equivalents for local debugging) +docker compose up -d +docker compose down +docker compose logs -f ghost +docker compose ps +docker compose pull +docker compose restart ghost + +# Optional profiles +docker compose --profile=analytics up -d +docker compose --profile=activitypub up -d +COMPOSE_PROFILES=analytics,activitypub docker compose up -d + +# Tinybird setup (analytics profile only) +docker compose run --rm tinybird-login +docker compose --profile=analytics up tinybird-sync +docker compose --profile=analytics up tinybird-deploy + +# Debugging +docker compose exec ghost sh +docker compose exec db mysql -u root -p ``` ## Configuration -All configuration is done via environment variables. Key patterns: +Deployments on Coolify: set FQDNs and SMTP env vars in the Coolify UI; +MySQL passwords auto-generate. See [`README.md`](README.md) for the flow. + +Local development without Coolify: set the SERVICE_* vars in `.env`: + +``` +SERVICE_URL_GHOST=http://localhost:2368 +SERVICE_USER_MYSQL=ghost +SERVICE_PASSWORD_MYSQL=localdev +SERVICE_PASSWORD_MYSQLROOT=localrootdev +SERVICE_URL_ANALYTICS=http://localhost:3000 +``` -- **Required variables**: `DOMAIN`, `DATABASE_PASSWORD`, `DATABASE_ROOT_PASSWORD` -- **Ghost config pattern**: `section__subsection__key=value` (e.g., `mail__options__service=Mailgun`) -- **Developer experiments**: Must set `labs__publicAPI=true` for analytics/ActivityPub features -- **Data persistence**: Volumes stored in `./data/ghost` and `./data/mysql` +Ghost config uses the flattened env-var pattern, e.g. `mail__options__host`, +`mail__transport=SMTP`. See [Ghost's config docs](https://ghost.org/docs/config/). -### Key configuration files: -- `.env` - Main environment configuration (create from `.env.example`) -- `compose.yml` - Docker Compose service definitions -- `Caddyfile` - Reverse proxy routing configuration -- `mysql-init/create-multiple-databases.sh` - MySQL multi-database initialization +### Key files +- [`compose.yml`](compose.yml) — regenerated nightly from upstream + `patch.py`. Don't edit by hand. +- [`.github/scripts/patch.py`](.github/scripts/patch.py) — all Coolify edits live here. +- [`.github/workflows/sync.yml`](.github/workflows/sync.yml) — nightly upstream sync. +- [`.env.example`](.env.example) — environment template. +- [`README.coolify.md`](README.coolify.md) — deploy docs; `patch.py` copies this to `README.md`. +- [`mysql-init/create-multiple-databases.sh`](mysql-init/create-multiple-databases.sh) — creates the `activitypub` database. ## Migration from Ghost CLI -The repository includes comprehensive migration tools: +The scripts in [`scripts/`](scripts/) are bare-metal migration tools, not +intended to run inside Coolify: + +- [`scripts/migrate.sh`](scripts/migrate.sh) — backs up a Ghost CLI install, + dumps the database with `--no-tablespaces`, converts `config.production.json` + to `.env`, and starts the Docker stack. +- [`scripts/config-to-env.js`](scripts/config-to-env.js) — flattens Ghost's + nested JSON config into the `section__subsection__key` env-var pattern. -- `scripts/migrate.sh` - Main migration script that: - - Backs up existing Ghost installation - - Automatically tries Ghost's database credentials first - - Only prompts for alternative credentials if needed - - Uses `--no-tablespaces` flag to avoid PROCESS privilege requirements - - Converts config.json to environment variables - - Preserves content and database - - Creates recovery script with clear restoration instructions - - Sets up Docker Compose environment +For Coolify migration from an existing Ghost CLI install, run `migrate.sh` +on the source host to produce an `.env`, then transfer the `.env` + database +dump + content directory to the Coolify host and deploy normally. -- `scripts/config-to-env.js` - Converts Ghost JSON config to .env format +## Daily sync workflow -## Development Workflow +[`sync.yml`](.github/workflows/sync.yml) runs at 00:00 UTC: -1. Clone repository and copy `.env.example` to `.env` -2. Configure required environment variables (domain, passwords) -3. Run `docker compose up -d` to start services -4. Access Ghost at `https://DOMAIN` (Caddy handles SSL automatically) -5. Monitor logs with `docker compose logs -f ghost` +1. Clone this fork, back up `.github/` to `/tmp`. +2. `git reset --hard upstream/main` (TryGhost/ghost-docker). +3. Restore `.github/` over the reset. +4. Run `python3 .github/scripts/patch.py` — re-applies Coolify edits. +5. `docker compose config --quiet` — validates the patched YAML. +6. `git push --force-with-lease`. -For analytics setup, see `TINYBIRD.md` for detailed instructions. +Any hand-edits to `compose.yml`, `caddy/`, `.env.example`, or `README.md` +will be overwritten nightly. Put durable changes in `patch.py`. ## Important Notes -- Ghost runs internally on port 2368; Caddy exposes it on 80/443 -- Email configuration is critical even without newsletter features (used for admin notifications) -- MySQL health checks ensure database is ready before Ghost starts -- The compose file uses yaml-language-server schema for IDE completion support -- For production, always use strong passwords and consider additional security measures +- Ghost pins to `${GHOST_VERSION:-6-alpine}`; Renovate intentionally does + not pin this image. Bump `GHOST_VERSION` in Coolify's env UI when needed. +- MySQL is pinned to `~8.0` (Renovate rule in [`.github/renovate.json5`](.github/renovate.json5)). Required for Ghost 6. +- Transactional email (`mail__*`) is required for admin invites and + password resets — not just newsletters. +- The patched `compose.yml` depends on Coolify's magic var injection. + Running `docker compose up` outside Coolify needs the SERVICE_* vars + in `.env` (see Configuration above). diff --git a/README.coolify.md b/README.coolify.md new file mode 100644 index 00000000..6410577d --- /dev/null +++ b/README.coolify.md @@ -0,0 +1,79 @@ +# Ghost on Coolify + +Ghost 6 CMS packaged for one-shot deploys on [Coolify](https://coolify.io), with +optional Tinybird analytics and ActivityPub federation. Forked from +[`TryGhost/ghost-docker`](https://github.com/TryGhost/ghost-docker) and synced +daily; the Coolify-specific patch lives in +[`.github/scripts/patch.py`](.github/scripts/patch.py). + +## Deploy + +1. In Coolify: **New Resource → Public Repository**, point at this repo, + build pack **Docker Compose**, compose file `compose.yml`. +2. On the `ghost` service, set the primary **Domain (FQDN)** to the URL + you want (e.g. `https://blog.example.com`). Coolify fills in + `SERVICE_URL_GHOST` automatically and wires Traefik to port 2368. +3. Set SMTP env vars on the resource: `mail__options__host`, + `mail__options__port`, `mail__options__auth__user`, + `mail__options__auth__pass`, `mail__from`. Transactional email is + required by Ghost for staff invites and password resets. +4. **Deploy**. MySQL credentials generate on first boot via + `SERVICE_USER_MYSQL` / `SERVICE_PASSWORD_MYSQL` / `SERVICE_PASSWORD_MYSQLROOT` — + you don't enter them manually. + +## Optional: separate admin domain + +Set `ADMIN_DOMAIN=admin.example.com` on the resource and add the same value +as a second FQDN on the `ghost` service in Coolify. Upstream's +`${ADMIN_DOMAIN:+https://${ADMIN_DOMAIN}}` expression means Ghost sees +`admin__url` only when the variable is non-empty. + +## Optional: analytics (Tinybird) + +Enable the `analytics` profile on the resource +(`COMPOSE_PROFILES=analytics`). Set a second FQDN on the `traffic-analytics` +service (e.g. `analytics.example.com`) — Coolify fills +`SERVICE_URL_ANALYTICS` and proxies port 3000. Populate +`TINYBIRD_*` env vars per [`TINYBIRD.md`](TINYBIRD.md). + +## Optional: ActivityPub + +Enable the `activitypub` profile (`COMPOSE_PROFILES=analytics,activitypub` +if combined). Set a third FQDN on the `activitypub` service. + +Fediverse discovery via `@user@your-primary-domain` additionally requires +routing `/.well-known/webfinger` on the Ghost FQDN to the ActivityPub +service. In Coolify, add this custom label to the `ghost` service: + +``` +traefik.http.routers.ghost-webfinger.rule=Host(`blog.example.com`) && PathPrefix(`/.well-known/webfinger`) +traefik.http.routers.ghost-webfinger.service=activitypub-http +traefik.http.services.activitypub-http.loadbalancer.server.port=8080 +``` + +## Upgrades + +Ghost's version floats on `${GHOST_VERSION:-6-alpine}`. Pin explicitly by +setting `GHOST_VERSION=6.2-alpine` on the resource if you need a specific +release. Renovate tracks MySQL patch updates within 8.0.x and the +analytics/ActivityPub image digests; Ghost itself is not pinned. + +## Running locally (without Coolify) + +Because the patched `compose.yml` targets Coolify's magic vars, local +`docker compose up` needs those vars set in `.env`: + +``` +SERVICE_URL_GHOST=http://localhost:2368 +SERVICE_USER_MYSQL=ghost +SERVICE_PASSWORD_MYSQL=localdev +SERVICE_PASSWORD_MYSQLROOT=localrootdev +SERVICE_URL_ANALYTICS=http://localhost:3000 +``` + +For a bare-metal Ghost CLI → Docker migration, see +[`scripts/migrate.sh`](scripts/migrate.sh) (not intended for Coolify hosts). + +## License + +MIT — see [LICENSE](LICENSE).