Skip to content

Commit 7f90764

Browse files
committed
Auto-patch compose.yml for Coolify compatibility
1 parent 4ceab31 commit 7f90764

15 files changed

Lines changed: 555 additions & 152 deletions

.env.example

Lines changed: 2 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,11 @@
11
# Use the below flags to enable the Analytics or ActivityPub containers as well
22
# COMPOSE_PROFILES=analytics,activitypub
33

4-
# Ghost domain
5-
# Custom public domain Ghost will run on
6-
DOMAIN=example.com
7-
84
# Ghost Admin domain
9-
# If you have Ghost Admin setup on a separate domain uncomment the line below and add the domain
10-
# You also need to uncomment the corresponding block in your Caddyfile
5+
# If Ghost Admin lives on its own domain, add it as a second FQDN on the
6+
# `ghost` service in the Coolify UI, or uncomment the line below for local runs.
117
# ADMIN_DOMAIN=
128

13-
# Ghost ports
14-
# Ports where Ghost will listen for HTTP traffic.
15-
# Change these if the default ports are in use, or if Ghost is behind a reverse proxy.
16-
HTTP_PORT=80
17-
HTTPS_PORT=443
18-
19-
# Database settings
20-
# All database settings must not be changed once the database is initialised
21-
DATABASE_ROOT_PASSWORD=reallysecurerootpassword
22-
# DATABASE_USER=optionalusername
23-
DATABASE_PASSWORD=ghostpassword
24-
259
# ActivityPub
2610
# If you'd prefer to self-host ActivityPub yourself uncomment the line below
2711
# ACTIVITYPUB_TARGET=activitypub:8080

.github/README.coolify.md

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
# Ghost on Coolify
2+
3+
Ghost 6 CMS packaged for one-shot deploys on [Coolify](https://coolify.io), with
4+
optional Tinybird analytics and ActivityPub federation. Forked from
5+
[`TryGhost/ghost-docker`](https://github.com/TryGhost/ghost-docker) and synced
6+
daily; the Coolify-specific patch lives in
7+
[`.github/scripts/patch.py`](.github/scripts/patch.py).
8+
9+
## Deploy
10+
11+
1. In Coolify: **New Resource → Public Repository**, point at this repo,
12+
build pack **Docker Compose**, compose file `compose.yml`.
13+
2. On the `ghost` service, set the primary **Domain (FQDN)** to the URL
14+
you want (e.g. `https://blog.example.com`). Coolify fills in
15+
`SERVICE_URL_GHOST` automatically and wires Traefik to port 2368.
16+
3. Set SMTP env vars on the resource: `mail__options__host`,
17+
`mail__options__port`, `mail__options__auth__user`,
18+
`mail__options__auth__pass`, `mail__from`. Transactional email is
19+
required by Ghost for staff invites and password resets.
20+
4. **Deploy**. MySQL credentials generate on first boot via
21+
`SERVICE_USER_MYSQL` / `SERVICE_PASSWORD_MYSQL` / `SERVICE_PASSWORD_MYSQLROOT`
22+
you don't enter them manually.
23+
24+
## Optional: separate admin domain
25+
26+
Set `ADMIN_DOMAIN=admin.example.com` on the resource and add the same value
27+
as a second FQDN on the `ghost` service in Coolify. Upstream's
28+
`${ADMIN_DOMAIN:+https://${ADMIN_DOMAIN}}` expression means Ghost sees
29+
`admin__url` only when the variable is non-empty.
30+
31+
## Optional: analytics (Tinybird)
32+
33+
Enable the `analytics` profile on the resource
34+
(`COMPOSE_PROFILES=analytics`). Set a second FQDN on the `traffic-analytics`
35+
service (e.g. `analytics.example.com`) — Coolify fills
36+
`SERVICE_URL_ANALYTICS` and proxies port 3000. Populate
37+
`TINYBIRD_*` env vars per [`TINYBIRD.md`](TINYBIRD.md).
38+
39+
## Optional: ActivityPub
40+
41+
Enable the `activitypub` profile (`COMPOSE_PROFILES=analytics,activitypub`
42+
if combined). Set a third FQDN on the `activitypub` service.
43+
44+
Fediverse discovery via `@user@your-primary-domain` additionally requires
45+
routing `/.well-known/webfinger` on the Ghost FQDN to the ActivityPub
46+
service. In Coolify, add this custom label to the `ghost` service:
47+
48+
```
49+
traefik.http.routers.ghost-webfinger.rule=Host(`blog.example.com`) && PathPrefix(`/.well-known/webfinger`)
50+
traefik.http.routers.ghost-webfinger.service=activitypub-http
51+
traefik.http.services.activitypub-http.loadbalancer.server.port=8080
52+
```
53+
54+
## Upgrades
55+
56+
Ghost's version floats on `${GHOST_VERSION:-6-alpine}`. Pin explicitly by
57+
setting `GHOST_VERSION=6.2-alpine` on the resource if you need a specific
58+
release. Renovate tracks MySQL patch updates within 8.0.x and the
59+
analytics/ActivityPub image digests; Ghost itself is not pinned.
60+
61+
## Running locally (without Coolify)
62+
63+
Because the patched `compose.yml` targets Coolify's magic vars, local
64+
`docker compose up` needs those vars set in `.env`:
65+
66+
```
67+
SERVICE_URL_GHOST=http://localhost:2368
68+
SERVICE_USER_MYSQL=ghost
69+
SERVICE_PASSWORD_MYSQL=localdev
70+
SERVICE_PASSWORD_MYSQLROOT=localrootdev
71+
SERVICE_URL_ANALYTICS=http://localhost:3000
72+
```
73+
74+
For a bare-metal Ghost CLI → Docker migration, see
75+
[`scripts/migrate.sh`](scripts/migrate.sh) (not intended for Coolify hosts).
76+
77+
## License
78+
79+
MIT — see [LICENSE](LICENSE).

.github/renovate.json5

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,19 @@
88
":separatePatchReleases",
99
],
1010
suppressNotifications: ["prIgnoreNotification"],
11+
customManagers: [
12+
{
13+
// Bump GHOST_VERSION in .env.example when the user pins an explicit tag.
14+
// The line is commented by default; this only fires once uncommented.
15+
customType: "regex",
16+
fileMatch: ["^\\.env\\.example$"],
17+
matchStrings: [
18+
"^GHOST_VERSION=(?<currentValue>\\S+)\\s*$",
19+
],
20+
datasourceTemplate: "docker",
21+
depNameTemplate: "ghost",
22+
},
23+
],
1124
packageRules: [
1225
{
1326
description: "Group ActivityPub containers together",

.github/scripts/patch.py

Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
#!/usr/bin/env python3
2+
"""Coolify compatibility patch for TryGhost/ghost-docker upstream.
3+
4+
Runs after `git reset --hard upstream/main` in the daily sync workflow and
5+
rewrites the upstream compose.yml to deploy cleanly on Coolify:
6+
7+
- Removes the Caddy reverse proxy (Coolify's Traefik handles ingress)
8+
- Switches Ghost URL / MySQL credentials to Coolify SERVICE_* magic vars
9+
- Declares SERVICE_URL_<NAME>_<PORT> so Traefik discovers the right port
10+
- Adds a Ghost healthcheck for Coolify's UI indicator
11+
- Deletes caddy/ and strips Caddy refs from .env.example
12+
- Overwrites README.md with the Coolify-specific README.coolify.md
13+
14+
All edits are idempotent: re-running on already-patched input is a no-op.
15+
Each substitution fails loudly when the anchor is missing, so upstream
16+
restructuring surfaces as a nonzero exit rather than silent breakage.
17+
"""
18+
19+
from __future__ import annotations
20+
21+
import pathlib
22+
import re
23+
import shutil
24+
import sys
25+
26+
27+
def fail(msg: str) -> None:
28+
print(f"patch.py: {msg}", file=sys.stderr)
29+
sys.exit(1)
30+
31+
32+
def swap(content: str, old: str, new: str, label: str) -> str:
33+
"""Single-occurrence replacement with idempotency and loud failure.
34+
Checks `new` before `old` so that superset replacements (where `old`
35+
is a substring of `new`) don't double-apply."""
36+
if new in content:
37+
return content
38+
if old in content:
39+
n = content.count(old)
40+
if n != 1:
41+
fail(f"{label}: expected 1 occurrence of old anchor, found {n}")
42+
return content.replace(old, new, 1)
43+
fail(f"{label}: neither old nor new anchor present — upstream changed?")
44+
45+
46+
def swap_all(content: str, old: str, new: str, label: str, expected: int) -> str:
47+
"""Multi-occurrence replacement with idempotency and loud failure."""
48+
n_new = content.count(new)
49+
if n_new == expected and old not in content:
50+
return content
51+
if old in content:
52+
n = content.count(old)
53+
if n != expected:
54+
fail(f"{label}: expected {expected} occurrences of old anchor, found {n}")
55+
return content.replace(old, new)
56+
fail(f"{label}: found {n_new} of new anchor, expected {expected} — upstream changed?")
57+
58+
59+
def patch_compose() -> None:
60+
path = pathlib.Path("compose.yml")
61+
if not path.exists():
62+
fail("compose.yml not found — run from repo root")
63+
c = path.read_text()
64+
65+
# Caddy service block (Coolify's Traefik replaces it)
66+
c = re.sub(r"^ caddy:.*?(?=^ [a-z])", "", c, flags=re.MULTILINE | re.DOTALL)
67+
c = c.replace(" caddy_data:\n", "")
68+
c = c.replace(" caddy_config:\n", "")
69+
c = re.sub(r"^\s+- caddy\n", "", c, flags=re.MULTILINE)
70+
71+
# Ghost URL → Coolify magic
72+
c = swap(
73+
c,
74+
"url: https://${DOMAIN:?DOMAIN environment variable is required}\n",
75+
"url: $SERVICE_URL_GHOST\n",
76+
"ghost.url",
77+
)
78+
79+
# MySQL credentials → Coolify magic
80+
c = swap(
81+
c,
82+
"MYSQL_ROOT_PASSWORD: ${DATABASE_ROOT_PASSWORD:?DATABASE_ROOT_PASSWORD environment variable is required}",
83+
"MYSQL_ROOT_PASSWORD: $SERVICE_PASSWORD_MYSQLROOT",
84+
"MYSQL_ROOT_PASSWORD",
85+
)
86+
c = swap_all(
87+
c,
88+
"MYSQL_USER: ${DATABASE_USER:-ghost}",
89+
"MYSQL_USER: $SERVICE_USER_MYSQL",
90+
"MYSQL_USER",
91+
expected=2,
92+
)
93+
c = swap(
94+
c,
95+
"database__connection__user: ${DATABASE_USER:-ghost}",
96+
"database__connection__user: $SERVICE_USER_MYSQL",
97+
"database__connection__user",
98+
)
99+
c = swap_all(
100+
c,
101+
"MYSQL_PASSWORD: ${DATABASE_PASSWORD:?DATABASE_PASSWORD environment variable is required}",
102+
"MYSQL_PASSWORD: $SERVICE_PASSWORD_MYSQL",
103+
"MYSQL_PASSWORD",
104+
expected=2,
105+
)
106+
c = swap(
107+
c,
108+
"database__connection__password: ${DATABASE_PASSWORD:?DATABASE_PASSWORD environment variable is required}",
109+
"database__connection__password: $SERVICE_PASSWORD_MYSQL",
110+
"database__connection__password",
111+
)
112+
c = swap(
113+
c,
114+
"MYSQL_DB: mysql://${DATABASE_USER:-ghost}:${DATABASE_PASSWORD:?DATABASE_PASSWORD environment variable is required}@tcp(db:3306)/activitypub",
115+
"MYSQL_DB: mysql://$SERVICE_USER_MYSQL:$SERVICE_PASSWORD_MYSQL@tcp(db:3306)/activitypub",
116+
"activitypub-migrate.MYSQL_DB",
117+
)
118+
119+
# Tinybird tracker endpoint → analytics FQDN (optional profile)
120+
c = swap(
121+
c,
122+
"tinybird__tracker__endpoint: https://${DOMAIN:?DOMAIN environment variable is required}/.ghost/analytics/api/v1/page_hit",
123+
"tinybird__tracker__endpoint: $SERVICE_URL_ANALYTICS/api/v1/page_hit",
124+
"tinybird__tracker__endpoint",
125+
)
126+
127+
# ActivityPub image storage URL → Ghost origin (images must stay on primary domain)
128+
c = swap(
129+
c,
130+
"LOCAL_STORAGE_HOSTING_URL: https://${DOMAIN}/content/images/activitypub",
131+
"LOCAL_STORAGE_HOSTING_URL: $SERVICE_URL_GHOST/content/images/activitypub",
132+
"activitypub.LOCAL_STORAGE_HOSTING_URL",
133+
)
134+
135+
# Ghost healthcheck (inserted between env_file and environment)
136+
ghost_env_anchor = (
137+
" # This is required to import current config when migrating\n"
138+
" env_file:\n"
139+
" - .env\n"
140+
" environment:\n"
141+
)
142+
ghost_with_hc = (
143+
" # This is required to import current config when migrating\n"
144+
" env_file:\n"
145+
" - .env\n"
146+
" healthcheck:\n"
147+
' test: ["CMD", "wget", "-qO-", "http://localhost:2368/ghost/api/admin/site/"]\n'
148+
" interval: 30s\n"
149+
" timeout: 5s\n"
150+
" retries: 5\n"
151+
" start_period: 60s\n"
152+
" environment:\n"
153+
)
154+
c = swap(c, ghost_env_anchor, ghost_with_hc, "ghost.healthcheck")
155+
156+
# SERVICE_URL_<NAME>_<PORT> declarations — Coolify uses these to wire Traefik
157+
c = swap(
158+
c,
159+
" url: $SERVICE_URL_GHOST\n",
160+
' url: $SERVICE_URL_GHOST\n SERVICE_URL_GHOST_2368: ""\n',
161+
"ghost.SERVICE_URL_GHOST_2368",
162+
)
163+
c = swap(
164+
c,
165+
" environment:\n NODE_ENV: production\n PROXY_TARGET:",
166+
' environment:\n NODE_ENV: production\n SERVICE_URL_ANALYTICS_3000: ""\n PROXY_TARGET:',
167+
"traffic-analytics.SERVICE_URL_ANALYTICS_3000",
168+
)
169+
c = swap(
170+
c,
171+
" NODE_ENV: production\n MYSQL_HOST: db\n",
172+
' NODE_ENV: production\n SERVICE_URL_ACTIVITYPUB_8080: ""\n MYSQL_HOST: db\n',
173+
"activitypub.SERVICE_URL_ACTIVITYPUB_8080",
174+
)
175+
176+
path.write_text(c)
177+
178+
179+
def patch_env_example() -> None:
180+
path = pathlib.Path(".env.example")
181+
if not path.exists():
182+
return
183+
e = path.read_text()
184+
185+
# DOMAIN is supplied by Coolify's SERVICE_URL_GHOST — no need to set here
186+
e = re.sub(
187+
r"# Ghost domain\n# Custom public domain Ghost will run on\nDOMAIN=example\.com\n\n",
188+
"",
189+
e,
190+
)
191+
# Drop the Caddyfile reference in the ADMIN_DOMAIN comment
192+
e = e.replace(
193+
"# If you have Ghost Admin setup on a separate domain uncomment the line below and add the domain\n"
194+
"# You also need to uncomment the corresponding block in your Caddyfile\n",
195+
"# If Ghost Admin lives on its own domain, add it as a second FQDN on the\n"
196+
"# `ghost` service in the Coolify UI, or uncomment the line below for local runs.\n",
197+
)
198+
# Coolify owns ingress ports
199+
e = re.sub(
200+
r"# Ghost ports\n# Ports where Ghost will listen for HTTP traffic\.\n"
201+
r"# Change these if the default ports are in use, or if Ghost is behind a reverse proxy\.\n"
202+
r"HTTP_PORT=80\nHTTPS_PORT=443\n\n",
203+
"",
204+
e,
205+
)
206+
# Coolify auto-generates DB credentials via SERVICE_USER_MYSQL / SERVICE_PASSWORD_MYSQL
207+
e = re.sub(
208+
r"# Database settings\n# All database settings must not be changed once the database is initialised\n"
209+
r"DATABASE_ROOT_PASSWORD=reallysecurerootpassword\n"
210+
r"# DATABASE_USER=optionalusername\n"
211+
r"DATABASE_PASSWORD=ghostpassword\n\n",
212+
"",
213+
e,
214+
)
215+
216+
path.write_text(e)
217+
218+
219+
def overwrite_readme() -> None:
220+
src = pathlib.Path(".github/README.coolify.md")
221+
if not src.exists():
222+
fail(f"{src} missing — the sync workflow should back it up under .github/")
223+
pathlib.Path("README.md").write_text(src.read_text())
224+
225+
226+
def main() -> None:
227+
patch_compose()
228+
shutil.rmtree("caddy", ignore_errors=True)
229+
patch_env_example()
230+
overwrite_readme()
231+
print("Patched compose.yml successfully")
232+
233+
234+
if __name__ == "__main__":
235+
main()

0 commit comments

Comments
 (0)