From 946a0514f7c238bb432e58b56011af40b964d311 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 15:45:36 +0000 Subject: [PATCH] feat: make the three-runner compose a Coolify service Deploys docker-compose.yml as one Coolify resource running all three X64 runners, and fixes two upstream footguns found while verifying the file against myoung34's entrypoint. Coolify contract: - APP_ID / APP_PRIVATE_KEY / APP_LOGIN use ${VAR:?} so a half-filled Environment Variables tab blocks the deploy instead of crash-looping; the optional vars keep ${VAR:-default} so they stay UI-editable. - SERVICE_FQDN_* / SERVICE_URL_* / SERVICE_PASSWORD_* magic variables are deliberately unused: no port, no domain, and the only secret is the GitHub App key the operator supplies. - Per-service healthcheck on Runner.Listener so Coolify can tell a registered runner from a crash-looping one. Exec form on purpose: under CMD-SHELL the wrapping sh -c carries the pattern in its own cmdline and pgrep -f matches itself, reporting healthy forever. - json-file log rotation, stop_grace_period for the deregister trap, and pull_policy: always for the floating tag. Behaviour fixes: - EPHEMERAL: "false" was enabling --ephemeral. The entrypoint tests it with [ -n ... ], so any non-empty value turns the flag on, which tore each runner down after every job and contradicted the file's own comment. EPHEMERAL is no longer passed at all. - DISABLE_AUTO_UPDATE had the same trap; it is hardcoded to "true" and hidden from the UI so nobody can type "false" and get the opposite. - Each service pins RUNNER_NAME to ${RUNNER_NAME_PREFIX}-N. Coolify recreates containers on every redeploy and config.sh runs with --replace, so the org runner list keeps exactly three entries instead of collecting an offline runner per redeploy. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01T3cupBSAf2XKiDu1AYPZ3R --- .env.example | 19 ++++++---- README.md | 55 ++++++++++++++++++++++++++- docker-compose.yml | 92 ++++++++++++++++++++++++++++++++++++---------- 3 files changed, 139 insertions(+), 27 deletions(-) diff --git a/.env.example b/.env.example index b3e3658..29ebf29 100644 --- a/.env.example +++ b/.env.example @@ -1,18 +1,23 @@ # Copy to .env and fill in. Never commit .env. -# Variables interpolated by docker-compose.yml for the 3-runner X64 layout. +# On Coolify these are the resource's Environment Variables - same names, +# entered in the UI instead of in this file. -# GitHub App that registers the runners (required) +# GitHub App that registers the runners (required - the deploy is blocked +# while any of them is empty) APP_ID= APP_PRIVATE_KEY= # Org login the runners register against (also used as ORG_NAME) APP_LOGIN= -# Optional — values match the compose defaults +# Optional - values match the compose defaults. +# Runner names are ${RUNNER_NAME_PREFIX}-1 .. -3 and are stable across +# redeploys. Change the prefix when a second host registers against the same +# org, otherwise the two stacks replace each other's registrations. RUNNER_NAME_PREFIX=runner LABELS=self-hosted,Linux,X64,beelink,docker,large RUNNER_GROUP=Default -DISABLE_AUTO_UPDATE=false -# EPHEMERAL is hard-coded to "false" in docker-compose.yml so the three -# runners stay persistent and keep their per-runner cache volumes. -# Setting it here has no effect. +# Do NOT set EPHEMERAL or DISABLE_AUTO_UPDATE here. The upstream entrypoint +# tests them with `[ -n ... ]`, so even "false" turns the flag on. +# DISABLE_AUTO_UPDATE is hardcoded to "true" in docker-compose.yml, and +# EPHEMERAL is never passed, which is what keeps the runners persistent. diff --git a/README.md b/README.md index 727228e..7fe10d1 100644 --- a/README.md +++ b/README.md @@ -29,12 +29,65 @@ Existing named `runner-work*` volumes keep their data; new cache volumes start empty and warm on first jobs. Do not share one bun/gradle volume across the three containers: concurrent jobs would corrupt the same tree. +## Deploying on Coolify + +The same file deploys as **one** Coolify resource running all three runners — +create a Service (or an Application with the Docker Compose build pack) that +points at `docker-compose.yml`, then fill in the Environment Variables tab. +Coolify reads the variable syntax in the compose file: + +| Syntax | In the Coolify UI | +|--------|-------------------| +| `${VAR:?}` | Required — the deploy is blocked while it is empty (`APP_ID`, `APP_PRIVATE_KEY`, `APP_LOGIN`) | +| `${VAR:-default}` | Editable, pre-filled (`RUNNER_NAME_PREFIX`, `LABELS`, `RUNNER_GROUP`) | +| `VAR: "value"` | Hardcoded — not shown, not editable (`DISABLE_AUTO_UPDATE`) | + +Coolify's `SERVICE_FQDN_*` / `SERVICE_URL_*` / `SERVICE_PASSWORD_*` magic +variables are deliberately unused: the runners publish no port, need no +domain, and their only secret is the GitHub App key you supply. Nothing here +needs generating. To run the stack on a second host against the same org, +give that host a different `RUNNER_NAME_PREFIX` — the names are meant to stay +readable in the GitHub runner list, so pick one rather than generating one. + +Coolify prefixes the named volumes with the resource UUID, so two stacks on +one host never collide and the caches survive redeploys. + +### Stable runner names + +Each service pins `RUNNER_NAME` to `${RUNNER_NAME_PREFIX}-1` … `-3` instead of +using the upstream random suffix. Coolify recreates containers on every +redeploy, and `config.sh` runs with `--replace`, so the org runner list keeps +exactly three entries instead of collecting an offline runner per redeploy. + +### `EPHEMERAL` and `DISABLE_AUTO_UPDATE` are traps + +The upstream entrypoint tests both with `[ -n ... ]`, so **any** non-empty +value turns the flag on — `EPHEMERAL=false` passes `--ephemeral` and tears the +runner down after every job, which also makes the health check flap. The +compose file therefore never passes `EPHEMERAL` at all, and hardcodes +`DISABLE_AUTO_UPDATE=true` (a self-updating runner writes into the container +layer and loses the update on the next redeploy). Do not add either one in the +Coolify UI. + +### Health check + +Each runner reports healthy while `Runner.Listener` is alive: + +```yaml +test: ["CMD", "pgrep", "-f", "Runner.Listener"] +``` + +The exec form matters — under `CMD-SHELL` the wrapping `sh -c` carries the +pattern in its own command line and `pgrep -f` matches itself, reporting +healthy forever. + ## X64 cache volumes This compose is for the **X64** class (`beelink`, `docker`, `large`). System labels (`Linux`, `X64`) are still applied by the runner binary; set `LABELS` in `.env` to `beelink,docker,large` (do not add a `build` label). -`EPHEMERAL` stays `false` so the volumes survive between jobs. +The runners are non-ephemeral (see above), so the volumes survive between +jobs. Each runner gets its own: diff --git a/docker-compose.yml b/docker-compose.yml index 0194ee8..3306a8d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,30 +1,78 @@ -# Beelink X64 layout: three persistent runners, each with its own work and -# cache volumes. Do not share bun/gradle/npm/cargo volumes across services — -# concurrent jobs would corrupt the same tree. YAML merge of volumes: -# replaces the list, so docker.sock is repeated on each service. +# Coolify service: three persistent X64 runners in one stack. +# +# Deploy this file as a single Coolify resource (Service, or an Application +# using the Docker Compose build pack). Coolify surfaces every ${VAR} below in +# the resource's Environment Variables tab: +# +# ${VAR:?} required - Coolify blocks the deploy while it is empty +# ${VAR:-default} optional - editable, pre-filled with the default +# VAR: "value" hardcoded - not shown in the UI, not editable +# +# Coolify's SERVICE_FQDN_* / SERVICE_URL_* / SERVICE_PASSWORD_* magic variables +# are deliberately unused: the runners publish no port, need no domain, and +# their only secret is the GitHub App key you supply. If you ever run this +# stack on two hosts against the same org, give each host a different +# RUNNER_NAME_PREFIX rather than generating one - the runner names are meant +# to be readable in the GitHub UI. +# +# Coolify prefixes the named volumes with the resource UUID, so each runner +# keeps its own work and cache tree and two stacks on one host never collide. +# Do not share bun/gradle/npm/cargo volumes across services - concurrent jobs +# would corrupt the same tree. YAML merge of volumes: replaces the list, so +# docker.sock is repeated on each service. +x-runner-env: &runner-env + APP_ID: ${APP_ID:?} + # Paste the PEM as-is (multi-line is fine in Coolify) or with literal \n. + APP_PRIVATE_KEY: ${APP_PRIVATE_KEY:?} + APP_LOGIN: ${APP_LOGIN:?} + RUNNER_SCOPE: org + ORG_NAME: ${APP_LOGIN:?} + RUNNER_WORKDIR: /tmp/runner/work + LABELS: ${LABELS:-self-hosted,Linux,X64,beelink,docker,large} + RUNNER_GROUP: ${RUNNER_GROUP:-Default} + # The upstream entrypoint tests these with `[ -n ... ]`, so ANY non-empty + # value enables the flag - "false" enables it too. DISABLE_AUTO_UPDATE is + # hardcoded (and hidden from the Coolify UI) because a self-updating runner + # writes into the container layer and loses the update on the next redeploy. + # EPHEMERAL is omitted for the same reason: setting it to "false" would pass + # --ephemeral and tear the runner down after every job. + DISABLE_AUTO_UPDATE: "true" + x-runner: &runner - image: ghcr.io/dodi-smart/github-runner + image: ghcr.io/dodi-smart/github-runner:latest + pull_policy: always restart: unless-stopped - environment: - APP_ID: ${APP_ID} - APP_PRIVATE_KEY: ${APP_PRIVATE_KEY} - APP_LOGIN: ${APP_LOGIN} - RUNNER_NAME_PREFIX: ${RUNNER_NAME_PREFIX:-runner} - RUNNER_WORKDIR: /tmp/runner/work - RUNNER_SCOPE: org - ORG_NAME: ${APP_LOGIN} - LABELS: ${LABELS:-self-hosted,Linux,X64,beelink,docker,large} - RUNNER_GROUP: ${RUNNER_GROUP:-Default} - DISABLE_AUTO_UPDATE: ${DISABLE_AUTO_UPDATE:-false} - EPHEMERAL: "false" - volumes: - - /var/run/docker.sock:/var/run/docker.sock security_opt: - label:disable + # Runner.Listener is the long-lived process; with EPHEMERAL unset it stays up + # between jobs, so its absence means the runner is dead or unregistered. + # Exec form on purpose: under CMD-SHELL the wrapping `sh -c` carries the + # pattern in its own cmdline and pgrep -f would match it, reporting healthy + # forever. + healthcheck: + test: ["CMD", "pgrep", "-f", "Runner.Listener"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 120s + # Give the SIGTERM trap time to deregister the runner before SIGKILL. + stop_grace_period: 30s + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" services: + # Fixed RUNNER_NAME instead of the upstream random suffix: Coolify recreates + # containers on every redeploy, and config.sh runs with --replace, so these + # three names stay three entries in the org runner list instead of leaving a + # dead offline runner behind each time. runner: <<: *runner + environment: + <<: *runner-env + RUNNER_NAME: ${RUNNER_NAME_PREFIX:-runner}-1 volumes: - /var/run/docker.sock:/var/run/docker.sock - runner-work:/tmp/runner/work @@ -35,6 +83,9 @@ services: - runner-cargo:/root/.cargo runner-2: <<: *runner + environment: + <<: *runner-env + RUNNER_NAME: ${RUNNER_NAME_PREFIX:-runner}-2 volumes: - /var/run/docker.sock:/var/run/docker.sock - runner-work-2:/tmp/runner/work @@ -45,6 +96,9 @@ services: - runner-cargo-2:/root/.cargo runner-3: <<: *runner + environment: + <<: *runner-env + RUNNER_NAME: ${RUNNER_NAME_PREFIX:-runner}-3 volumes: - /var/run/docker.sock:/var/run/docker.sock - runner-work-3:/tmp/runner/work