# 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.