Skip to content

Commit c58e2f0

Browse files
mariuspruvotclaude
andauthored
fix(infra): build claude-runner via compose build-only service (#40)
* fix(infra): build claude-runner image out-of-band instead of via compose profile The `claude-runner` image is a runtime dependency (the API spawns containers from it per session), not a Compose service. A previous attempt shipped it as a `profiles: [build-only]` service, but Compose excludes profiled services from both build and run unless the profile is explicitly activated — which Coolify does not do, causing `404 No such image: claude-runner:latest` when the API tries to start a session. Changes: - Remove `claude-runner` service from infra/coolify/docker-compose.prod.yml - Fix the Makefile `build-runner` tag (`claude-runner:latest`, no namespace) - Make `make build` also build the runner - Document the per-host one-time build in Quick Start, self-hosting guide, and Coolify guide (with pre-deployment command) - Update CLAUDE.md decisions and troubleshooting Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * 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> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent e46c070 commit c58e2f0

7 files changed

Lines changed: 63 additions & 29 deletions

File tree

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,7 @@ Key additions for production: `ENVIRONMENT=production`, `ADMIN_PASSWORD`, `CORS_
137137
- **Open source target**: designed for self-hosting with own Claude licenses
138138
- **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()`
139139
- **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.
140-
- **claude-runner image**: built via a `profiles: [build-only]` service in the prod compose — Coolify builds the image but never starts the container. The API spawns it dynamically. Image name is `claude-runner:latest` (no namespace prefix).
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.
141141
- **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.
142142
- **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`.
143143
- **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: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,14 @@ test:
1919
build:
2020
docker compose -f infra/coolify/docker-compose.prod.yml build
2121

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.
2228
build-runner:
23-
docker build -t helprs/claude-runner:latest infra/docker/claude-runner/
29+
docker build -t claude-runner:latest infra/docker/claude-runner/
2430

2531
migrate:
2632
cd apps/api && uv run alembic upgrade head

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-
docker compose up --build # API :8000, Web :5173, Postgres :5432
46-
make build-runner # Build the Claude Code container image
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: 21 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -144,8 +144,8 @@ Click **Save**. Coolify generates Traefik routing rules and provisions Let's Enc
144144
Click **Deploy**. Coolify will:
145145

146146
1. Clone the repo
147-
2. Build the API, Web, and claude-runner images
148-
3. Start the API, Web, and DB containers (claude-runner is `profiles: [build-only]`, not started)
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
149149
4. Run Alembic migrations automatically (API entrypoint runs `alembic upgrade head`)
150150

151151
### Verify
@@ -219,20 +219,13 @@ The `docker-compose.prod.yml` has `group_add: ["${DOCKER_GID:-994}"]`. If your s
219219
docker images | grep claude-runner
220220
# Should show: claude-runner latest <id> <date> <size>
221221

222-
docker ps | grep claude-runner
223-
# 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.
224226
```
225227

226-
If the image is missing, the `claude-runner` service may have been excluded from the build. Verify the compose file includes:
227-
228-
```yaml
229-
claude-runner:
230-
build:
231-
context: ./infra/docker/claude-runner
232-
image: claude-runner
233-
profiles:
234-
- build-only
235-
```
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.
236229

237230
---
238231

@@ -295,7 +288,20 @@ The `DOCKER_GID` does not match your server's Docker group. See [step 8](#8-dock
295288
No such image: claude-runner:latest
296289
```
297290

298-
Verify the compose includes the claude-runner service with `profiles: [build-only]`. Redeploy to rebuild the image.
291+
The claude-runner service did not build. Verify `infra/coolify/docker-compose.prod.yml` includes the service block:
292+
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"
300+
```
301+
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.
299305

300306
### Skills directory is empty
301307

docs/self-hosting.md

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

171171
```bash
172-
# Build and start all services (including claude-runner image)
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.
173174
docker compose -f infra/coolify/docker-compose.prod.yml up -d --build
174175

175176
# Verify services are healthy
176177
docker compose -f infra/coolify/docker-compose.prod.yml ps
177178
```
178179

179-
!!! note "claude-runner image"
180-
The `claude-runner` service in the compose file uses `profiles: [build-only]`.
181-
It is built during `docker compose up --build` but does not run as a long-lived
182-
service. The API spawns claude-runner containers on demand.
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.
187+
188+
This pattern keeps `docker compose up --build` as the single source of truth
189+
for the whole stack, including the runner image.
183190

184191
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.
185192

@@ -211,7 +218,7 @@ cd helprs
211218
cp .env.example .env
212219
# Edit .env with your values...
213220

214-
# Start services
221+
# Start services (the claude-runner image is built as a build-only service)
215222
docker compose -f infra/coolify/docker-compose.prod.yml up -d --build
216223

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

320327
- Verify Docker socket is mounted: check that `/var/run/docker.sock` is accessible to the API container
321-
- Verify the claude-runner image exists: `docker images | grep claude-runner`
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)
322329
- Check `SKILLS_HOST_PATH` is an absolute path and the directory exists on the Docker host
323330
- Verify the `DOCKER_GID` matches the host's Docker group: `getent group docker | cut -d: -f3`
324331
- Check API logs for container creation errors: `docker compose logs api | grep container`

infra/coolify/docker-compose.prod.yml

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

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.
6369
claude-runner:
6470
build:
6571
context: ./infra/docker/claude-runner
66-
image: claude-runner
67-
profiles:
68-
- build-only
72+
image: claude-runner:latest
73+
entrypoint: ["/bin/true"]
74+
restart: "no"
6975

7076
db:
7177
image: postgres:16-alpine

0 commit comments

Comments
 (0)