Skip to content

Self-Hosted Runners

By default VeloGit runs each ritual on an ephemeral Fly.io machine. With the Docker dispatch provider it instead runs each ritual as an ephemeral container on a Docker daemon you control — so the whole platform, CI included, can run on a single VM you own.

This page is for operators self-hosting VeloGit.

The rituals-dispatcher service decides where a run executes based on one environment variable:

DISPATCH_PROVIDERRuns each ritual as…
fly (default)a Fly.io machine
gcpa Cloud Run job
dockera container on the local Docker daemon

Whichever provider is used, the model is the same: one fresh, single-use runner per run, built from the run’s base image (see Runner Environment). The runner binary is side-loaded from the data-plane at boot and authenticates with a per-run scoped token (see Service-to-Service Auth), so the Docker path is exactly as isolated, credential-wise, as the Fly path.

  • A host with a Docker daemon.
  • The rituals-dispatcher running on that host with:
    • the docker CLI available (it’s baked into the dispatcher image), and
    • the Docker socket mounted: -v /var/run/docker.sock:/var/run/docker.sock.

The dispatcher shells out to docker run against that socket to create one container per run.

Set these on the dispatcher:

VariableRequiredPurpose
DISPATCH_PROVIDER=dockeryesSelects this provider.
RUNNER_BINARY_URLyesWhere the runner container downloads the rituals-service binary — the data-plane’s /internal/runner-binary.
DATAPLANE_URLyesThe data-plane base URL, as reachable from inside a runner container (see the gotcha below).
DOCKER_NETWORKnoAttaches each runner to a named Docker network so it can reach the data-plane by service name.
NATS_URL, NATS_NKEY_SEEDas configuredPassed through to the runner.

Secrets (VELO_RUN_TOKEN, NATS_NKEY_SEED) are passed to the container via a temporary 0600 env-file, not -e flags, so they don’t appear in ps.

The one gotcha: DATAPLANE_URL must be reachable from the container

Section titled “The one gotcha: DATAPLANE_URL must be reachable from the container”

This is the mistake to avoid. DATAPLANE_URL is used inside the runner container to clone the repo and report results. http://localhost:8080 almost never works there — localhost inside a container is the container itself, not your host or the data-plane. Use one of:

  • a shared Docker network + the data-plane’s service name (http://data-plane:8080) — set DOCKER_NETWORK to that network. This is the cleanest option and what the compose example below uses.
  • http://host.docker.internal:8080 (Docker Desktop; on Linux add --add-host=host.docker.internal:host-gateway), or
  • the host’s LAN/VPC IP.

The runner reaches the data-plane by service name over a shared network:

services:
data-plane:
image: velogit/data-plane
networks: [velo]
# …storage, DB, etc.
rituals-dispatcher:
image: velogit/rituals-dispatcher
networks: [velo]
environment:
DISPATCH_PROVIDER: docker
DATAPLANE_URL: http://data-plane:8080
RUNNER_BINARY_URL: http://data-plane:8080/internal/runner-binary
DOCKER_NETWORK: velo # runners join this network too
NATS_URL: nats://nats:4222
# NATS_NKEY_SEED: …
volumes:
- /var/run/docker.sock:/var/run/docker.sock # required
networks:
velo:

Each run then starts a container roughly equivalent to:

docker run -d --rm --env-file <tmp> --network velo \
--label velo.run=<run-id> <base-image> sh -c '<bootstrap>'
  • Mounting the Docker socket grants the dispatcher root-equivalent control of the host. Run the dispatcher on a host dedicated to VeloGit, not a shared box.
  • Runs execute as containers on the same host. That’s fine when you trust the code in your repos (the normal self-host case). It is not sufficient isolation for running untrusted code from multiple tenants on shared hardware — for that you want microVM isolation (e.g. Firecracker/Kata), which the Docker provider does not give you.
  • Cancellation is passive. Containers run with --rm and honour the pipeline timeout, so they self-terminate and clean up — but cancelling a run marks it terminal in the UI without immediately killing the container; the container finishes its current work or times out first.
  • Single node. The dispatcher talks to its local Docker daemon only; there is no remote-Docker or multi-node scheduling yet.
  • No dependency cache. Every run is a cold start (fresh clone, packages: reinstall). Pre-baking a base_image is the workaround — see Runner Environment.