Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
8.7 KiB
v1.4.15 — Auto-deployment audit & design
Stage: C2
Last updated: 2026-05-09 (v1.4.15 release cycle)
Status: designed + shipped — see commits in .planning/phase-C2-report.md.
Background
Through v1.4.6 → v1.4.14 the recipe to deploy a freshly-published GHCR image to production has been:
-
Tag a release → GHCR builds and pushes the image (
docker-publish.yml). -
Trigger Coolify "Deploy" via the dashboard.
-
Manually SSH to
apps-01and force-pull the image because Coolify's own deploy did not always honour the new digest:ssh apps-01 'docker pull ghcr.io/mbombeck/healthlog:1.4.X && \ docker tag ghcr.io/mbombeck/healthlog:1.4.X ghcr.io/mbombeck/healthlog:latest && \ cd /data/coolify/applications/pg8wggwogo8c4gc4ks0kk4ss && \ docker compose up -d app' -
Verify
/api/versionreturned the new version + image digest changed.
The maintainer's request for v1.4.15 — eliminate step 2 and 3. When GHCR has a new image, the production container should redeploy by itself.
Investigation — what Coolify supports
Coolify version on apps-01.bombeck.io: 4.0.0-beta.470 (verified via
mcp__coolify-apps01__get_version).
The current application config (UUID pg8wggwogo8c4gc4ks0kk4ss,
/api/v1/applications/pg8wggwogo8c4gc4ks0kk4ss) shows:
build_pack: dockercomposegit_repository: MBombeck/HealthLog,git_branch: mainsource_type: App\Models\GithubApp— this matters: Coolify's "auto-deploy on git push" is wired to the GitHub App webhook, not to the GHCR package webhook. There is no native "redeploy when registry has new image" mode fordockercompose-typed apps.manual_webhook_secret_github: null— no webhook secret configured today.
What Coolify does support natively (per the Coolify docs):
- GitHub App auto-deploy: pushes to
maintrigger an immediate redeploy. For us this would deploy from a SHA before GHCR finishes building, since GHCR runs in the same push event in parallel. Race-prone. - Deploy webhook URL: every Coolify resource exposes
GET https://<coolify-fqdn>/api/v1/deploy?uuid=<resource-uuid>with Bearer-token auth. This is a trigger-only surface — it queues a redeploy. Coolify treats this as an authenticated remote-control command; the recipe in the docs is "GitHub Actions builds the image, pushes to GHCR, thencurl --request GET '${{ secrets.COOLIFY_WEBHOOK }}' --header 'Authorization: Bearer ${{ secrets.COOLIFY_TOKEN }}'." - Notification webhook (outgoing): Coolify can POST a deployment-event payload to a configured URL on success and failure. This is how we get a notification back into the app for audit logging + admin alerts.
(2) solves the "trigger" half; (3) solves the "report" half. We use both.
Decision — chosen approach
Path A (chosen): GitHub Actions → Coolify deploy webhook → Coolify
notifications → HealthLog POST /api/internal/deploy-webhook.
Rationale:
- Native to Coolify; no extra daemon on
apps-01. - Both halves of the auto-deploy contract (trigger + status) live in configuration we already control.
- Watchtower would mean shipping a new container, an extra footprint to audit, and (since v1.4.14 has multi-tenant prep with worker-mode separation) a polling daemon that might wake the app off-pattern. The Coolify webhook is event-driven and free.
Path B (rejected — but kept on the table for v1.4.16 if Coolify webhook
proves unreliable): Watchtower with a per-image label
(com.centurylinklabs.watchtower.enable=true) gated on ghcr.io/mbombeck/healthlog.
Implementation plan (3 atomic commits)
-
feat(deploy): auto-deploy on GHCR push (no more manual force-pull).github/workflows/docker-publish.yml: after theBuild & push imagestep, add a finalTrigger Coolify deploystep that runs only on the publish path (push tomainor to av*tag — never on PRs). It usessecrets.COOLIFY_WEBHOOK(the full Bearer-protected URL with?uuid=…)secrets.COOLIFY_TOKEN. Failure of the deploy trigger does not fail the build (the image is already published; deploy is a follow-up).
- Document required secrets in
.env.example.
-
feat(deploy): admin notification + audit log on deploy success/failure- New endpoint:
src/app/api/internal/deploy-webhook/route.tsthat accepts a Coolify-format JSON payload and:- validates
X-Deploy-Webhook-Secretheader (timing-safe) againstprocess.env.DEPLOY_WEBHOOK_SECRET; - rate-limits by client IP;
- writes an
auditLogrow withsystem.deploy.{success,failure}; - on failure, calls
dispatchNotification({ eventType: "SYSTEM_ALERT" })for every admin user (so Telegram is paged if configured) — same pattern assrc/lib/integrations/status.ts:maybeAlertAdmins.
- validates
- Webhook handler is a Next.js Route Handler — no new deps.
- Unit-tested with the same mock pattern as
src/app/api/withings/webhook/__tests__/route.test.ts.
- New endpoint:
-
Document everything in this file +
phase-C2-report.md.
Maintainer-side configuration after merge
Since the automated tooling does not have access to the GitHub repo settings or Coolify's UI directly, three manual steps remain after the auto-deploy code ships:
- Generate a Coolify API token in Coolify UI →
Keys & Tokens→ "Create New Token" with permission*(orread:sensitiveplus deploy). Token format:n_<base62>. - Add two GitHub repository secrets:
COOLIFY_WEBHOOK=https://apps-01.bombeck.io/api/v1/deploy?uuid=pg8wggwogo8c4gc4ks0kk4ss&force=falseCOOLIFY_TOKEN= the token from step 1.
- Configure Coolify outgoing notifications:
- Settings → Notifications → Webhook
- URL:
https://healthlog.bombeck.io/api/internal/deploy-webhook - Add custom header:
X-Deploy-Webhook-Secret: <DEPLOY_WEBHOOK_SECRET>(the value placed intoapps-01:.envfor the HealthLog container) - Enable: deployment success + deployment failure
- Set
DEPLOY_WEBHOOK_SECRETenv var onapps-01(HealthLog container.env+ Coolify environment variables UI). Generate withopenssl rand -hex 32.
These four manual steps are tracked in .planning/phase-C2-report.md's
"Maintainer-side follow-up" section. The code is fully complete and tested
without them; auto-deploy simply doesn't fire until the secrets are
populated.
Failure modes considered
| Failure | What happens |
|---|---|
| Coolify deploy URL down | GH Actions step continues (continue-on-error: true); image stays on GHCR, deploy falls back to manual. |
| Coolify notification fires before image pull | Coolify retries the pull internally; final status (success/failure) reaches our webhook regardless. |
| Webhook secret leaked | Rotate DEPLOY_WEBHOOK_SECRET, redeploy. Audit-log entry on every received call shows source IP. |
| Webhook handler down at deploy time | Coolify retries 3× per docs; final missed event is recoverable from Coolify's own deployment log. |
| Coolify API rejects token (permission scope) | GH Actions step prints the response body; the next merge surfaces it as a workflow failure. |
| GHCR push succeeds but webhook step is broken | Image still gets to GHCR; manual deploy still works as a fallback. |
Watchtower — why not (revisited)
If Path A breaks (Coolify webhook flaky, beta channel regression, …), v1.4.16 can install Watchtower:
# /data/coolify/applications/pg8wggwogo8c4gc4ks0kk4ss/docker-compose.yaml
# (additional services block, NOT in the main compose)
services:
watchtower:
image: containrrr/watchtower:1.7.1
volumes:
- /var/run/docker.sock:/var/run/docker.sock
command:
- --interval=300
- --label-enable
restart: unless-stopped
with the existing app service labelled
com.centurylinklabs.watchtower.enable=true. We deliberately do not ship
this in v1.4.15 — adding a polling daemon to the production host is a
material expansion of the trusted compute base and the Coolify path is
good enough.
References
- Coolify GitHub Actions guide: https://coolify.io/docs/applications/ci-cd/github/actions
- Coolify Authorization API docs: https://coolify.io/docs/api-reference/authorization
- v1.4.14 manual force-pull recipe:
.planning/phase-8-report.md - C3 docker-publish reliability fix:
docs/audit/v1415-ci-reliability.md