Skip to content

Commit 32ec19f

Browse files
mariuspruvotclaude
andcommitted
refactor(infra): use build-only service pattern for claude-runner
Switches from "out-of-band make build-runner" (previous commit) to a proper Compose build-only service: claude-runner: build: { context: ./infra/docker/claude-runner } image: claude-runner:latest entrypoint: ["/bin/true"] restart: "no" The container exits immediately on `docker compose up` (entrypoint is overridden to `/bin/true`) and is not restarted, so it produces no runtime overhead — but Compose still builds the image and leaves it tagged `claude-runner:latest` on the host, ready for the API to spawn session containers from via the Docker socket. Benefits over the previous commit: - `docker compose up --build` is the single command on every host (Coolify, VPS, local dev, ECS EC2) - No Coolify pre-deployment command to configure in the UI - No `make build-runner` step for users - Rebuilds automatically whenever `infra/docker/claude-runner/` changes Applied to both the dev `docker-compose.yml` and the prod `infra/coolify/docker-compose.prod.yml`. `make build-runner` stays as a convenience shortcut for quick iteration. Docs reverted to the simpler "just run compose" narrative. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 25c190a commit 32ec19f

7 files changed

Lines changed: 63 additions & 65 deletions

File tree

CLAUDE.md

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
## Quick Start
22

33
```bash
4-
make build-runner # One-time per Docker host: build the claude-runner image
54
docker compose up --build # Start all services (API :8000, Web :5173, Postgres :5432)
65
make lint # Ruff check + format + mypy (API), ESLint (Web)
76
make test # pytest (API), vitest (Web)
@@ -138,7 +137,7 @@ Key additions for production: `ENVIRONMENT=production`, `ADMIN_PASSWORD`, `CORS_
138137
- **Open source target**: designed for self-hosting with own Claude licenses
139138
- **Post-results to PR**: after session completion, the API can post score card as a PR comment — opt-in per installation via `post_results_to_pr` boolean; extraction and formatting in `container/pr_comment.py`, triggered in `_event_stream()` after `mark_completed()`
140139
- **Coolify deployment**: Two-domain setup via Traefik: `helprs.tech` (web) and `api.helprs.tech` (API). TLS via Let's Encrypt, managed by Coolify. Domains are set in Coolify UI (General > Domains for api / Domains for web) — they may get cleared on redeploy, re-check after each deploy. "Preserve Repository During Deployment" must be enabled so skills are available on the host. The prod compose uses `./` paths (repo-root-relative) because Coolify sets `--project-directory` to the repo root.
141-
- **claude-runner image**: NOT a Compose service — it is a runtime dependency built once per Docker host with `make build-runner` (or a pre-deploy command on managed platforms). The API spawns containers from it dynamically via the mounted Docker socket. Image name is `claude-runner:latest` (no namespace prefix — hard-coded in `container/service.py:CLAUDE_RUNNER_IMAGE`). Previous attempt to include it as a `profiles: [build-only]` compose service was removed because `profiles:` excludes the service from both build and run unless the profile is explicitly activatedwhich Coolify does not do, causing `404 No such image` failures on spawn.
140+
- **claude-runner image**: declared in both `docker-compose.yml` and `infra/coolify/docker-compose.prod.yml` as a **build-only service**`entrypoint: ["/bin/true"]` + `restart: "no"` so the container exits immediately on `up`, leaving only the built image `claude-runner:latest` on the host. The API spawns containers from this image dynamically via the mounted Docker socket. Image tag is hard-coded in `container/service.py:CLAUDE_RUNNER_IMAGE` — it must stay `claude-runner:latest` (no namespace prefix). Earlier attempt with `profiles: [build-only]` was reverted: `profiles:` excludes a service from both build AND run unless the profile is explicitly activated, which Coolify does not do, causing `404 No such image` failures on session spawn.
142141
- **Non-root API container**: production Dockerfile uses `appuser` with `chown -R appuser:appuser /app` for uv cache writes. Docker socket access requires `group_add: ["${DOCKER_GID:-994}"]` in the compose to match the host's docker group GID.
143142
- **BYOK supports OAuth tokens**: `validate_claude_key()` accepts both API keys (`sk-ant-api03-...`) and OAuth tokens (`sk-ant-oat...`). OAuth tokens skip server-side validation (validated at runtime by Claude Code CLI). Frontend setup page guides users to `claude setup-token`.
144143
- **VITE_* build args**: `VITE_API_URL` and `VITE_GITHUB_APP_SLUG` are build-time variables — must be passed as `args` in the compose and declared as `ARG`/`ENV` in `Dockerfile.web`. They cannot be set at runtime.

Makefile

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,13 @@ test:
1818

1919
build:
2020
docker compose -f infra/coolify/docker-compose.prod.yml build
21-
$(MAKE) build-runner
2221

23-
# Builds the claude-runner image used by the API to spawn per-session
24-
# containers. This is a one-time setup per Docker host — the image is not a
25-
# service, it's a runtime dependency referenced by tag in
26-
# helprs/modules/container/service.py (CLAUDE_RUNNER_IMAGE). Re-run this
27-
# target whenever infra/docker/claude-runner/ changes.
22+
# Rebuild just the claude-runner image. Normally you don't need to call this
23+
# directly — both compose files declare claude-runner as a build-only service,
24+
# so `docker compose up --build` produces the image as a side-effect. This
25+
# shortcut is useful for quick iteration on infra/docker/claude-runner/ without
26+
# touching the API or web services. Image tag must match
27+
# CLAUDE_RUNNER_IMAGE in helprs/modules/container/service.py.
2828
build-runner:
2929
docker build -t claude-runner:latest infra/docker/claude-runner/
3030

README.md

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,7 @@ cd helprs
4242
cp .env.example .env
4343
# Fill in .env (see docs/self-hosting.md for details)
4444

45-
make build-runner # One-time: build the Claude Code runner image (required before first session)
46-
docker compose up --build # API :8000, Web :5173, Postgres :5432
45+
docker compose up --build # API :8000, Web :5173, Postgres :5432 (+ builds the claude-runner image)
4746
```
4847

4948
Open [http://localhost:5173](http://localhost:5173), authenticate with GitHub, and you're ready to go.

docker-compose.yml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,16 @@ services:
3434
- CHOKIDAR_USEPOLLING=true
3535
command: npx vite --host 0.0.0.0 --port 5173
3636

37+
# Build-only service: the API spawns containers from this image dynamically
38+
# via the Docker socket. `entrypoint: /bin/true` + `restart: "no"` make the
39+
# container exit immediately — the image stays available on the host.
40+
claude-runner:
41+
build:
42+
context: ./infra/docker/claude-runner
43+
image: claude-runner:latest
44+
entrypoint: ["/bin/true"]
45+
restart: "no"
46+
3747
db:
3848
image: postgres:16-alpine
3949
ports:

docs/deploy-coolify.md

Lines changed: 19 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -133,21 +133,6 @@ Click **Save**. Coolify generates Traefik routing rules and provisions Let's Enc
133133

134134
- **Preserve Repository During Deployment**: **Enable this**. Without it, Coolify removes the cloned repo after building images. The `skills/` directory must remain on the host because the API mounts it into claude-runner containers at runtime.
135135

136-
### Pre-deployment Command (required)
137-
138-
The API spawns ephemeral containers from the `claude-runner` image. This image is **not** a Compose service — it is a runtime dependency that must exist on the Docker host before the API can start a session.
139-
140-
Set Coolify's **Pre-deployment Command** to:
141-
142-
```bash
143-
docker build -t claude-runner:latest /data/coolify/applications/<uuid>/infra/docker/claude-runner/
144-
```
145-
146-
Replace `<uuid>` with your Coolify application directory (same one you use for `SKILLS_HOST_PATH` in step 7). The image is tagged `claude-runner:latest` with no namespace prefix — this is the exact tag the API references (`CLAUDE_RUNNER_IMAGE` in `apps/api/src/helprs/modules/container/service.py`).
147-
148-
!!! tip "Why a pre-deploy command?"
149-
Coolify runs `docker compose up --build`, which only builds services listed in the compose file. Keeping `claude-runner` out of the compose avoids two footguns: Compose trying to start a container that has no long-lived process, and `profiles:` silently excluding the service from the build. A one-line pre-deploy command is explicit and portable to any host. Re-runs are cheap (Docker caches the layers).
150-
151136
### Advanced > General
152137

153138
- **Auto Deploy**: Enable for automatic redeployment on push to `main`.
@@ -159,10 +144,9 @@ Replace `<uuid>` with your Coolify application directory (same one you use for `
159144
Click **Deploy**. Coolify will:
160145

161146
1. Clone the repo
162-
2. Run the pre-deploy command to build `claude-runner:latest` on the host
163-
3. Build the API and Web images
164-
4. Start the API, Web, and DB containers (claude-runner is not a service — it is spawned per session by the API)
165-
5. Run Alembic migrations automatically (API entrypoint runs `alembic upgrade head`)
147+
2. Build the API, Web, and claude-runner images (claude-runner is a **build-only service** — the container exits immediately with `/bin/true`, leaving the image available on the host for the API to spawn session containers from)
148+
3. Start the API, Web, and DB containers
149+
4. Run Alembic migrations automatically (API entrypoint runs `alembic upgrade head`)
166150

167151
### Verify
168152

@@ -235,17 +219,13 @@ The `docker-compose.prod.yml` has `group_add: ["${DOCKER_GID:-994}"]`. If your s
235219
docker images | grep claude-runner
236220
# Should show: claude-runner latest <id> <date> <size>
237221

238-
docker ps | grep claude-runner
239-
# Should be empty (it's only spawned on demand, not a long-lived service)
222+
docker ps -a | grep claude-runner
223+
# Expected: an "Exited (0)" container from the last compose up — this is
224+
# normal, the service is declared as build-only (entrypoint /bin/true).
225+
# The API does NOT use this container — it spawns fresh ones per session.
240226
```
241227

242-
If the image is missing, the pre-deployment command did not run or failed. Build it manually:
243-
244-
```bash
245-
docker build -t claude-runner:latest /data/coolify/applications/<uuid>/infra/docker/claude-runner/
246-
```
247-
248-
Then re-check the pre-deployment command in Coolify (step 5) so future deploys rebuild it automatically. An image pruning operation (`docker image prune -a`) will also remove it — the pre-deploy command will rebuild it on the next deploy.
228+
If the image is missing, the compose build step failed. Check Coolify's **Logs** tab for build errors (usually a Dockerfile issue in `infra/docker/claude-runner/`) and redeploy.
249229

250230
---
251231

@@ -308,13 +288,20 @@ The `DOCKER_GID` does not match your server's Docker group. See [step 8](#8-dock
308288
No such image: claude-runner:latest
309289
```
310290

311-
This happens when the pre-deployment command (step 5) did not run or the image was pruned. Build it manually on the host:
291+
The claude-runner service did not build. Verify `infra/coolify/docker-compose.prod.yml` includes the service block:
312292

313-
```bash
314-
docker build -t claude-runner:latest /data/coolify/applications/<uuid>/infra/docker/claude-runner/
293+
```yaml
294+
claude-runner:
295+
build:
296+
context: ./infra/docker/claude-runner
297+
image: claude-runner:latest
298+
entrypoint: ["/bin/true"]
299+
restart: "no"
315300
```
316301
317-
Then verify the pre-deployment command is configured in Coolify so it rebuilds on every deploy. The image name must be exactly `claude-runner:latest` (no namespace prefix) — the API references this tag in `apps/api/src/helprs/modules/container/service.py`.
302+
Redeploy via Coolify. Check the deploy logs for errors in the `claude-runner` build step. The image name must be exactly `claude-runner:latest` (no namespace prefix) — the API references this tag in `apps/api/src/helprs/modules/container/service.py`.
303+
304+
If the image was pruned manually (`docker image prune -a`), just redeploy — Compose rebuilds it as part of the stack.
318305

319306
### Skills directory is empty
320307

docs/self-hosting.md

Lines changed: 14 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -169,26 +169,24 @@ POSTGRES_PASSWORD= # Strong password for production DB
169169
### Option A: Docker Compose (simplest)
170170

171171
```bash
172-
# 1. Build the claude-runner image (one-time per host; re-run if infra/docker/claude-runner/ changes)
173-
make build-runner
174-
175-
# 2. Build and start services (api, web, db)
172+
# Build and start all services. The claude-runner image is built as a side-
173+
# effect and left available on the host for the API to spawn containers from.
176174
docker compose -f infra/coolify/docker-compose.prod.yml up -d --build
177175

178-
# 3. Verify services are healthy
176+
# Verify services are healthy
179177
docker compose -f infra/coolify/docker-compose.prod.yml ps
180178
```
181179

182-
!!! important "Why `claude-runner` is not in the compose file"
183-
The `claude-runner` image is a **runtime dependency**, not a service. The API
184-
spawns a new container from it for each session (via the mounted Docker socket)
185-
— it is never run as a long-lived process. Putting it in the compose file would
186-
either cause Compose to try to start it (and crash), or require a `profiles:`
187-
flag that silently excludes it from automated builds. A plain `docker build` is
188-
explicit and works on every Docker host.
180+
!!! note "About the `claude-runner` "Exited" container"
181+
The `claude-runner` service is a **build-only service**: `docker compose up`
182+
builds the image, starts the container, which exits immediately (its entrypoint
183+
is overridden to `/bin/true`) with `restart: "no"`. The container stays in
184+
`Exited (0)` state and is not restarted — but the image `claude-runner:latest`
185+
remains available on the host. The API spawns new containers from this image
186+
per session via the Docker socket.
189187

190-
Re-run `make build-runner` after editing anything under `infra/docker/claude-runner/`
191-
or after a `docker image prune -a`.
188+
This pattern keeps `docker compose up --build` as the single source of truth
189+
for the whole stack, including the runner image.
192190

193191
The API runs on port 8000, the frontend on port 80. You need a reverse proxy (nginx, Caddy, Traefik) in front to handle TLS and route traffic.
194192

@@ -220,10 +218,7 @@ cd helprs
220218
cp .env.example .env
221219
# Edit .env with your values...
222220

223-
# Build the claude-runner image (one-time per host)
224-
make build-runner
225-
226-
# Start services
221+
# Start services (the claude-runner image is built as a build-only service)
227222
docker compose -f infra/coolify/docker-compose.prod.yml up -d --build
228223

229224
# Set up your preferred reverse proxy (Caddy example)
@@ -330,7 +325,7 @@ If you enabled **Post results to PR** in installation settings, the score card i
330325
### Container won't start
331326

332327
- Verify Docker socket is mounted: check that `/var/run/docker.sock` is accessible to the API container
333-
- Verify the claude-runner image exists: `docker images | grep claude-runner` — if missing, run `make build-runner` (the image is a per-host one-time build, not a Compose service)
328+
- Verify the claude-runner image exists: `docker images | grep claude-runner` — if missing, run `docker compose up --build` (the image is built as part of the normal compose up)
334329
- Check `SKILLS_HOST_PATH` is an absolute path and the directory exists on the Docker host
335330
- Verify the `DOCKER_GID` matches the host's Docker group: `getent group docker | cut -d: -f3`
336331
- Check API logs for container creation errors: `docker compose logs api | grep container`

infra/coolify/docker-compose.prod.yml

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -60,10 +60,18 @@ services:
6060
memory: 128M
6161
restart: unless-stopped
6262

63-
# Note: the `claude-runner` image is NOT a service — it is a runtime
64-
# dependency built once per Docker host with `make build-runner` (or via a
65-
# Coolify pre-deploy command). The API spawns containers from it dynamically
66-
# via the Docker socket. See docs/deploy-coolify.md and docs/self-hosting.md.
63+
# Build-only service: the API spawns containers from this image dynamically
64+
# via the Docker socket. We still declare it here so `docker compose up --build`
65+
# produces the image; `entrypoint: /bin/true` + `restart: "no"` make the
66+
# container exit immediately without becoming a long-lived service. The image
67+
# `claude-runner:latest` then stays available on the host for the API to use.
68+
# Image tag is hard-coded in helprs/modules/container/service.py.
69+
claude-runner:
70+
build:
71+
context: ./infra/docker/claude-runner
72+
image: claude-runner:latest
73+
entrypoint: ["/bin/true"]
74+
restart: "no"
6775

6876
db:
6977
image: postgres:16-alpine

0 commit comments

Comments
 (0)