Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 12 additions & 7 deletions .env.example
Original file line number Diff line number Diff line change
@@ -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.
55 changes: 54 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
92 changes: 73 additions & 19 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down