Files
HealthLog/docs/audit/v1415-auto-deploy.md
T
Marc-André BombeckandClaude Opus 4.7 1a46bfe78a chore(security): MED cluster — Coolify URL scrub + isCurrent device-id + SLEEP_DURATION migration note
- Coolify runbook + audit briefs scrub the live deploy URL to a
  placeholder (Sec-MED-2). Real values live in repo secrets.
- /api/auth/me/devices isCurrent now compares against the session's
  device id, not the unauthenticated X-Device-Id header (Sr-MED-5).
- Migration 0036 sleep-duration unit shift documented + verification
  command added to the coolify-auto-deploy howto.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 02:44:12 +02:00

8.8 KiB
Raw Blame History

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:

  1. Tag a release → GHCR builds and pushes the image (docker-publish.yml).

  2. Trigger Coolify "Deploy" via the dashboard.

  3. Manually SSH to apps-01 and 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'
    
  4. Verify /api/version returned 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: dockercompose
  • git_repository: MBombeck/HealthLog, git_branch: main
  • source_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 for dockercompose-typed apps.
  • manual_webhook_secret_github: null — no webhook secret configured today.

What Coolify does support natively (per the Coolify docs):

  1. GitHub App auto-deploy: pushes to main trigger 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.
  2. 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, then curl --request GET '${{ secrets.COOLIFY_WEBHOOK }}' --header 'Authorization: Bearer ${{ secrets.COOLIFY_TOKEN }}'."
  3. 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)

  1. feat(deploy): auto-deploy on GHCR push (no more manual force-pull)

    • .github/workflows/docker-publish.yml: after the Build & push image step, add a final Trigger Coolify deploy step that runs only on the publish path (push to main or to a v* tag — never on PRs). It uses secrets.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.
  2. feat(deploy): admin notification + audit log on deploy success/failure

    • New endpoint: src/app/api/internal/deploy-webhook/route.ts that accepts a Coolify-format JSON payload and:
      • validates X-Deploy-Webhook-Secret header (timing-safe) against process.env.DEPLOY_WEBHOOK_SECRET;
      • rate-limits by client IP;
      • writes an auditLog row with system.deploy.{success,failure};
      • on failure, calls dispatchNotification({ eventType: "SYSTEM_ALERT" }) for every admin user (so Telegram is paged if configured) — same pattern as src/lib/integrations/status.ts:maybeAlertAdmins.
    • 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.
  3. 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:

  1. Generate a Coolify API token in Coolify UI → Keys & Tokens → "Create New Token" with permission * (or read:sensitive plus deploy). Token format: n_<base62>.
  2. Add two GitHub repository secrets:
    • COOLIFY_WEBHOOK = https://<COOLIFY_INSTANCE>/api/v1/deploy?uuid=<APPLICATION_UUID>&force=false (the actual values live in repo secrets only).
    • COOLIFY_TOKEN = the token from step 1.
  3. 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 into apps-01:.env for the HealthLog container)
    • Enable: deployment success + deployment failure
  4. Set DEPLOY_WEBHOOK_SECRET env var on apps-01 (HealthLog container .env + Coolify environment variables UI). Generate with openssl 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