# Custom Runner Images
> Build your own runner image from the official BountyHub image to preinstall tools.
## Custom runner images
The official runner image is deliberately minimal. It ships the runner and its
helper binaries and nothing else, so you can build on top of it instead of
maintaining a fork of the runner whenever you need extra tooling.
Reasons to build your own image:
- **Preinstall tools.** Workflows invoke programs by name from `PATH`. Tools
baked into the image are available immediately instead of being downloaded on
every run.
- **Keep private tooling private.** Proprietary scanners can live in your image
and never leave your infrastructure.
- **Control versions.** Pin the exact scanner/runtime versions your workflows
were tested against instead of installing `latest` at run time.
## What the official image contains
|
Path
|
Purpose
|
/usr/local/bin/runner-worker-local
|
Runner controller. Also the image entrypoint.
|
/usr/local/bin/bh
|
BountyHub CLI. Workflow steps use
bh ask
to reach the operator.
|
/usr/local/bin/cookielab
|
Native
cookielab
CLI used by the
cookielab
BHW module.
|
/opt/bountyhub/dsh
|
Pinned DeepSeek Harness payload (
BH_DSH_BIN=/opt/bountyhub/dsh/bin/dsh
).
|
The runtime base is `debian:bookworm-slim` with `ca-certificates` and
`libstdc++6` installed. The image runs as the non-root `runner` user (uid
`1000`) and has no writable home directory by default; the runner stores its
config and workdir under `$HOME/.bountyhub/runners/`.
The `runner-worker-local` and `runner-worker` images include the dsh payload.
The `runner-worker-serverless` image contains the same layout minus `bh` and
dsh (it only starts job containers), and the `runner-worker` job image ships
`runner-worker`, `cookielab`, and dsh.
## Option 1: extend the official image
Derive from the published image and add your tools. The image sets
`USER runner`, so switch to `root` for package installation and back to `runner`
afterwards.
```dockerfile
FROM ghcr.io/bountyhub-org/runner-worker-local:latest
USER root
# Preinstall everything your workflows call by name.
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
git curl jq unzip python3 \
&& rm -rf /var/lib/apt/lists/*
# The runner stores its config and workdir under ~/.bountyhub, so the user
# needs a writable home directory.
RUN mkdir -p /home/runner && chown -R runner:runner /home/runner
WORKDIR /home/runner
# Bring your own tooling; keep it out of the base image.
COPY --chown=runner:runner ./tools /opt/tools
ENV PATH="/opt/tools:${PATH}"
USER runner
```
## Option 2: copy the binaries into your own base
If you need a different distribution, or an image where you control every layer,
copy the binaries out of the official image. This is the same approach the
official Dockerfiles use.
```dockerfile
FROM debian:bookworm-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates libstdc++6 \
&& rm -rf /var/lib/apt/lists/* \
&& useradd --system --uid 1000 --create-home runner
COPY --from=ghcr.io/bountyhub-org/runner-worker-local:latest \
/usr/local/bin/runner-worker-local \
/usr/local/bin/bh \
/usr/local/bin/cookielab \
/usr/local/bin/
# The harness is a separate tree, not a single binary. Copy it if you want
# agent steps to work; otherwise the runner falls back to `dsh` on PATH or
# fails the agent step.
COPY --from=ghcr.io/bountyhub-org/runner-worker-local:latest \
/opt/bountyhub/dsh /opt/bountyhub/dsh
ENV BH_DSH_BIN=/opt/bountyhub/dsh/bin/dsh
WORKDIR /home/runner
USER runner
ENTRYPOINT ["/usr/local/bin/runner-worker-local"]
```
The binaries are dynamically linked, so use a base with at least the glibc
version of `debian:bookworm` (2.36). `debian:bookworm-slim` and `ubuntu:24.04`
both work; older bases may fail at startup.
## Build the image
```bash
docker build -t my-runner:latest .
```
Pin a specific image tag instead of `latest` when you want reproducible builds,
and rebuild when you upgrade the runner.
## Register and run in a container
The runner writes its config to `$HOME/.bountyhub`, so mount a volume over the
home directory. A named volume inherits the ownership of the directory in the
image, which keeps it writable for the non-root user.
```bash
docker volume create my-runner-home
docker run --rm -it \
-v my-runner-home:/home/runner \
my-runner:latest \
register --token "" \
--url "https://bountyhub.org" \
--name "my-runner" \
--capacity 4 \
--unattended
```
Then run the controller:
```bash
docker run -d --name my-runner \
-v my-runner-home:/home/runner \
my-runner:latest \
run --name "my-runner"
```
If you bind-mount a host directory instead of using a named volume, make sure
uid `1000` can write to it, or pass `--user "$(id -u):$(id -g)"`.
## Upgrading
Treat the image as the deployment unit: rebuild it from a newer base tag when a
new runner release is available. The `upgrade` subcommand replaces the running
executable in place, so it cannot update the root-owned binary in
`/usr/local/bin` while the container runs as `runner`.
## Security notes
- Never bake a registration token or platform token into the image.
- Keep `USER runner` unless a tool genuinely requires root; prefer installing
tools to a writable path over running the whole runner as root.
- The runner can execute arbitrary commands by design. Run one runner per
container and isolate it the same way you would isolate any other scanning
host.