Skip to content

Commit 25c190a

Browse files
mariuspruvotclaude
andcommitted
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>
1 parent e46c070 commit 25c190a

6 files changed

Lines changed: 64 additions & 28 deletions

File tree

CLAUDE.md

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

33
```bash
4+
make build-runner # One-time per Docker host: build the claude-runner image
45
docker compose up --build # Start all services (API :8000, Web :5173, Postgres :5432)
56
make lint # Ruff check + format + mypy (API), ESLint (Web)
67
make test # pytest (API), vitest (Web)
@@ -137,7 +138,7 @@ Key additions for production: `ENVIRONMENT=production`, `ADMIN_PASSWORD`, `CORS_
137138
- **Open source target**: designed for self-hosting with own Claude licenses
138139
- **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()`
139140
- **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).
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 activated — which Coolify does not do, causing `404 No such image` failures on spawn.
141142
- **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.
142143
- **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`.
143144
- **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
@@ -18,9 +18,15 @@ test:
1818

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

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.
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 & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,8 @@ 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)
4546
docker compose up --build # API :8000, Web :5173, Postgres :5432
46-
make build-runner # Build the Claude Code container image
4747
```
4848

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

docs/deploy-coolify.md

Lines changed: 31 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,21 @@ 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+
136151
### Advanced > General
137152

138153
- **Auto Deploy**: Enable for automatic redeployment on push to `main`.
@@ -144,9 +159,10 @@ Click **Save**. Coolify generates Traefik routing rules and provisions Let's Enc
144159
Click **Deploy**. Coolify will:
145160

146161
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)
149-
4. Run Alembic migrations automatically (API entrypoint runs `alembic upgrade head`)
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`)
150166

151167
### Verify
152168

@@ -223,17 +239,14 @@ docker ps | grep claude-runner
223239
# Should be empty (it's only spawned on demand, not a long-lived service)
224240
```
225241

226-
If the image is missing, the `claude-runner` service may have been excluded from the build. Verify the compose file includes:
242+
If the image is missing, the pre-deployment command did not run or failed. Build it manually:
227243

228-
```yaml
229-
claude-runner:
230-
build:
231-
context: ./infra/docker/claude-runner
232-
image: claude-runner
233-
profiles:
234-
- build-only
244+
```bash
245+
docker build -t claude-runner:latest /data/coolify/applications/<uuid>/infra/docker/claude-runner/
235246
```
236247

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.
249+
237250
---
238251

239252
## 10. Install the GitHub App
@@ -295,7 +308,13 @@ The `DOCKER_GID` does not match your server's Docker group. See [step 8](#8-dock
295308
No such image: claude-runner:latest
296309
```
297310

298-
Verify the compose includes the claude-runner service with `profiles: [build-only]`. Redeploy to rebuild the image.
311+
This happens when the pre-deployment command (step 5) did not run or the image was pruned. Build it manually on the host:
312+
313+
```bash
314+
docker build -t claude-runner:latest /data/coolify/applications/<uuid>/infra/docker/claude-runner/
315+
```
316+
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`.
299318

300319
### Skills directory is empty
301320

docs/self-hosting.md

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -169,17 +169,26 @@ 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+
# 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)
173176
docker compose -f infra/coolify/docker-compose.prod.yml up -d --build
174177

175-
# Verify services are healthy
178+
# 3. Verify services are healthy
176179
docker compose -f infra/coolify/docker-compose.prod.yml ps
177180
```
178181

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.
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.
189+
190+
Re-run `make build-runner` after editing anything under `infra/docker/claude-runner/`
191+
or after a `docker image prune -a`.
183192

184193
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.
185194

@@ -211,6 +220,9 @@ cd helprs
211220
cp .env.example .env
212221
# Edit .env with your values...
213222

223+
# Build the claude-runner image (one-time per host)
224+
make build-runner
225+
214226
# Start services
215227
docker compose -f infra/coolify/docker-compose.prod.yml up -d --build
216228

@@ -318,7 +330,7 @@ If you enabled **Post results to PR** in installation settings, the score card i
318330
### Container won't start
319331

320332
- 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`
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)
322334
- Check `SKILLS_HOST_PATH` is an absolute path and the directory exists on the Docker host
323335
- Verify the `DOCKER_GID` matches the host's Docker group: `getent group docker | cut -d: -f3`
324336
- Check API logs for container creation errors: `docker compose logs api | grep container`

infra/coolify/docker-compose.prod.yml

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

63-
claude-runner:
64-
build:
65-
context: ./infra/docker/claude-runner
66-
image: claude-runner
67-
profiles:
68-
- build-only
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.
6967

7068
db:
7169
image: postgres:16-alpine

0 commit comments

Comments
 (0)