Configuring Zoomies¶
This is the settings reference. For a first installation, follow the quick start; for capacity planning and tuning, use advanced operations.
| Looking for… | Jump to |
|---|---|
| Why a setting has its current value | The four layers |
| Controller address and webhooks | server.external_url |
| The encryption key for recovery | security.encryption_key |
| A key's environment variable or when it applies | Every key |
| Runner labels, images, sizing and limits for one pool | Pool settings |
| Jobs that need Docker | Jobs that build container images |
Almost every setting lives in the fleet's own SQLite database and is changed on the Settings → Configuration page. A change made there is kept: it survives a restart, it is written to the audit trail, and it applies to this controller and nothing else.
Three keys cannot live there, because they are what gets that database open:
database.path, and security.encryption_key or security.encryption_key_file.
A setting that unlocks a store cannot be stored in that store. Those go in
zoomies.yaml or the environment, and that is the whole of what a fresh
install's file contains.
The four layers¶
flowchart LR
d["built-in defaults"] --> f["zoomies.yaml"]
f --> db["the fleet's database<br/>(the settings page)"]
db --> e["ZOOMIES_* environment"]
e --> v{"config.Validate"}
v -->|"an error"| stop["startup stops, and the message<br/>names what to change"]
v -->|"a warning"| warn["startup continues -- printed here, and shown<br/>in the UI's problems drawer while it is true"]
v -->|"info"| note["startup continues -- a default worth knowing<br/>rather than anything wrong"]
v -->|"nothing to say"| ok["running"]
Each layer wins over the one before it, and the settings page says which layer each value came from, so "I changed it and nothing happened" is a question the page answers rather than one you have to work out.
The environment is deliberately last. It is the way back in when a stored setting has locked you out of the interface that would fix it (a bind address nothing can reach, an external URL that breaks the login redirect) and it is what keeps every containerised deployment working, since those ship their configuration as environment variables. The cost is that a value typed on the settings page can be silently overridden, so it is not left silent: a setting an environment variable is holding is shown in its own group at the foot of the page, naming the variable, and the API refuses to change it rather than storing something that would be overridden at the next restart.
To hand such a setting over to the settings page, remove the variable from the deployment's environment file and restart.
Upgrading from a file-only install¶
On the first start after the upgrade, the settings your zoomies.yaml actually
spells are copied into the database, attributed to the file, and the file stays
as the layer underneath them. Nothing about what the controller runs changes;
you simply gain the ability to change them. It happens once, so a setting you
later clear back to its default is not poured back in at the next restart, and
an info finding (settings.imported_from_file) says it happened.
A key the file never mentioned is left alone. The defaults are worked out on the host that reads them (the database path from its state directory, the agent's capacity from its cores) so a row claiming to be "the default" would freeze one machine's answers for every machine after it.
From a terminal¶
When the settings page is the thing that is broken, zoomies config does the
same job against a stopped controller:
zoomies config list # what this fleet has stored, and where each value came from
zoomies config get server.bind # one setting, and which layer won
zoomies config set server.bind 127.0.0.1:8080
zoomies config unset server.bind # back to the file, or the built-in default
It refuses to run while a controller is up, because writing settings under a process that has already read them leaves the two disagreeing with no way for either to find out.
When a setting stops the controller starting¶
This is the failure the layers make possible: a value saved on the settings page
that the validator refuses at the next start. The page that would fix it is
behind the controller that will not start, and editing zoomies.yaml does
nothing; the database sits above the file, so the file cannot win.
Zoomies says so at the moment it refuses. Every finding that names a setting carries the layer holding it, and a refusal prints the command that takes it back out:
configuration is not valid:
- server.bind: "0.0.0.0" is not a host:port address
fix: use the form "127.0.0.1:8080" or ":8080".
this value is stored in this fleet's database, so editing the configuration
file will not change it; with the controller stopped, `zoomies config unset
server.bind` puts the file or the default back in charge
The same sentence appears on the problems drawer and the Configuration tab, for the settings that are merely unwise rather than fatal, so "I changed it and nothing happened" is answered before it is asked.
A value the environment is pinning gets the other instruction, because there is
nothing stored to unset: ZOOMIES_* is the last word, and the variable has to
go from the environment the controller starts with. That is also what makes it
the way back in when the database is the problem and you would rather not stop
the controller to fix it.
The file¶
The parser is strict. A misspelled key is an error naming the line, not a setting that silently does nothing.
If no file exists and none was named explicitly, the database plus the environment plus the defaults are used, which is what makes the container image work with nothing but environment variables.
Where things live¶
Two directories, and neither has one fixed answer: they depend on the operating system and on whether the process is running as root.
| Configuration | State | |
|---|---|---|
| Override | ZOOMIES_CONFIG_DIR |
ZOOMIES_STATE_DIR |
| Linux, as root | /etc/zoomies |
/var/lib/zoomies |
| Linux, as anyone else | ~/.config/zoomies |
~/.config/zoomies |
| macOS | ~/Library/Application Support/zoomies |
same |
| Windows | %ProgramData%\zoomies |
same |
The configuration directory holds zoomies.yaml and the encryption key; the
state directory holds the database (which is where the settings themselves
live) and the agents' work areas. Every default
path below that begins <config dir> or <state dir> resolves through this
table, and zoomies config print says what they came out as on this host.
ZOOMIES_STATE_DIR is the one to reach for when the database belongs on a
different disk from everything else; it moves the database and the work
directories together, so the two do not have to be set separately.
Every finding carries a code, and the code is the stable half: it is what you search for and alert on, while the sentence beside it is written for whoever is reading and may improve. Problem codes lists all of them (the validator's and the running controller's) with severities and what to do.
Everything at once¶
Every setting, with its default and its environment override. These are the
values the settings page edits; the block below is the same list in the shape
zoomies.yaml takes, for the deployments that still configure from a file.
The defaults below are the actual defaults. A file containing only the settings you want to change is the normal case, and a fresh install's file contains only the two or three keys that have to be in one.
server:
bind: 127.0.0.1:8080 # ZOOMIES_BIND
tailcat_enabled: true # ZOOMIES_TAILCAT_ENABLED -- permit private agent connections
external_url: "" # ZOOMIES_EXTERNAL_URL -- required for webhooks
tls:
mode: off # ZOOMIES_TLS_MODE -- off | self-signed | files
cert_file: "" # ZOOMIES_TLS_CERT_FILE
key_file: "" # ZOOMIES_TLS_KEY_FILE
hosts: [] # ZOOMIES_TLS_HOSTS -- names for a generated cert
trusted_proxies: [] # ZOOMIES_TRUSTED_PROXIES -- CIDRs, or [cloudflare]; 0.0.0.0/0 is warned about
allowed_origins: [] # ZOOMIES_ALLOWED_ORIGINS -- extra browser origins; "*" is warned about
allow_indexing: false # ZOOMIES_ALLOW_INDEXING -- let search engines in
read_timeout: 30s # ZOOMIES_READ_TIMEOUT
write_timeout: 0s # ZOOMIES_WRITE_TIMEOUT -- 0: SSE and log tails must not be cut off
idle_timeout: 120s # ZOOMIES_IDLE_TIMEOUT
database:
path: <state dir>/zoomies.db # ZOOMIES_DB_PATH -- see "Where things live" below
security:
encryption_key: "" # ZOOMIES_ENCRYPTION_KEY
encryption_key_file: <config dir>/encryption.key # ZOOMIES_ENCRYPTION_KEY_FILE
session_ttl: 168h # ZOOMIES_SESSION_TTL
cookie_secure: null # ZOOMIES_COOKIE_SECURE (derived when unset)
disable_auth: false # ZOOMIES_DISABLE_AUTH
public_view: false # ZOOMIES_PUBLIC_VIEW (anyone may read the fleet, see public-view.md)
allow_private_egress: false # ZOOMIES_ALLOW_PRIVATE_EGRESS (let the URLs it dials be private addresses)
docker_in_docker_expected: false # ZOOMIES_DOCKER_IN_DOCKER_EXPECTED
rate_limit_logins: 10 # ZOOMIES_RATE_LIMIT_LOGINS (per address per minute, and 5x that per account
# over 15m); 0 disables it and is warned about
require_two_step: false # ZOOMIES_REQUIRE_TWO_STEP (password accounts set up an authenticator
# at their next sign-in; single sign-on and API tokens are not asked)
mcp_oauth: null # ZOOMIES_MCP_OAUTH (let Claude sign in to /mcp through the browser;
# unset turns it on when auth is on and the controller is on https)
mcp_open_registration: true # ZOOMIES_MCP_OPEN_REGISTRATION (let MCP clients register themselves,
# rather than only use a client an administrator created)
mcp_admin_tools: false # ZOOMIES_MCP_ADMIN_TOOLS (offer an administrator's MCP token the host-edit
# and tuning-settings tools; off, they are not offered)
auto_apply_remedies: shadow # ZOOMIES_AUTO_APPLY_REMEDIES (off, shadow or on: make the change a problem proposes
# without a click, after it has stood for two hours; shadow only records it)
github:
api_base_url: https://api.github.com # ZOOMIES_GITHUB_API_BASE_URL
upload_base_url: "" # ZOOMIES_GITHUB_UPLOAD_BASE_URL
webhook_path: /webhooks/github # ZOOMIES_WEBHOOK_PATH
poll_interval: 30s # ZOOMIES_POLL_INTERVAL
poll_fallback: true # ZOOMIES_POLL_FALLBACK
allow_workflow_cancellation: true # ZOOMIES_ALLOW_WORKFLOW_CANCELLATION
runner_image: ghcr.io/eyupio/zoomies-runner:latest # ZOOMIES_RUNNER_IMAGE
runner_version: "" # ZOOMIES_RUNNER_VERSION
agent:
embedded: true # ZOOMIES_AGENT_EMBEDDED
name: <hostname> # ZOOMIES_AGENT_NAME
capacity: <cpus / 2> # ZOOMIES_AGENT_CAPACITY
backend: docker # ZOOMIES_AGENT_BACKEND -- docker | podman | process
docker_host: "" # ZOOMIES_DOCKER_HOST / DOCKER_HOST -- "" autodetects
work_dir: <state dir>/work # ZOOMIES_WORK_DIR
labels: {} # ZOOMIES_AGENT_LABELS -- "gpu=true,zone=eu"
network: "" # ZOOMIES_AGENT_NETWORK
heartbeat_interval: 30s # ZOOMIES_HEARTBEAT_INTERVAL -- a host is unhealthy after 90s of silence and its runners lost after 5m; above 45s is warned about
prewarm_timeout: 5m # ZOOMIES_AGENT_PREWARM_TIMEOUT -- background pulls only; 1s–15m
prewarm_jitter: 30s # ZOOMIES_AGENT_PREWARM_JITTER -- random delay before background pulls; 0s–5m
bootstrap_cpu_grace: 2m # ZOOMIES_AGENT_BOOTSTRAP_CPU_GRACE -- 0 applies pressure throttling immediately; maximum 10m
finished_retention: 0s # ZOOMIES_AGENT_FINISHED_RETENTION -- 0 removes a finished workload after its report is acknowledged
docker_build_cache_mb: 0 # ZOOMIES_AGENT_DOCKER_BUILD_CACHE_MB -- target for unused builder cache; 0 prunes nothing
# Process backend only:
runner_sha256: "" # ZOOMIES_AGENT_RUNNER_SHA256 -- digest of the runner archive, when github.runner_version is pinned
allow_unverified_runner_download: false # ZOOMIES_AGENT_ALLOW_UNVERIFIED_RUNNER_DOWNLOAD -- warned about
runner_download_url: "" # ZOOMIES_AGENT_RUNNER_DOWNLOAD_URL -- an internal mirror of the actions/runner releases
registry_auth: "" # ZOOMIES_REGISTRY_AUTH -- credentials for a private image registry
extra_ca_file: "" # ZOOMIES_AGENT_EXTRA_CA_FILE -- a PEM root CA container runners trust, for a TLS-intercepting proxy
# Standalone agents only:
controller_url: "" # ZOOMIES_CONTROLLER_URL
join_token: "" # ZOOMIES_JOIN_TOKEN
agent_token: "" # ZOOMIES_AGENT_TOKEN
ca_file: "" # ZOOMIES_AGENT_CA_FILE
client_cert_file: "" # ZOOMIES_AGENT_CLIENT_CERT_FILE
client_key_file: "" # ZOOMIES_AGENT_CLIENT_KEY_FILE
insecure_skip_verify: false # ZOOMIES_AGENT_INSECURE_SKIP_VERIFY
allow_insecure_http: false # ZOOMIES_AGENT_ALLOW_INSECURE_HTTP -- plain http:// off-host
runners:
docker_wait: 3m # ZOOMIES_DOCKER_WAIT -- how long a Docker pool's runner waits for its daemon; 0 uses the 2m compatibility fallback
default_cpus: 2 # ZOOMIES_RUNNER_DEFAULT_CPUS -- what one runner gets on a pool that has not said otherwise
default_memory_mb: 4096 # ZOOMIES_RUNNER_DEFAULT_MEMORY_MB -- the same, in megabytes
env: {} # ZOOMIES_RUNNER_ENV -- "HTTPS_PROXY=http://proxy:3128,NO_PROXY=localhost"; a pool's env wins
scheduler:
interval: 10s # ZOOMIES_SCHEDULER_INTERVAL
scale_up_delay: 0s # ZOOMIES_SCALE_UP_DELAY
max_runner_lifetime: 6h # ZOOMIES_MAX_RUNNER_LIFETIME
provision_timeout: 20m # ZOOMIES_PROVISION_TIMEOUT -- must outlast a cold image pull and the Docker wait above
drain_timeout: 15m # ZOOMIES_DRAIN_TIMEOUT
registration_concurrency: 1 # ZOOMIES_REGISTRATION_CONCURRENCY -- per installation; 1–16
max_creates_per_tick: 10 # ZOOMIES_MAX_CREATES_PER_TICK
default_runner_limits: true # ZOOMIES_DEFAULT_RUNNER_LIMITS -- a pool with no cpus or memory_mb gets one slot's share of its host; off is warned about
host_throttling: true # ZOOMIES_HOST_THROTTLING -- step an overwhelmed host down and lift it after calm; off is warned about
host_auto_adjust: true # ZOOMIES_HOST_AUTO_ADJUST -- keep every host at its recommended capacity and reserve; a host can override
auto_rerun: false # ZOOMIES_AUTO_RERUN -- re-run a job whose runner died under it; on is warned about
auto_rerun_limit: 1 # ZOOMIES_AUTO_RERUN_LIMIT -- automatic re-runs per workflow run, 1–5
capacity_demand:
destination_url: "" # ZOOMIES_CAPACITY_DEMAND_URL (empty disables)
signing_secret: "" # ZOOMIES_CAPACITY_DEMAND_SIGNING_SECRET
cooldown: 10m # ZOOMIES_CAPACITY_DEMAND_COOLDOWN
timeout: 10s # ZOOMIES_CAPACITY_DEMAND_TIMEOUT
pools: [] # ZOOMIES_CAPACITY_DEMAND_POOLS (IDs or names)
log:
level: info # ZOOMIES_LOG_LEVEL -- debug | info | warn | error
format: json # ZOOMIES_LOG_FORMAT -- json | text
oidc:
enabled: false # ZOOMIES_OIDC_ENABLED
issuer: "" # ZOOMIES_OIDC_ISSUER
client_id: "" # ZOOMIES_OIDC_CLIENT_ID
client_secret: "" # ZOOMIES_OIDC_CLIENT_SECRET
redirect_url: "" # ZOOMIES_OIDC_REDIRECT_URL (derived from external_url)
scopes: [openid, profile, email] # ZOOMIES_OIDC_SCOPES
username_claim: preferred_username # ZOOMIES_OIDC_USERNAME_CLAIM
groups_claim: groups # ZOOMIES_OIDC_GROUPS_CLAIM
platform_groups: [] # ZOOMIES_OIDC_PLATFORM_GROUPS
admin_groups: [] # ZOOMIES_OIDC_ADMIN_GROUPS
operator_groups: [] # ZOOMIES_OIDC_OPERATOR_GROUPS
allow_signup: false # ZOOMIES_OIDC_ALLOW_SIGNUP
link_by_username: false # ZOOMIES_OIDC_LINK_BY_USERNAME -- let SSO take over a password account of the same name; warned about
label: "" # ZOOMIES_OIDC_LABEL -- the sign-in button's words; empty names the issuer's host
hide_password_login: false # ZOOMIES_OIDC_HIDE_PASSWORD_LOGIN -- SSO only on the sign-in page; admins keep /login?password
metrics:
enabled: true # ZOOMIES_METRICS_ENABLED
path: /metrics # ZOOMIES_METRICS_PATH
public: false # ZOOMIES_METRICS_PUBLIC
status:
mode: off # ZOOMIES_STATUS_MODE off | authenticated | public; public is warned about
retention:
jobs: 720h # ZOOMIES_RETENTION_JOBS (30 days)
runners: 168h # ZOOMIES_RETENTION_RUNNERS (7 days; the row, not the container -- see agent.finished_retention)
scaling_events: 8760h # ZOOMIES_RETENTION_SCALING_EVENTS (365 days of scaling history; was retention.audit, which is still read)
samples: 168h # ZOOMIES_RETENTION_SAMPLES (7 days of the per-minute fleet and per-host samples the Overview and the Hosts page draw)
runner_sessions: 8760h # ZOOMIES_RETENTION_RUNNER_SESSIONS (365 days of the usage ledger's runner sessions, which outlive the rows above)
webhooks: 168h # ZOOMIES_RETENTION_WEBHOOKS
machines: 168h # ZOOMIES_RETENTION_MACHINES (7 days of deleted-machine rows -- what was rented, when, and what it cost)
update_attempts: 2160h # ZOOMIES_RETENTION_UPDATE_ATTEMPTS (90 days of finished update attempts and rollouts, so why a host is behind stays answerable; 24h at least, 0 keeps all)
limits: # fleet-wide ceilings; 0 is unlimited, and every one is 0 by default except job_stats_window
hosts: 0 # ZOOMIES_LIMITS_HOSTS -- enrolled hosts
pools: 0 # ZOOMIES_LIMITS_POOLS -- pools
runners: 0 # ZOOMIES_LIMITS_RUNNERS -- live runners across every pool
join_tokens: 0 # ZOOMIES_LIMITS_JOIN_TOKENS -- join tokens neither used nor expired
event_subscribers: 0 # ZOOMIES_LIMITS_EVENT_SUBSCRIBERS -- open live-update streams (one per UI tab)
visitor_event_subscribers: 200 # ZOOMIES_LIMITS_VISITOR_EVENT_SUBSCRIBERS -- a public view's visitors' streams, a ceiling of their own
job_stats_window: 2160h # ZOOMIES_LIMITS_JOB_STATS_WINDOW -- longest span one job statistics request may cover (90 days; 0 is this default, not unlimited)
backup:
directory: "" # ZOOMIES_BACKUP_DIRECTORY -- empty: a `backups` directory beside the database
interval: 24h # ZOOMIES_BACKUP_INTERVAL -- 0 switches scheduled backups off
keep: 7 # ZOOMIES_BACKUP_KEEP -- the controller's own copies; 0 keeps every one
remotes: [] # where each copy is sent afterwards; empty is nothing leaving the host
# remotes:
# - name: offsite # ZOOMIES_BACKUP_REMOTE_NAME
# endpoint: https://s3.eu-west-2.amazonaws.com # ZOOMIES_BACKUP_REMOTE_ENDPOINT
# region: eu-west-2 # ZOOMIES_BACKUP_REMOTE_REGION
# bucket: acme-zoomies # ZOOMIES_BACKUP_REMOTE_BUCKET
# prefix: prod # ZOOMIES_BACKUP_REMOTE_PREFIX
# access_key_id: AKIA... # ZOOMIES_BACKUP_REMOTE_ACCESS_KEY_ID
# secret_access_key: "" # ZOOMIES_BACKUP_REMOTE_SECRET_ACCESS_KEY -- keep this in the environment
# passphrase: "" # ZOOMIES_BACKUP_REMOTE_PASSPHRASE -- seals the archive before it leaves
# path_style: null # ZOOMIES_BACKUP_REMOTE_PATH_STYLE -- unset chooses by endpoint
# keep: 30 # ZOOMIES_BACKUP_REMOTE_KEEP -- this bucket's own retention; 0 keeps every one
# disabled: false # ZOOMIES_BACKUP_REMOTE_DISABLED
# - name: minio # the second remote's environment is ZOOMIES_BACKUP_REMOTE_2_*
# endpoint: http://minio:9000
# bucket: backups
images:
refresh_interval: 1h # ZOOMIES_IMAGE_REFRESH_INTERVAL -- 0 switches it off
updates:
check_interval: 24h # ZOOMIES_UPDATE_CHECK_INTERVAL -- 0 never asks
mode: off # ZOOMIES_UPDATE_MODE -- off | manual | auto; off says a release exists and nothing more
soak: 24h # ZOOMIES_UPDATE_SOAK -- auto only: how long a release is public before it is taken
provider:
enabled: false # ZOOMIES_PROVIDER_ENABLED -- off: renting machines spends money
paused: false # ZOOMIES_PROVIDER_PAUSED -- the kill switch; holds creation only
interval: 30s # ZOOMIES_PROVIDER_INTERVAL
sweep_interval: 10m # ZOOMIES_PROVIDER_SWEEP_INTERVAL -- how often each provider is asked what it is running
max_machines: 0 # ZOOMIES_PROVIDER_MAX_MACHINES -- fleet-wide ceiling; 0 rents nothing, and is warned about
max_creates_in_flight: 2 # ZOOMIES_PROVIDER_MAX_CREATES_IN_FLIGHT
scale_up_delay: 0s # ZOOMIES_PROVIDER_SCALE_UP_DELAY -- a machine takes minutes; it has already waited
call_timeout: 30s # ZOOMIES_PROVIDER_CALL_TIMEOUT
create_timeout: 20m # ZOOMIES_PROVIDER_CREATE_TIMEOUT
bootstrap_timeout: 10m # ZOOMIES_PROVIDER_BOOTSTRAP_TIMEOUT
enrol_timeout: 15m # ZOOMIES_PROVIDER_ENROL_TIMEOUT
delete_timeout: 15m # ZOOMIES_PROVIDER_DELETE_TIMEOUT
ambiguity_timeout: 30m # ZOOMIES_PROVIDER_AMBIGUITY_TIMEOUT -- then a person is asked
idle_timeout: 15m # ZOOMIES_PROVIDER_IDLE_TIMEOUT
scale_down_cooldown: 15m # ZOOMIES_PROVIDER_SCALE_DOWN_COOLDOWN -- at least one idle_timeout, or the fleet churns
delete_grace: 10m # ZOOMIES_PROVIDER_DELETE_GRACE -- after a machine's host goes silent
kennel:
enabled: false # ZOOMIES_KENNEL_ENABLED -- off means no GitHub request, nothing stored
scope: served # ZOOMIES_KENNEL_SCOPE -- served | installation; installation multiplies the requests
refresh_interval: 24h # ZOOMIES_KENNEL_REFRESH_INTERVAL -- how stale what it reads from GitHub may get; at least 1h
api_budget_percent: 20 # ZOOMIES_KENNEL_API_BUDGET_PERCENT -- share of an installation's hourly limit it may spend; 5–50
disabled_checks: [] # ZOOMIES_KENNEL_DISABLED_CHECKS -- check codes or areas to turn off
ui:
capacity_map:
overview_layout: overlay # ZOOMIES_UI_CAPACITY_MAP_OVERVIEW_LAYOUT -- overlay | split; what the Overview's map opens with
hosts_layout: overlay # ZOOMIES_UI_CAPACITY_MAP_HOSTS_LAYOUT -- the same for the Hosts page, separately
queue_warning_threshold: 1 # ZOOMIES_UI_QUEUE_WARNING_THRESHOLD -- jobs queued before the queue tiles turn amber
Every key¶
Where each setting can live, and when a change to it takes effect. "At once" means the running controller picks it up on its next pass; "next restart" means the change is stored and applied when the controller starts again, which the settings page reports rather than refusing the edit.
agent¶
| Key | Environment | Takes effect | What it is |
|---|---|---|---|
agent.agent_token |
ZOOMIES_AGENT_TOKEN |
on the agent's own host | Agent token: The credential a standalone agent carries afterwards. It is configured on that agent's own host. |
agent.allow_insecure_http |
ZOOMIES_AGENT_ALLOW_INSECURE_HTTP |
on the agent's own host | Allow plain HTTP to the controller: Let a standalone agent use a plain http:// controller URL off loopback, which puts its token and every runner credential on the wire in the clear. It is configured on that agent's own host. |
agent.allow_unverified_runner_download |
ZOOMIES_AGENT_ALLOW_UNVERIFIED_RUNNER_DOWNLOAD |
next restart | Allow unverified runner downloads: Let the process backend install a runner archive whose digest it cannot check. The alternative to checking is executing whatever the network handed over. |
agent.backend |
ZOOMIES_AGENT_BACKEND |
next restart | Runner backend: What a runner runs in: a Docker container, a Podman container, or a bare process on this host. |
agent.ca_file |
ZOOMIES_AGENT_CA_FILE |
on the agent's own host | Controller certificate: The certificate a standalone agent pins for its controller. It is configured on that agent's own host. |
agent.capacity |
ZOOMIES_AGENT_CAPACITY |
next restart | Runners per host: How many runners this host will hold at once when it first enrols. It defaults to one per two cores, which leaves the machine room to breathe. Afterwards the host's own row holds it: change it on the host's card, or with zoomies hosts edit --capacity. |
agent.client_cert_file |
ZOOMIES_AGENT_CLIENT_CERT_FILE |
on the agent's own host | Client certificate: A standalone agent's client certificate, for mutual TLS. It is configured on that agent's own host. |
agent.client_key_file |
ZOOMIES_AGENT_CLIENT_KEY_FILE |
on the agent's own host | Client private key: The key for that client certificate. It is configured on that agent's own host. |
agent.controller_url |
ZOOMIES_CONTROLLER_URL |
on the agent's own host | Controller URL: The controller a standalone agent connects to. It is configured on that agent's own host. |
agent.docker_build_cache_mb |
ZOOMIES_AGENT_DOCKER_BUILD_CACHE_MB |
next restart | Docker build cache target: The target size for unused Docker builder cache. 0 leaves a shared or externally managed daemon alone. |
agent.docker_host |
ZOOMIES_DOCKER_HOST |
next restart | Docker socket: The Docker or Podman socket. Empty finds one, preferring a rootless socket over the root one. |
agent.extra_ca_file |
ZOOMIES_AGENT_EXTRA_CA_FILE |
next restart | Extra CA for runners: A PEM bundle on this host that container runners and their Docker sidecars trust as well as the image's own, for a network whose proxy re-signs TLS. |
agent.embedded |
ZOOMIES_AGENT_EMBEDDED |
next restart | Run an agent in this controller: Run an agent inside this controller, so a single machine needs one process. Off makes a controller that schedules runners onto other hosts and starts none itself. |
agent.prewarm_timeout |
ZOOMIES_AGENT_PREWARM_TIMEOUT |
next restart | Background image preparation budget; default 5m, range 1s–15m. Foreground create budgets are independent. |
agent.prewarm_jitter |
ZOOMIES_AGENT_PREWARM_JITTER |
next restart | Random delay before background preparation; default 30s, range 0s–5m. Foreground work can pass during the delay. |
agent.bootstrap_cpu_grace |
ZOOMIES_AGENT_BOOTSTRAP_CPU_GRACE |
next restart | Startup CPU grace: Keep new runners at their normal CPU quota for 2m before applying host-pressure throttling. Range 0s–10m; 0s disables the grace. Memory and CPU limits remain enforced. |
agent.finished_retention |
ZOOMIES_AGENT_FINISHED_RETENTION |
next restart | Keep finished containers for: How long a finished runner's container stays on the host before the agent deletes it. It is the window for reading a finished runner's log, and it is host disk: 0 deletes on the next pass. |
agent.heartbeat_interval |
ZOOMIES_HEARTBEAT_INTERVAL |
next restart | Heartbeat interval: How often an agent reports in. A host that goes quiet for 90 seconds is counted lost, so this has to be comfortably under that. |
agent.insecure_skip_verify |
ZOOMIES_AGENT_INSECURE_SKIP_VERIFY |
on the agent's own host | Skip certificate verification: Let a standalone agent skip verifying its controller's certificate. It is configured on that agent's own host. |
agent.join_token |
ZOOMIES_JOIN_TOKEN |
on the agent's own host | Join token: The single-use token a standalone agent redeems to enrol. It is configured on that agent's own host. |
agent.labels |
ZOOMIES_AGENT_LABELS |
next restart | Host labels: Key=value labels describing this host, which a pool can require of the hosts it runs on. |
agent.name |
ZOOMIES_AGENT_NAME |
next restart | Host name: What this host is called in the fleet. Empty names it after the machine it is on. At most 128 characters, with no backtick or control character: the controller refuses a join that breaks that, before the join token is redeemed. |
agent.network |
ZOOMIES_AGENT_NETWORK |
next restart | Container network: An existing container network to attach runners to. Empty uses the daemon's default bridge. |
agent.registry_auth |
ZOOMIES_REGISTRY_AUTH |
next restart | Registry credentials: A base64 X-Registry-Auth value the container backends send when they pull. Without it a pool on a private registry cannot use pinned-only pulls at all. |
agent.runner_download_url |
ZOOMIES_AGENT_RUNNER_DOWNLOAD_URL |
next restart | Runner download mirror: Where the process backend fetches runner archives from, for hosts that mirror releases internally. The path below it is the same. |
agent.runner_sha256 |
ZOOMIES_AGENT_RUNNER_SHA256 |
next restart | Runner archive digest: The expected digest of the actions/runner archive the process backend downloads. Zoomies ships the digest for the release it pins; supply one when you pin another. |
agent.work_dir |
ZOOMIES_WORK_DIR |
next restart | Working directory: Where runner working directories and the agent's own credentials live. |
assistant¶
| Key | Environment | Takes effect | What it is |
|---|---|---|---|
assistant.allow_private_provider |
ZOOMIES_ASSISTANT_ALLOW_PRIVATE_PROVIDER |
at once | Allow a private provider address: Let an assistant provider's address name this machine or a private network, which is where a local model lives. Off, saving one is refused with a 422 that names this switch in the base_url field error. It opens only the assistant's providers; security.allow_private_egress is not consulted for them and stays as it is. |
assistant.local_only |
ZOOMIES_ASSISTANT_LOCAL_ONLY |
at once | Local models only: Refuse any assistant connection that resolves to a public address, after resolving it, so nothing the assistant is told can leave this machine or the LAN whatever a provider row says. A hosted provider cannot be reached while this is on. |
backup¶
These three are edited on Settings → Backups, beside the copies they produce, rather than in the Configuration list. They are ordinary settings otherwise: the same keys, the same environment overrides, the same export.
| Key | Environment | Takes effect | What it is |
|---|---|---|---|
backup.directory |
ZOOMIES_BACKUP_DIRECTORY |
at once | Backup directory: Where backups are kept. Empty is a backups directory beside the database, which on a container deployment is the mounted volume. A relative path is relative to the database's directory. |
backup.interval |
ZOOMIES_BACKUP_INTERVAL |
at once | Scheduled backup interval: How often the controller takes a copy of its own database. 0 switches scheduled backups off; one can still be taken on demand from the Backups tab or with zoomies backup. |
backup.keep |
ZOOMIES_BACKUP_KEEP |
at once | Backups to keep: How many of the controller's own backups are kept; the oldest beyond it go after each new one. 0 keeps every one. Backups somebody uploaded or fetched back from a remote are never counted and never deleted by this. |
backup.remotes¶
Where each copy goes after it is taken: a list of S3-compatible buckets, empty
by default, so nothing leaves the host until somebody says where it goes. Any
implementation of the S3 API does (AWS, MinIO, Ceph, Backblaze B2, Cloudflare
R2, Garage) and what lands there is exactly the archive the Backups tab
downloads, so a copy pulled out of a bucket in three years opens with zoomies
restore and nothing else. Backup and restore is the
whole story; this is the reference.
Unlike everything else on this page, these are read from zoomies.yaml and the
environment only. They are not settings in the database, and the settings page
does not edit them: a fleet whose database is gone has no stored settings to
read, and finding the offsite copy is exactly what that fleet needs to do. Keep
the file mode 0600, and prefer the environment for the secret key.
Destinations can also be added on the Backups page, where they are stored as rows with their secrets sealed under the instance encryption key. A fleet normally uses one or the other. A name described in both places is the file's, and the stored row is shown as ignored rather than left to disagree in silence, see Backup and restore.
| Key | Environment | What it is |
|---|---|---|
name |
ZOOMIES_BACKUP_REMOTE_NAME |
What this destination is called on the Backups tab, in the log and in the API. Lower-case letters, digits and dashes. Empty is offsite, then offsite-2. |
endpoint |
ZOOMIES_BACKUP_REMOTE_ENDPOINT |
The service's URL. The scheme decides whether the connection is encrypted; there is no separate switch to disagree with it. |
region |
ZOOMIES_BACKUP_REMOTE_REGION |
What requests are signed for. Empty is us-east-1, which implementations with no regions of their own accept. |
bucket |
ZOOMIES_BACKUP_REMOTE_BUCKET |
The bucket. Zoomies never creates it: a destination that appeared by itself is one nobody has set the retention, versioning or access policy of. |
prefix |
ZOOMIES_BACKUP_REMOTE_PREFIX |
The key prefix inside it, so one bucket can hold several fleets. |
access_key_id |
ZOOMIES_BACKUP_REMOTE_ACCESS_KEY_ID |
The credential's id. |
secret_access_key |
ZOOMIES_BACKUP_REMOTE_SECRET_ACCESS_KEY |
Its secret. It belongs in the environment, or in a file only the controller reads. |
passphrase |
ZOOMIES_BACKUP_REMOTE_PASSPHRASE |
Seals the archive with argon2id and AES-256-GCM before it is uploaded. Empty uploads the plain archive and the validator says what that costs. Nothing here can recover a lost passphrase. |
path_style |
ZOOMIES_BACKUP_REMOTE_PATH_STYLE |
Puts the bucket in the path rather than the hostname. Unset chooses: virtual-hosted for AWS's own endpoints, path style for everything else, which is what MinIO, Ceph and a bare address need. |
keep |
ZOOMIES_BACKUP_REMOTE_KEEP |
How many copies this remote holds; the oldest beyond it go after each upload. 0 keeps every one, and is the default. |
disabled |
ZOOMIES_BACKUP_REMOTE_DISABLED |
Stops uploads without removing what the bucket holds or making the configuration something you have to reconstruct. |
The unnumbered environment variables above are the first remote; the second is
ZOOMIES_BACKUP_REMOTE_2_* and so on to nine. A variable whose …_NAME
matches a remote the file already describes fills that one in wherever it sits
in the list, which is how a compose file hands over only the secret key of a
bucket zoomies.yaml describes.
The permissions a remote needs on its bucket and prefix are s3:PutObject,
s3:GetObject, s3:DeleteObject and s3:ListBucket; the last two only for
retention and for the Backups tab's listing, so a write-only credential works
if you set keep: 0 and never expect the page to say what is there.
capacity_demand¶
| Key | Environment | Takes effect | What it is |
|---|---|---|---|
capacity_demand.cooldown |
ZOOMIES_CAPACITY_DEMAND_COOLDOWN |
at once | Cooldown: How long to wait before asking for capacity for the same pool again. |
capacity_demand.destination_url |
ZOOMIES_CAPACITY_DEMAND_URL |
at once | Destination URL: Where signed requests for host capacity are posted. Empty disables the integration. |
capacity_demand.pools |
ZOOMIES_CAPACITY_DEMAND_POOLS |
at once | Pools to publish for: Which pools to publish demand for. Empty publishes for all of them. |
capacity_demand.signing_secret |
ZOOMIES_CAPACITY_DEMAND_SIGNING_SECRET |
at once | Signing secret: The secret those requests are signed with. Anyone holding it can forge one, so it is stored sealed. |
capacity_demand.timeout |
ZOOMIES_CAPACITY_DEMAND_TIMEOUT |
at once | Request timeout: How long one of those requests may take. |
database¶
| Key | Environment | Takes effect | What it is |
|---|---|---|---|
database.path |
ZOOMIES_DB_PATH |
file or environment only | Database file: The SQLite file holding this fleet, including every setting below. It is named in the configuration file or the environment because nothing can read it from inside itself. |
github¶
| Key | Environment | Takes effect | What it is |
|---|---|---|---|
github.allow_workflow_cancellation |
ZOOMIES_ALLOW_WORKFLOW_CANCELLATION |
at once | Allow cancelling workflow runs: Let operators ask GitHub to cancel the workflow run that owns a job. Turning it off is what a read-only Actions grant wants. |
github.api_base_url |
ZOOMIES_GITHUB_API_BASE_URL |
at once | GitHub API base URL, https://api.github.com for github.com, your Enterprise Server's /api/v3, or a GHE.com tenant's address. It is the default for a new installation; each existing one keeps the base it was added with. |
github.poll_fallback |
ZOOMIES_POLL_FALLBACK |
next restart | Poll for queued jobs: List queued jobs on a timer as well as waiting for webhooks. On by default: a controller that silently stops scaling because a webhook was misconfigured is worse than a few extra API calls. |
github.toolchain_scan_interval |
ZOOMIES_TOOLCHAIN_SCAN_INTERVAL |
at once | Toolchain scan interval: How often every workflow is read for the toolchain versions each pool's jobs install. 0, the default, scans only when asked: a scan reads every workflow file in every repository, from the GitHub quota the scheduler shares. |
github.poll_interval |
ZOOMIES_POLL_INTERVAL |
at once | Poll interval: How often the fallback poller looks for queued jobs. |
github.runner_image |
ZOOMIES_RUNNER_IMAGE |
at once | Default runner image: The container image a new pool runs when it names neither an image nor an operating system. |
github.runner_version |
ZOOMIES_RUNNER_VERSION |
at once | Pinned runner release: Pin the actions/runner release. Empty tracks whatever the image carries. |
github.upload_base_url |
ZOOMIES_GITHUB_UPLOAD_BASE_URL |
at once | GitHub upload base URL: The upload endpoint, when your Enterprise Server puts it somewhere other than beside the API. |
github.webhook_path |
ZOOMIES_WEBHOOK_PATH |
next restart | Webhook path: The path GitHub posts deliveries to. Changing it means changing the App's webhook URL too. |
images¶
| Key | Environment | Takes effect | What it is |
|---|---|---|---|
images.refresh_interval |
ZOOMIES_IMAGE_REFRESH_INTERVAL |
at once | Image refresh interval: How often every pool's image is prewarmed again, so a moving tag reaches the hosts. 0 switches it off, which is what an air-gapped fleet wants. |
kennel¶
| Key | Environment | Takes effect | What it is |
|---|---|---|---|
kennel.agent_guidance |
ZOOMIES_KENNEL_AGENT_GUIDANCE |
at once | Check agent guidance: opt into bounded instruction-file reads and structural checks. Off by default. Needs Contents read; draft-PR repairs need Contents write and Pull requests write. |
kennel.api_budget_percent |
ZOOMIES_KENNEL_API_BUDGET_PERCENT |
at once | GitHub request budget: The share, from 5 to 50, of an installation's hourly GitHub request limit that Kennel Club may spend. Scaling, registration and polling come first, and Kennel Club stops altogether when less than half the limit is left. |
kennel.disabled_checks |
ZOOMIES_KENNEL_DISABLED_CHECKS |
at once | Checks turned off: Checks to turn off, by code (exposure.fork_code_ran) or by area (exposure, capacity, setup, ci, token). A check that is turned off is listed as turned off in Settings, not hidden, and a repository with none left to run is not given the badge. |
kennel.enabled |
ZOOMIES_KENNEL_ENABLED |
at once | Check repository standards: Whether Kennel Club runs. Off by default, and off means off: no request to GitHub, nothing stored, and AI Context carries on as it was. On, it reads facts about the repositories this fleet serves and keeps what it concludes. |
kennel.refresh_interval |
ZOOMIES_KENNEL_REFRESH_INTERVAL |
at once | Refresh interval: How stale what Kennel Club reads from GitHub may get before it is read again. What the fleet observed for itself is re-checked as the fleet changes, whatever this says. The smallest useful value is 1h. |
kennel.repository_setup |
ZOOMIES_KENNEL_REPOSITORY_SETUP |
at once | Check repository setup: Whether Kennel Club reads the default-branch file inventory for advisory setup checks. Off by default. Needs Contents read for private repositories; reads no file contents and changes no App permissions. |
kennel.settings_checks |
ZOOMIES_KENNEL_SETTINGS_CHECKS |
at once | Check repository settings: Whether Kennel Club reads each repository's Actions settings, the default workflow token and the policy for fork pull requests, and the status checks its default branch requires. Off by default. Needs the App's Administration read permission, which GitHub offers no narrower form of and which is asked for separately; reads no file contents and changes no setting. |
kennel.workflow_checks |
ZOOMIES_KENNEL_WORKFLOW_CHECKS |
at once | Check workflow best practices: Whether Kennel Club reads default-branch workflow contents for timeouts, concurrency, action pins and token permission declarations. Off by default. Needs Contents read for private repositories; changes no files or App permissions. |
kennel.scope |
ZOOMIES_KENNEL_SCOPE |
at once | Repositories to check, served checks the repositories this fleet has run a job for; installation checks every repository the GitHub App can see, up to 500. installation multiplies the requests Kennel Club makes, so it is a choice and not the default. |
optimisation¶
| Key | Environment | Takes effect | What it is |
|---|---|---|---|
optimisation.source_reads |
ZOOMIES_OPTIMISATION_SOURCE_READS |
at once | Read workflow source for optimisation previews: Let the controller read one workflow file at a run's commit, through the installation's own GitHub access, so an operator can review it before a checkout optimisation is previewed. Off, no source is read. It does not turn on workflow edits and no model sees the file. |
optimisation.workflow_edits |
ZOOMIES_OPTIMISATION_WORKFLOW_EDITS |
at once | Preview workflow edits: Let the controller compute and keep, for fourteen days, a proposed change to a workflow file, such as reading one commit instead of the full history. Nothing is published, no pull request is opened and no job is run. Needs optimisation.source_reads as well. |
limits¶
| Key | Environment | Takes effect | What it is |
|---|---|---|---|
limits.event_subscribers |
ZOOMIES_LIMITS_EVENT_SUBSCRIBERS |
at once | Most live-update streams: The most live-update streams open at once for people signed in and API tokens. Every open tab of the UI holds one, so leave room for every operator's browser. A public view's visitors are not counted here. 0 is unlimited. |
limits.hosts |
ZOOMIES_LIMITS_HOSTS |
at once | Most hosts: The most hosts that may be enrolled at once; a join beyond it is refused. 0 is unlimited. A host joining again under its own name is not counted twice. |
limits.job_stats_window |
ZOOMIES_LIMITS_JOB_STATS_WINDOW |
at once | Longest job statistics window: The longest span of jobs one job statistics request may cover, from the API, the CLI or the MCP job_stats tool. A longer request is refused with this number in the message. 0 is the default of 90 days, not unlimited: the statistics are computed from the job rows on each request. Jobs older than retention.jobs no longer exist to be counted. |
limits.join_tokens |
ZOOMIES_LIMITS_JOIN_TOKENS |
at once | Most outstanding join tokens: The most join tokens that may be outstanding at once, counting those neither used nor expired; minting one beyond it is refused. 0 is unlimited. |
limits.pools |
ZOOMIES_LIMITS_POOLS |
at once | Most pools: The most pools this instance holds; creating one beyond it is refused. 0 is unlimited. |
limits.runners |
ZOOMIES_LIMITS_RUNNERS |
at once | Most runners: The most live runners across every pool. At the ceiling the scheduler creates no more, and each pool it held back says so in its scaling reason. 0 is unlimited. |
limits.visitor_event_subscribers |
ZOOMIES_LIMITS_VISITOR_EVENT_SUBSCRIBERS |
at once | Most visitors' live-update streams: The most live-update streams a public view's visitors may hold at once, all of them together. It is a ceiling of its own, so a crowd of visitors never takes the streams the people who run the fleet need. A visitor past it still reads every page, without live updates. 0 is unlimited. |
Every limit is platform-scoped: it is how much of the controller's machine a
fleet may spend, so on an instance several teams use, the account that runs the
process sets it and the fleet's administrators do not. A request that would
cross one is refused with a 409 whose code is limit_reached and whose
message and field name the setting. The runner ceiling has no request to
refuse (runners are the scheduler's to create) so the scheduler stops at it
and each pool it held back says limits.runners in its scaling reason.
A ceiling on a controller only its own machine can reach protects nothing, and
the validator says so with limits.loopback.
log¶
| Key | Environment | Takes effect | What it is |
|---|---|---|---|
log.format |
ZOOMIES_LOG_FORMAT |
next restart | Log format, json for a log collector, text for a person reading a terminal. |
log.level |
ZOOMIES_LOG_LEVEL |
at once | Log level: How much detail the controller logs. |
metrics¶
| Key | Environment | Takes effect | What it is |
|---|---|---|---|
metrics.enabled |
ZOOMIES_METRICS_ENABLED |
next restart | Prometheus endpoint: Serve the Prometheus endpoint. |
metrics.path |
ZOOMIES_METRICS_PATH |
next restart | Metrics path: Where it is served. |
metrics.public |
ZOOMIES_METRICS_PUBLIC |
next restart | Serve metrics without authentication: Serve it without authentication. Off by default, because job and repository names are visible in the label set. |
status¶
| Key | Environment | Takes effect | What it is |
|---|---|---|---|
status.mode |
ZOOMIES_STATUS_MODE |
at once | Fleet status page: Who may read the fleet's status at /status, /status.svg and /api/v1/status: nobody (off, the default), anyone signed in (authenticated), or anyone who can reach this controller (public). It carries no names: a state, banded counts, rounded waits and problem codes. public raises the status.public warning, and is refused with status.public_no_tls on a public bind without TLS. |
oidc¶
| Key | Environment | Takes effect | What it is |
|---|---|---|---|
oidc.admin_groups |
ZOOMIES_OIDC_ADMIN_GROUPS |
next restart | Administrator groups: Provider groups whose members get the administrator role. |
oidc.allow_signup |
ZOOMIES_OIDC_ALLOW_SIGNUP |
next restart | Create accounts on first sign-in: Create an account on a first successful single sign-on, rather than refusing anyone not already here. |
oidc.client_id |
ZOOMIES_OIDC_CLIENT_ID |
next restart | Client ID: The client this controller identifies itself as. |
oidc.client_secret |
ZOOMIES_OIDC_CLIENT_SECRET |
next restart | Client secret: The client secret that goes with it. |
oidc.enabled |
ZOOMIES_OIDC_ENABLED |
next restart | Single sign-on: Offer single sign-on as well as local accounts. |
oidc.hide_password_login |
ZOOMIES_OIDC_HIDE_PASSWORD_LOGIN |
at once | Hide the password form: Show only the single sign-on button while single sign-on is working, and refuse password sign-in below administrator. An administrator can still sign in with a password from /login?password. |
oidc.groups_claim |
ZOOMIES_OIDC_GROUPS_CLAIM |
next restart | Groups claim: The token claim listing the groups a user is in. |
oidc.issuer |
ZOOMIES_OIDC_ISSUER |
next restart | Issuer URL: The identity provider's issuer URL, from which its endpoints are discovered. |
oidc.label |
ZOOMIES_OIDC_LABEL |
at once | Sign-in button text: The words on the single sign-on button. Empty says "Sign in with" and the issuer's host. |
oidc.link_by_username |
ZOOMIES_OIDC_LINK_BY_USERNAME |
next restart | Link sign-on to local accounts: Let a first single sign-on take over an existing local account with the same username. Turn it on for the one migration where that is the intention, then turn it off again. |
oidc.platform_groups |
ZOOMIES_OIDC_PLATFORM_GROUPS |
next restart | Platform groups: Provider groups whose members get the platform role, above administrator. |
oidc.operator_groups |
ZOOMIES_OIDC_OPERATOR_GROUPS |
next restart | Operator groups: Provider groups whose members get the operator role. A user in no mapped group is a viewer. |
oidc.redirect_url |
ZOOMIES_OIDC_REDIRECT_URL |
next restart | Redirect URL: Where the provider sends the browser back to. Empty derives it from the external URL. |
oidc.scopes |
ZOOMIES_OIDC_SCOPES |
next restart | Scopes: The scopes asked for at sign-in. |
oidc.username_claim |
ZOOMIES_OIDC_USERNAME_CLAIM |
next restart | Username claim: The token claim that becomes a Zoomies username. |
provider¶
| Key | Environment | Takes effect | What it is |
|---|---|---|---|
provider.ambiguity_timeout |
ZOOMIES_PROVIDER_AMBIGUITY_TIMEOUT |
next restart | Unknown-outcome timeout: How long an operation whose outcome is unknown is reconciled by looking before a person is asked instead. It must outlast the creation timeout: a create that is merely slow is not an unknown outcome. |
provider.bootstrap_timeout |
ZOOMIES_PROVIDER_BOOTSTRAP_TIMEOUT |
next restart | Agent install timeout: How long installing the agent inside a machine that is already up may take. |
provider.call_timeout |
ZOOMIES_PROVIDER_CALL_TIMEOUT |
next restart | Provider request timeout: How long one API request to a provider may take. |
provider.create_timeout |
ZOOMIES_PROVIDER_CREATE_TIMEOUT |
next restart | Machine creation timeout: How long the whole asynchronous creation of a machine may take, rather than the request that starts it. |
provider.delete_grace |
ZOOMIES_PROVIDER_DELETE_GRACE |
next restart | Grace before a silent machine is lost: How long a machine whose host has gone silent is left alone before it is treated as lost. It has to outlast the controller's own judgement that a host is gone, or a network blip would destroy a machine in the middle of a job. |
provider.delete_timeout |
ZOOMIES_PROVIDER_DELETE_TIMEOUT |
next restart | Deletion timeout: How long an asynchronous deletion may take. |
provider.enabled |
ZOOMIES_PROVIDER_ENABLED |
at once | Rent machines: Whether the machine loop does anything at all. Off by default: renting a machine spends money, and nothing here should start doing that because a release added the ability to. Turning it off stops the whole loop, including draining and deleting machines already rented; use Pause new machines to stop buying and keep tending them. |
provider.enrol_timeout |
ZOOMIES_PROVIDER_ENROL_TIMEOUT |
next restart | Enrolment timeout: How long a bootstrapped machine has to appear as a host. It has to outlast a heartbeat timeout, or a machine that joined and went briefly quiet would be given up on. |
provider.idle_timeout |
ZOOMIES_PROVIDER_IDLE_TIMEOUT |
at once | Idle before draining: How long a machine's host must have had no runner on it before the machine is drained. |
provider.interval |
ZOOMIES_PROVIDER_INTERVAL |
next restart | Machine loop interval: How often the machine loop runs. It is slower than the scheduler's on purpose: a clone takes minutes, and the pass that watches one gains nothing from a ten-second tick. |
provider.max_creates_in_flight |
ZOOMIES_PROVIDER_MAX_CREATES_IN_FLIGHT |
at once | Machines built at once: How many machines may be being built at once across the fleet, so a burst of queued jobs cannot ask a hypervisor for fifty clones in one pass. |
provider.max_machines |
ZOOMIES_PROVIDER_MAX_MACHINES |
at once | Machines the fleet may rent: The ceiling across every provider. Zero rents nothing, exactly as a pool's max_runners of zero runs nothing: a maximum of none is none. |
provider.paused |
ZOOMIES_PROVIDER_PAUSED |
at once | Pause new machines: Stop creating machines while leaving draining, deleting, recovering and verifying ownership running. A switch that stopped those too would strand running machines nobody is watching. |
provider.scale_down_cooldown |
ZOOMIES_PROVIDER_SCALE_DOWN_COOLDOWN |
next restart | Cooldown before deleting: How long that idleness must hold continuously before anything is deleted, so a quiet minute between two bursts does not destroy the machines the second burst is about to want. |
provider.scale_up_delay |
ZOOMIES_PROVIDER_SCALE_UP_DELAY |
next restart | Delay before renting: How long a pool's demand must stand before a machine is bought for it. Zero, unlike the scheduler's: a machine that takes four minutes to arrive has already spent the delay by being slow. |
provider.sweep_interval |
ZOOMIES_PROVIDER_SWEEP_INTERVAL |
next restart | Ownership sweep interval: How often each provider is asked for everything it believes it is running, which is how an orphaned machine and one that vanished underneath us are both found. |
retention¶
| Key | Environment | Takes effect | What it is |
|---|---|---|---|
retention.assistant_conversations |
ZOOMIES_RETENTION_ASSISTANT_CONVERSATIONS |
at once | Keep Eli conversations for: How long a person's saved conversations with Eli are kept since they were last touched. Each person keeps at most fifty and can delete any of theirs sooner; nobody else can read them. 0 keeps them until the person deletes them. |
retention.jobs |
ZOOMIES_RETENTION_JOBS |
at once | Keep job history for: How long job history is kept. |
retention.machines |
ZOOMIES_RETENTION_MACHINES |
at once | Keep deleted machines for: How long a deleted machine's row is kept, so what the fleet rented and gave back is still answerable after the machine itself is gone. |
retention.runner_sessions |
ZOOMIES_RETENTION_RUNNER_SESSIONS |
at once | Keep runner sessions for: How long the usage ledger keeps each finished runner's session (who it ran for, where, and for how long) after the runner's own row has been pruned. |
retention.runners |
ZOOMIES_RETENTION_RUNNERS |
at once | Keep finished runners for: How long finished runners are kept. |
retention.samples |
ZOOMIES_RETENTION_SAMPLES |
at once | Keep Overview samples for: How long the Overview's samples are kept. |
retention.scaling_events |
ZOOMIES_RETENTION_SCALING_EVENTS |
at once | Keep scaling history for: How long scaling decisions are kept. Audit rows are not covered by this, or by anything: they are never deleted. |
retention.update_attempts |
ZOOMIES_RETENTION_UPDATE_ATTEMPTS |
at once | Keep update history for: How long a finished attempt to update the controller or a host, and the rollout that grouped it, is kept, so why a host is behind stays answerable. One still open is never removed, and neither is a failure on the release a machine would still be taken to or the controller last tried, nor a rollout a person cancelled while no later one to its release has ended, so age never resets the two failures that hand a machine to a person or undoes a cancel. 0 keeps every one. |
retention.webhooks |
ZOOMIES_RETENTION_WEBHOOKS |
at once | Keep webhook deliveries for: How long webhook deliveries are kept. |
runners¶
| Key | Environment | Takes effect | What it is |
|---|---|---|---|
runners.default_cpus |
ZOOMIES_RUNNER_DEFAULT_CPUS |
at once | Standard CPUs per runner: Where a pool's CPU slider opens when somebody chooses to set a fixed size, in cores; fractions are allowed. It is not what a pool with no size becomes: such a pool is given one slot's share of whichever host each runner lands on. 0 means nothing has been said and the built-in 2 cores answers. |
runners.default_memory_mb |
ZOOMIES_RUNNER_DEFAULT_MEMORY_MB |
at once | Standard memory per runner: How much memory one runner gets on a pool that has not said otherwise, in megabytes. It is the figure a new pool opens on, and the one a host's recommended capacity is worked out from. 0 means nothing has been said and the built-in 4096 answers. |
runners.minimum_cpus |
ZOOMIES_RUNNER_MINIMUM_CPUS |
at once | Minimum CPUs per runner: The fleet's minimum CPU per runner, in cores: the least a runner may be given when no host has room for its standard size (a fixed pool's figures, or an automatic pool's whole slot share) so a host a little short still runs the job. An automatic pool's runner is never given less: a slot share below it is raised to it, and the host holds fewer runners. Every pool that sets no minimum of its own follows it, live; a pool's own minimum wins. 0 is none. |
runners.minimum_memory_mb |
ZOOMIES_RUNNER_MINIMUM_MEMORY_MB |
at once | Minimum memory per runner: The fleet's minimum memory per runner, in megabytes: the least a runner may be given when no host has room for its standard size, fixed or automatic. An automatic pool's runner is never given less: a slot share below it is raised to it, and the host holds fewer runners. Every pool that sets no minimum of its own follows it, live; a pool's own minimum wins. 0 is none; anything set is held to 512. |
runners.small_cpus |
ZOOMIES_RUNNER_SMALL_CPUS |
at once | Small runner, CPUs: The size of one runner in the small class where a host's runner profile says nothing: what an automatic pool gives it, and what a job is measured against when it is classed. Default 1. |
runners.small_memory_mb |
ZOOMIES_RUNNER_SMALL_MEMORY_MB |
at once | Small runner, memory: Default 2048. |
runners.medium_cpus |
ZOOMIES_RUNNER_MEDIUM_CPUS |
at once | Medium runner, CPUs: Default 2, the size of the fleet's own default runner. |
runners.medium_memory_mb |
ZOOMIES_RUNNER_MEDIUM_MEMORY_MB |
at once | Medium runner, memory: Default 4096. |
runners.large_cpus |
ZOOMIES_RUNNER_LARGE_CPUS |
at once | Large runner, CPUs: Default 4. |
runners.large_memory_mb |
ZOOMIES_RUNNER_LARGE_MEMORY_MB |
at once | Large runner, memory: Default 8192. |
runners.docker_wait |
ZOOMIES_DOCKER_WAIT |
at once | Docker daemon wait: How long DinD provisioning waits for a healthy daemon, and a Docker runner waits before registering. Default 3m. Whole seconds, up to an hour; 0 leaves the runner image's own default. A pool's env can set ZOOMIES_DOCKER_WAIT to override it for that pool. |
runners.env |
ZOOMIES_RUNNER_ENV |
at once | Runner environment: Key=value variables every runner starts with, such as a proxy or a package mirror. A pool's own env wins where the two name the same variable. Every job can read these, so a credential does not belong here: give it to the pool, or to the workflow as a GitHub secret. |
scheduler¶
| Key | Environment | Takes effect | What it is |
|---|---|---|---|
scheduler.auto_rerun |
ZOOMIES_AUTO_RERUN |
at once | Re-run jobs the fleet broke: Ask GitHub to run a job again when this fleet is what broke it, never a test that failed. Off by default: it spends GitHub minutes without asking. |
scheduler.auto_rerun_limit |
ZOOMIES_AUTO_RERUN_LIMIT |
at once | Automatic re-runs per workflow run: How many times one run may be re-run automatically (1–5), counted from GitHub's own run attempt. |
scheduler.default_runner_limits |
ZOOMIES_DEFAULT_RUNNER_LIMITS |
at once | Default runner limits: Give a runner whose pool sets no CPU or memory limit one slot's share of its host as a real limit. Off, a host's worth of them can each take every core. |
scheduler.drain_timeout |
ZOOMIES_DRAIN_TIMEOUT |
at once | Drain timeout: Fail a runner that has been draining this long with no job left on it. A runner still finishing a job is never touched by it. |
scheduler.host_throttling |
ZOOMIES_HOST_THROTTLING |
at once | Throttle hosts under pressure: Let the controller throttle a host its measurements say is overwhelmed, and step it back up after a stretch of calm. |
scheduler.host_auto_adjust |
ZOOMIES_HOST_AUTO_ADJUST |
at once | Keep hosts at their recommended size: Let the controller keep every host's capacity and reserve at what Zoomies recommends for its machine, disk and the pools' runner size, and re-work them as the host reports and the pools change. This covers hosts the fleet rents from a provider. A host can be set on or off by itself. On by default; a host set to never adjust keeps the figures an operator typed. |
scheduler.host_order |
ZOOMIES_HOST_ORDER |
at once | Host placement order: Which host a runner prefers once the spec score has decided. headroom (default) picks the one with the most CPU and memory left afterwards, which spreads work. largest_standard picks the one whose runner of the pool is biggest, which suits a mixed fleet sized by runner profile. best_fit picks the one with the least left, filling one host before starting the next. Hosts are ranked first by what their agents report (more and faster CPU, memory and disk score higher) and by any operator's pin, so the order only chooses between hosts of the same priority. It only ever chooses among hosts that already fit, so it moves where runners go and never whether they start. |
scheduler.history_sizing |
ZOOMIES_HISTORY_SIZING |
at once | Job-history placement, shadow (default) records where it would place differently, on places each runner only on a host with room for what the jobs waiting on its pool used on their recent runs (the ninetieth percentile of their peaks, memory with a fifth more) and sizes it up to that where the pool leaves its size to the host, off ignores history. See Sizing from job history. |
scheduler.size_routing |
ZOOMIES_SIZE_ROUTING |
at once | Route jobs by size class, off (default) classes nothing. shadow classes each job small, medium or large from what its recent runs used and records the class and where the job ran, and sends nothing anywhere. on sends each job to the pool for its class. A workflow that writes zoomies-large in runs-on is always honoured and never moved; a job that writes only zoomies is routed best effort, because GitHub decides which waiting job a runner takes. Changing it puts the jobs already waiting through the new mode. See size classes and automatic pools. |
scheduler.auto_pools |
ZOOMIES_AUTO_POOLS |
at once | Automatic pools, off (default) leaves pools as you made them. shadow says what would change and still gives each host its class. on makes and keeps one pool for each architecture and size class among the hosts the fleet has, sized by the hosts in it. A pool you made is never touched. See size classes and automatic pools. |
scheduler.auto_pools_installation |
ZOOMIES_AUTO_POOLS_INSTALLATION |
at once | Installation for automatic pools: The organisation or repository whose GitHub App installation the pools belong to. Empty means the only installation; a fleet with several has to say which. |
scheduler.auto_pools_docker_mode |
ZOOMIES_AUTO_POOLS_DOCKER_MODE |
at once | Docker mode of automatic pools, none (default) gives jobs no daemon, dind gives each runner a private one. The host socket is not offered. |
scheduler.auto_pools_host_grace |
ZOOMIES_AUTO_POOLS_HOST_GRACE |
at once | Grace before a silent host stops counting: How long a host may go without a heartbeat and still count towards its pool's maximum. Default 10m; it has to be longer than the five minutes after which the fleet gives a silent host's runners up. |
scheduler.size_class_hold |
ZOOMIES_SIZE_CLASS_HOLD |
at once | Hold before a host changes class: How long a host's measurements must name a different class, without a break, before it moves. Default 10m; 0 moves it at once. |
scheduler.size_default_class |
ZOOMIES_SIZE_DEFAULT_CLASS |
at once | Class of a job with no history, medium (default), the size of the fleet's own default runner. |
scheduler.size_fallback_wait |
ZOOMIES_SIZE_FALLBACK_WAIT |
at once | Wait for room in a job's class: How long a job waits before another class may take it. Default 2m; a class with no pool is not waited on. |
scheduler.size_small_max_cpus |
ZOOMIES_SIZE_SMALL_MAX_CPUS |
at once | Largest small host, CPUs: Allocatable CPUs, after the reserve. Default 4. A host is in the lower of the classes its CPU and memory each name. |
scheduler.size_small_max_memory_mb |
ZOOMIES_SIZE_SMALL_MAX_MEMORY_MB |
at once | Largest small host, memory: Allocatable megabytes. Default 16384. |
scheduler.size_medium_max_cpus |
ZOOMIES_SIZE_MEDIUM_MAX_CPUS |
at once | Largest medium host, CPUs: Default 12. A host with more is large. |
scheduler.size_medium_max_memory_mb |
ZOOMIES_SIZE_MEDIUM_MAX_MEMORY_MB |
at once | Largest medium host, memory: Default 49152. A host with more is large. |
scheduler.placement_mode |
ZOOMIES_PLACEMENT_MODE |
at once | Host placement, headroom (default), shadow to compare startup-history placement, or readiness to enable it. See rollout and evidence. |
scheduler.interval |
ZOOMIES_SCHEDULER_INTERVAL |
at once | Scheduler interval: How often the scheduler runs a pass even with nothing to react to. |
scheduler.registration_concurrency |
ZOOMIES_REGISTRATION_CONCURRENCY |
at once | Bound concurrent credential requests per installation; default 1, range 1–16. Excess demand waits for a later scheduling pass. Existing calls finish when lowered. |
scheduler.max_creates_per_tick |
ZOOMIES_MAX_CREATES_PER_TICK |
at once | Runners created per pass: How many runners may be created in one pass, so a thundering herd of queued jobs cannot exhaust a host in one go. |
scheduler.max_runner_lifetime |
ZOOMIES_MAX_RUNNER_LIFETIME |
at once | Maximum runner lifetime: Drain a runner that has lived this long, next time it is not busy. It bounds how long a runner's credentials live; it never ends a job. |
scheduler.provision_timeout |
ZOOMIES_PROVISION_TIMEOUT |
at once | Provision timeout: Fail a runner that never finishes registering, so a bad image does not hold a host slot for ever. |
scheduler.scale_up_delay |
ZOOMIES_SCALE_UP_DELAY |
at once | Scale-up delay: How long a job must have been queued before it counts as demand. It damps churn when jobs arrive in bursts; 0 reacts at once. |
security¶
| Key | Environment | Takes effect | What it is |
|---|---|---|---|
security.cookie_secure |
ZOOMIES_COOKIE_SECURE |
next restart | Secure session cookies: Force the Secure attribute on session cookies. Unset derives it from the external URL and the TLS mode, which is right unless a proxy in front makes it wrong. |
security.allow_private_egress |
ZOOMIES_ALLOW_PRIVATE_EGRESS |
live | Allow private outbound addresses: Let the URLs this controller dials (the OIDC issuer, the GitHub API, the capacity-demand destination, the runner download mirror, a backup remote and a provider) name this machine, a link-local address or a private network. Off, writing one through the API is refused and one in the file or environment is warned about, as egress.private_target. Turn it on when one of them really lives on a network you own. |
security.disable_auth |
ZOOMIES_DISABLE_AUTH |
next restart | Disable authentication: Remove all authentication. It exists for local development, and it is refused wherever this controller looks reachable. |
security.docker_in_docker_expected |
ZOOMIES_DOCKER_IN_DOCKER_EXPECTED |
live | Docker-in-Docker is expected here: Stop a pool that gives its jobs their own Docker daemon being listed as a dangerous setting. The daemon still runs in a privileged container; this is a fleet saying it knows, so that the settings still worth a second look are not buried under one it has already decided. The host socket and persistent runners keep warning. |
security.encryption_key |
ZOOMIES_ENCRYPTION_KEY |
file or environment only | Encryption key: The 32-byte key, base64 or hex, that seals GitHub App private keys, webhook secrets and the stored credentials below. Prefer the key file or the environment variable: a key written into zoomies.yaml is a key in your configuration management system. |
security.encryption_key_file |
ZOOMIES_ENCRYPTION_KEY_FILE |
file or environment only | Encryption key file: Where that key is read from, and written to on a first run. Back it up beside the database: without it the sealed rows cannot be read. |
security.mcp_oauth |
ZOOMIES_MCP_OAUTH |
live | Sign in to MCP with OAuth: Let an MCP client such as Claude be added by this controller's /mcp address alone and sign a person in through the browser, with this controller as its own OAuth authorisation server. The token it receives works on /mcp and nowhere else, at a role the person chooses and no higher than their own. Unset turns it on when authentication is on and the controller is reached over https. See Connect Claude to Zoomies. |
security.mcp_open_registration |
ZOOMIES_MCP_OPEN_REGISTRATION |
live | Let MCP clients register themselves: Let an MCP client register itself (by dynamic client registration or a client ID metadata document) rather than only use a client an administrator created under Settings, MCP clients. A client that registers itself can do nothing until a person signs in and approves it. Off, only the clients an administrator created can ask. |
security.mcp_admin_tools |
ZOOMIES_MCP_ADMIN_TOOLS |
live | Offer administrator tools over MCP: Let an MCP token or connection whose role is administrator edit a host's name, labels and reserve, cordon it and lift its throttle, and read and change the tuning settings (scheduler, retention, runners, limits, logging, metrics, images, status, UI and the update check interval, never security, sign-in, GitHub, providers, the database, or the update mode and soak). Off, those tools are not offered and an agent that asks is told to turn this on. It changes nothing for a role below administrator. |
security.auto_apply_remedies |
ZOOMIES_AUTO_APPLY_REMEDIES |
live | Apply suggested changes automatically: Let the controller make the change a problem proposes -- a sidecar share, a smallest runner, a warm runner -- without anyone clicking it. A change is made only after the same proposal has stood unchanged for two hours, at most once a day for any one pool or host, and never one an administrator has undone. It runs as the pool's or host's own update, so every refusal that route makes still holds, and each change is recorded with what it replaced and can be undone from the problems page. Shadow, the default, works out what it would have changed and when, and records that without changing anything, so what turning it on would do can be read first (would_apply in GET /problems/auto-applied, and under Problems); off, the controller only proposes. true and false are still read as on and off. |
security.public_view |
ZOOMIES_PUBLIC_VIEW |
next restart | Public read-only view: Let anyone who can reach this controller read its fleet without signing in: pools, hosts, runners, jobs, usage and runner logs. The audit log, accounts, tokens, settings, AI context and Eli stay behind sign-in, and every change still needs somebody signed in. For a fleet that is meant to be public, such as a demo. See Public view. |
security.rate_limit_logins |
ZOOMIES_RATE_LIMIT_LOGINS |
next restart | Login attempts per minute: Password attempts allowed per source address per minute, and five times that per account. |
security.require_two_step |
ZOOMIES_REQUIRE_TWO_STEP |
next restart | Require two-step verification: Make every account that signs in with a password set up an authenticator app at its next sign-in. Accounts that use single sign-on are not asked (their identity provider owns their second factor) and API tokens are unaffected. See Two-step verification. |
security.session_ttl |
ZOOMIES_SESSION_TTL |
next restart | Session lifetime: How long a browser login lasts before it has to be made again. |
server¶
| Key | Environment | Takes effect | What it is |
|---|---|---|---|
server.allow_indexing |
ZOOMIES_ALLOW_INDEXING |
next restart | Allow search engine indexing: Invite search engines into the UI. Off by default: a controller is somebody's infrastructure rather than somebody's website. |
server.allowed_origins |
ZOOMIES_ALLOWED_ORIGINS |
next restart | Allowed browser origins: Extra browser origins allowed to make state-changing requests. Empty means same-origin only, which is what the built-in UI needs. |
server.bind |
ZOOMIES_BIND |
next restart | Listen address: The address the controller listens on. 127.0.0.1:8080 is this machine only; 0.0.0.0:8080 is every interface. |
server.external_url |
ZOOMIES_EXTERNAL_URL |
next restart | External URL: How GitHub and browsers reach this controller. It forms the webhook URL, so webhooks need it. |
server.idle_timeout |
ZOOMIES_IDLE_TIMEOUT |
next restart | Idle timeout: How long an idle keep-alive connection is held open. |
server.read_timeout |
ZOOMIES_READ_TIMEOUT |
next restart | Read timeout: How long a client may take to send its request. |
server.tailcat_enabled |
ZOOMIES_TAILCAT_ENABLED |
next restart | Private agent network: Permit private agent connections, started on first enrolment. |
server.tls.cert_file |
ZOOMIES_TLS_CERT_FILE |
next restart | Certificate file: The certificate the listener serves, when the mode is files. |
server.tls.hosts |
ZOOMIES_TLS_HOSTS |
next restart | Certificate host names: The names baked into a generated self-signed certificate. |
server.tls.key_file |
ZOOMIES_TLS_KEY_FILE |
next restart | Private key file: The private key for that certificate. The file stays on disk; only its path is stored here. |
server.tls.mode |
ZOOMIES_TLS_MODE |
next restart | TLS mode: How the listener terminates TLS: off behind a reverse proxy, self-signed for a generated certificate, files for one of your own. |
server.trusted_proxies |
ZOOMIES_TRUSTED_PROXIES |
next restart | Trusted proxies: CIDRs whose X-Forwarded-For header is believed, or the word cloudflare for Cloudflare's published ranges. Empty takes client addresses from the socket, which is the safe answer. |
server.write_timeout |
ZOOMIES_WRITE_TIMEOUT |
next restart | Write timeout: How long a response may take. It is 0, and should stay 0: the event stream and a followed log are responses that never end. |
ui¶
What the web UI opens with. The capacity map layouts are each a starting point: the page itself lets an operator pick the other answer, and remembers the pick in that browser, so what is set here is what somebody who has never chosen sees. The two pages that carry it are set separately, because they are opened for different reasons (the Overview to glance at a fleet, the Hosts page to look into a machine) and a fleet may want each to open on its own answer. The queue warning threshold is not a per-page starting point: there is nothing to move away from on the page, so what is set here is what every operator's browser sees.
| Key | Environment | Takes effect | What it is |
|---|---|---|---|
ui.capacity_map.hosts_layout |
ZOOMIES_UI_CAPACITY_MAP_HOSTS_LAYOUT |
at once | Hosts capacity map layout: The same choice for the map on the Hosts page, which can open differently from the Overview's: split suits the page a machine is looked into on, overlay the page a fleet is glanced at. |
ui.capacity_map.overview_layout |
ZOOMIES_UI_CAPACITY_MAP_OVERVIEW_LAYOUT |
at once | Overview capacity map layout: How the host capacity map on the Overview opens: overlay draws every host on one chart, split draws a chart for each. An operator who picks the other one on the page keeps their pick in that browser. |
ui.queue_warning_threshold |
ZOOMIES_UI_QUEUE_WARNING_THRESHOLD |
at once | Queue depth warning threshold: How many jobs must be queued before the queue tiles on the Overview, Jobs and Pools pages turn to their warning colour. 1, the default, warns as soon as anything is waiting; a fleet whose queue is normally busy can raise it so the colour still means something. |
updates¶
| Key | Environment | Takes effect | What it is |
|---|---|---|---|
updates.check_interval |
ZOOMIES_UPDATE_CHECK_INTERVAL |
at once | Update check interval: How often github.com is asked which release of Zoomies is current. 0 never asks, and is the one request that is not about your fleet. Nothing is ever downloaded by it. |
updates.mode |
ZOOMIES_UPDATE_MODE |
at once | Release update mode: What this controller does about a newer release of Zoomies. off (default) says that one exists and nothing more. manual adds Update buttons for the controller and its hosts, and nothing moves without a click. auto takes the newest release once it has been public for updates.soak, and the hosts that have opted in then follow it, one at a time. Auto updates a machine only through its own update helper: while a release is due and the helper beside the controller is not installed, auto waits and updates no host either (where that helper cannot be installed, the controller is left to a person and hosts follow the release it runs). Hosts go one at a time, nothing starts while the controller is fenced, and a failed update halts the rollout until an administrator resumes or cancels it on the Hosts page (or with zoomies updates resume or cancel), where a running rollout can be cancelled too. Only the platform role changes it, and an assistant connected over MCP cannot. A mode other than off beside updates.check_interval: 0 raises the updates.mode_without_check warning, and auto raises the updates.auto notice so that unattended updating is never silent. |
updates.soak |
ZOOMIES_UPDATE_SOAK |
at once | Release update soak: How long a release must have been public before auto takes it, counted from when GitHub published it. A newer release restarts the wait, so one that is replaced quickly is never installed. 24h is the default. 0 removes the wait, which under auto raises the updates.auto_without_soak warning. manual ignores it, because a person pressing the button is the soak. |
The settings that matter most¶
server.external_url¶
The address GitHub and your browser use. Webhooks are delivered to
<external_url><github.webhook_path>, and the OIDC redirect URL is derived from
it.
Without it, Zoomies cannot tell GitHub where to deliver webhooks, so scaling falls back entirely to polling and reacts in tens of seconds rather than instantly. It warns about this at startup.
server.tailcat_enabled¶
Default true; environment override ZOOMIES_TAILCAT_ENABLED.
Permits built-in private agent connections through Tailcat. The controller
starts its userspace listener on first private enrolment, then resumes it on
subsequent starts. Requires authentication and the instance encryption key.
Set false and restart to stop private connections without deleting their saved
identity. See Private hosts.
server.bind¶
Defaults to loopback. If you change it to 0.0.0.0 and leave TLS off, you get a
warning, which is correct behaviour if a reverse proxy terminates TLS, and a
problem otherwise. When you do run behind a proxy, also set
server.trusted_proxies so audit entries and login rate limiting see the real
client address instead of your proxy's.
It governs X-Forwarded-Proto as well as X-Forwarded-For. Without it the
controller reads every request as plain http, whatever the header says,
because nothing distinguishes your proxy from anyone else who can reach the
listener, so it will not send Strict-Transport-Security, and an https://
Origin is same-origin only by way of server.external_url. Set both and
neither applies.
The setup token¶
While the instance has no accounts, the controller prints a setup token at
startup (on the banner and on a log line beginning setup token) and the
first-run page asks for it. It is what stops a controller you have just deployed
from being claimed by whoever loads the page first; an empty database is not, by
itself, proof that you are its owner.
It is minted per process and held only in memory, so restarting the controller
prints a new one, and the line stops appearing for good once an account exists.
zoomies init and the installer create the first account on the console
and never need it. The token is for a person at a browser; an unattended
deployment should not scrape it out of the log, and uses the variables below.
The first account, from the environment¶
A compose file or a Terraform module has nobody watching, so there is nobody to
read the setup token. These three variables create the first account instead,
once, at startup, on a database with no accounts. They are read from the
environment only: they are not keys in zoomies.yaml and not settings in the
database, because they are an instruction to an empty database rather than
something the controller keeps doing.
| Environment | What it is |
|---|---|
ZOOMIES_BOOTSTRAP_ADMIN |
The username of the first account. It holds the platform role, as the one the first-run page makes does. |
ZOOMIES_BOOTSTRAP_PASSWORD_FILE |
A file holding that account's password, at least 12 characters. A trailing newline is ignored. |
ZOOMIES_BOOTSTRAP_TOKEN_FILE |
A file holding an API token you generated, at least 32 characters, no whitespace, for example openssl rand -hex 32. The controller registers it as a platform API token named bootstrap for the account, and prints nothing: the secret is the one you already hold. The account has no password, so it is automation's identity and cannot sign in to the UI; create people their own accounts with it. |
Set the username and exactly one of the two files; anything else is
bootstrap.incomplete and the controller does not start. Each file must be
mode 0600 or tighter; a file group or other can read is refused, as the
encryption key file is. Docker's secrets: mounts files 0444 unless the long
syntax sets mode: 0400.
The account is recorded in the audit log as auth.bootstrap with the system as
the actor and the method it came by; the first-run page, the installer and an
answer file's admin keys write the same row with their own method. Once any
account exists the variables are ignored, and bootstrap.ignored names them
until they are removed. /readyz reports bootstrap_required, so a provisioner
waits on one endpoint for the instance to be usable.
server.allow_indexing¶
Off, and it should usually stay off. A controller serves /robots.txt and
/sitemap.xml like any other web address, and by default the first of them
declines crawling altogether: this is your infrastructure, and appearing in a
search result is a way of being found that nobody asked for. The page itself
carries noindex, nofollow to say the same thing to a crawler that arrived from
a link without reading robots.txt.
Turn it on and robots.txt invites crawlers to the UI's own pages (never
/api/, /metrics or the webhook path) advertises the sitemap, and the page
switches to index, follow. Zoomies warns at startup when it is on, because the
sign-in page and this controller's address then become public knowledge.
/sitemap.xml is served either way. It lists the interface's top-level pages
and nothing about your fleet: no pool, runner or job has an entry, because those
addresses are gone by tomorrow.
security.encryption_key¶
32 bytes, base64 or hex. Generate one with openssl rand -base64 32.
Supply it through ZOOMIES_ENCRYPTION_KEY or a 0600 key file. Putting it in
zoomies.yaml produces a warning, because anything that can read your config
(backups, configuration management, a support bundle) can then decrypt every
stored secret.
Zoomies refuses to read a key file that is group- or world-readable.
Back it up, separately from the database. Losing it means re-entering the GitHub App private key and webhook secret.
github.api_base_url: GitHub Enterprise Server¶
A bare hostname is accepted and /api/v3 appended. Everything else (App auth,
JIT configs, webhooks, runner groups) works the same.
GitHub Enterprise Cloud with data residency (GHE.com) is laid out like
github.com rather than like Enterprise Server: the API is the root of its own
api. host, with no /api/v3. Give the tenant's address in any of its forms
(octocorp.ghe.com, https://octocorp.ghe.com or
https://api.octocorp.ghe.com) and Zoomies uses
https://api.octocorp.ghe.com/ for the API and https://octocorp.ghe.com for
runner registration and the links it shows.
github.toolchain_scan_interval: what each pool's jobs install¶
A toolchain scan reads every workflow every installation can see and records,
per pool, which setup-python, setup-node, setup-go, setup-java and
setup-dotnet versions its jobs ask for, how many jobs ask, and in which
repositories. Each job is counted against the pool the scheduler would give it
on its labels, so the answer is about the pool that really runs it. It is what
tells you what to bake into a pool's image or keep in its
cache, rather than finding out one download at a time.
Read the result with GET /api/v1/toolchains, and start a scan by hand with
POST /api/v1/toolchains/scan. A version the workflow does not state is listed
with the reason: go-version-file: go.mod reads a file this does not fetch, and
${{ inputs.python }} is only known when the run starts. A matrix is expanded
the way GitHub expands it, so python-version: ${{ matrix.python }} over
["3.12", "3.13"] is two versions. Jobs on GitHub-hosted labels, or labels no
pool carries, are listed as unmatched rather than guessed onto a pool.
It needs the App's Contents: read permission, which the migration wizard already asks for. GitHub answers an App without it exactly as it answers a repository with no workflows, so the scan asks first, and says so, rather than reporting an organisation that needs nothing.
It is off by default because of what it costs. A scan lists every repository and reads every workflow file in each: on an organisation with hundreds of repositories that is thousands of API calls, from the same hourly quota the scheduler uses to find queued jobs. It reads four repositories at a time, and skips an installation GitHub is already rate-limiting, but it does not ration itself beyond that: a daily scan, or one by hand after workflows change, is plenty, because workflows do not change by the hour. The result is kept in memory, not in the database: it is a reading the next scan replaces, and a restart loses nothing a scan cannot rebuild.
A finished scan also fills the tool cache of every pool that keeps one, with the versions it found that pool's jobs asking for.
github.allow_workflow_cancellation: cancel runs from Zoomies¶
On by default. Operators can cancel the GitHub workflow run containing a queued or running job from its drawer. GitHub only provides a run-level API, so this stops every job in that run. The confirmation also offers force cancellation for a run that remains stuck after an ordinary request. As soon as GitHub accepts the request, Zoomies pauses every locally queued job in the run and removes runners executing its jobs; it does not wait for the current step to finish. GitHub's completion events still supply the authoritative job conclusions.
This feature requires the GitHub App's Actions: read and write permission.
New Apps created while the setting is enabled request it automatically. For an
existing App, change the permission in GitHub and accept the permission update
on every installation before using the action. Set the option to false if you
prefer to keep Actions read-only and hide cancellation from Zoomies.
github.runner_image: the fallback runner image¶
This is the last of three answers, not the first. A pool that names an image
gets that image; a pool that names a platform gets the variant its platform
selects; only a pool that names neither falls back to this setting. Whichever it
lands on, a pool whose docker_mode gives jobs a daemon then gets that image's
Docker variant. See Naming and platforms for the catalogue and how
a pool picks from it.
Five images are published to GHCR:
| Image | What it is |
|---|---|
ghcr.io/eyupio/zoomies |
the controller |
ghcr.io/eyupio/zoomies-agent |
an agent, for a host that runs one in a container |
ghcr.io/eyupio/zoomies-runner |
the runner a pool starts |
ghcr.io/eyupio/zoomies-runner-docker |
the same, plus a Docker CLI; a pool is switched to it when its docker_mode gives jobs a daemon, see Jobs that build container images |
ghcr.io/eyupio/zoomies-runner-full |
zoomies-runner-docker plus the language toolchains the setup-* actions would otherwise download, for the Ubuntu variants; a pool opts in by naming it, see The full image |
They share their tag names and release/development channels.
The controller and the agent follow releases:
| Tag | Points at | Published by |
|---|---|---|
latest |
The newest full release | release.yml, when a release is published |
vX.Y.Z |
One published release | release.yml, when a release is published |
dev |
The tip of main |
ci.yml, on every push to main |
main |
The tip of main |
ci.yml, on every push to main |
sha-<commit> |
One exact commit | ci.yml, on every push to main |
An operator pulling latest gets a release, which is what it reads as. It used
to mean the tip of main, and both workflows wrote it, so whichever ran last
won, and a controller pulled from it could be an unreleased build stamped
main-sha-abc1234. That is worse than a stale tag: an agent is installed from a
release asset and no release carries a main- version, so such a controller
cannot match any agent it enrols, and shows every host as a different build for
as long as it runs. Run dev when you want main; it says so.
A prerelease (a tag with a hyphen in it, v0.1-alpha, v1.0-rc1) is
published under its own tag and does not move latest. Name it to run it.
The runner images use the same explicit channels:
| Tag | Points at | Published by |
|---|---|---|
latest |
The newest full release | release.yml, when a release is published |
dev |
The tip of main |
ci.yml, on every push to main |
main |
The tip of main |
ci.yml, on every push to main |
sha-<commit> |
One exact commit | ci.yml, on every push to main |
vX.Y.Z |
One tagged release | release.yml, on a v* tag |
With no image override, development controller images automatically select
:dev runners; platform pools select e.g. :debian-12-dev. Release controller
images select their own release tag, e.g. :v1.2.3 or :debian-12-v1.2.3,
including when pulled via :latest. Docker-enabled pools keep the same tag
when selecting zoomies-runner-docker.
The dev, main and SHA controller aliases share one image and therefore
share the dev runner default: a container cannot discover which alias was
used to pull it. For an immutable development runner, explicitly select
sha-<commit> (or <platform>-sha-<commit>) in the pool image field.
Native builds retain the latest default. Local container builds default to
dev; set the Docker build argument RUNNER_IMAGE_TAG for a different tag.
Explicit pool images, including custom repositories, tags and digests, remain
overrides. github.runner_image / ZOOMIES_RUNNER_IMAGE still overrides the
fallback for pools without a platform. Leave these unset to follow the build.
Existing saved :latest values are treated as explicit overrides; clear the
pool image and remove any fallback setting to restore automatic selection.
Both runner images are also published with one tag per operating system
(ubuntu-2404, ubuntu-2604, ubuntu-2204, debian-12, debian-13,
fedora-42, rocky-9, each built for amd64 and arm64) plus <os>-<version>-dev, <os>-<version>-main and
<os>-<version>-<tag> for
pinning one operating system without pinning the controller. latest is the
ubuntu-2404 variant. Set this key to a specific variant to change what an
unspecified pool boots fleet-wide.
Every push to main rebuilds the runner variants so their dev labels and
build stamp identify the same commit as the controller, agent image and binary.
Every variant and both targets come out of the same Dockerfile, so they are
never out of step with each other.
What is in the runner image¶
Both runner images carry the actions/runner release and its .NET dependencies, and the toolchain a build typically reaches for without saying so:
| Compilers and build tools | build-essential (gcc, g++, make), cmake, pkg-config, autoconf, automake, libtool, patch, gettext |
| Headers native extensions link against | OpenSSL, zlib, libffi, libyaml, libxml2, libxslt, libcurl, SQLite, readline, bzip2, lzma, ncurses, uuid |
| Interpreters | python3 with pip and venv, nodejs with npm |
| Source and transfer | git, git-lfs, curl, wget, rsync, openssh-client, gnupg |
| Archives | tar, gzip, xz-utils, bzip2, zstd, zip, unzip |
| GitHub | the gh CLI |
| Linting | shellcheck |
| Diagnostics | jq, file, netcat-openbsd, dnsutils, iputils-ping, net-tools, lsb-release |
python3 and nodejs are a floor, not a choice about versions: setup-python
and setup-node still install what a workflow asks for and take precedence on
PATH. They are here so that a step which uses either without a setup
action first does not fail.
Deliberately absent: a JDK, and the other language runtimes with a good
setup-* action. Those install the version the workflow asked for, where this
image could only guess, at a cost of hundreds of megabytes of probably-wrong
version.
A large image is no longer cold-start time. A pool's image is prewarmed on every host that can run it and refreshed on a timer, so the pull happens in the background and a job finds the image already there. What a bigger image costs is disk on the host.
If you want something leaner, point the pool at an image of your own; Zoomies
only requires that it can run the entrypoint contract described in
deploy/runner-entrypoint.sh.
The tool cache¶
setup-python, setup-node, setup-go and the other setup actions unpack
what they download into the runner's tool cache. Both images set
AGENT_TOOLSDIRECTORY=/opt/hostedtoolcache (the path GitHub's own runners
use) and give that directory to the runner account. Left to its default, the
cache would sit under _work/_tool and go with the runner at the end of every
job.
That makes the tool cache something you can fill ahead of time. An image built
FROM one of ours that installs the versions your workflows ask for into
/opt/hostedtoolcache turns each of those setup steps into a lookup rather
than a download; the pool's image prewarm puts that image on the host before
any job needs it. To keep one tool cache between a pool's runners instead, see
keeping a tool cache.
The full image¶
ghcr.io/eyupio/zoomies-runner-full is that image built for you: the Docker
variant with the toolchains most builds reach for already in place, so the
first step of a job is its own work rather than a download.
| Toolchain | Versions | Where a job finds it |
|---|---|---|
| Python | 3.10 to 3.14 | the tool cache, for setup-python |
| Node.js | 22 and 24 | the tool cache, for setup-node |
| Go | the two supported releases | the tool cache, for setup-go |
| Java (Eclipse Temurin) | 17, 21 and 25; 17 is JAVA_HOME |
the tool cache, for setup-java |
| .NET SDK | 8 and 10 | /usr/share/dotnet, where setup-dotnet installs |
| Maven and Gradle | the current release of each | mvn and gradle on PATH |
| Rust | stable, with rustfmt and clippy |
rustup, cargo and rustc on PATH |
Each is laid out exactly as its setup action leaves what it downloads, so a
step asking for a version the image carries is answered from disk. Asking for
one it does not carry still works; that step downloads as it would anywhere
else. A version range such as 3.12 or 22 is matched against what the tool
cache holds, and the newest patch there wins. setup-dotnet still asks the
network which patch is current before it finds the SDK already installed, so
.NET saves the download, not the round trip.
It is published for the Ubuntu variants (24.04, 26.04 and 22.04) on both
architectures, under the same tags as zoomies-runner. The Python builds
setup-python installs are made for Ubuntu and nothing else, and an image
without them would be missing the toolchain most jobs ask for first. Point a
pool at it by name:
The versions are the newest release of each line when the image was built,
pinned by digest in deploy/toolcache.lock, and a weekly workflow moves them
forward. Each archive is checked against that digest before it is unpacked.
Most publishers state the digest; actions/python-versions does not, so a
Python archive's digest is the one recorded when it was first pinned, and an
archive that changes after that fails the build.
It is several gigabytes larger than zoomies-runner. That is disk on each host
that runs the pool, not queue time: the image is prewarmed on those hosts before
a job needs it, like any other pool image.
updates.check_interval: knowing the controller is behind¶
Once a day, the controller asks github.com which release of Zoomies is current. If that is not the release this controller was built from, the Overview's problems panel says so, at info severity, naming both versions and linking the release notes.
The check only ever tells you. It downloads nothing and restarts nothing.
What is done about a release it finds is the business of updates.mode, below,
which is off until a person who runs the process changes it. The upgrade stays
a decision you make.
The notice appears only on a controller built from a release tag. One built
from main (which is what the :dev and :main images are) is normally
ahead of the newest release, so telling it that a release is available would
be telling it to downgrade. It says nothing rather than something wrong.
This is the one request Zoomies makes to github.com that is not about your
fleet, and it goes there whatever github.api_base_url is set to, because the
releases of this software live on github.com whichever GitHub your runners talk
to. An air-gapped deployment, or a GitHub Enterprise Server one with no route
out, wants check_interval: 0, which switches off the check and the notice
together.
updates.mode: what is done about a newer release¶
Finding out that a release exists and installing it are separate decisions, and
the second is not made for you. updates.mode makes it, and only the platform
role can change it:
| Mode | What it does |
|---|---|
off (default) |
The info notice that a newer release exists, naming both versions and linking the release notes, as above. Nothing new is created and nothing is downloaded. |
manual |
Update buttons appear: for the controller on Settings → Updates, and on the card of each host whose update helper is installed. Nothing moves without a click. |
auto |
The controller takes the newest release once it has been public for updates.soak, and the hosts that have opted in then follow it, one at a time. The buttons stay, and mean "now, without the wait". |
Auto updates a machine only through its own update helper: while a release is due and the helper beside the controller is not installed, auto waits and updates no host either (where that helper cannot be installed, the controller is left to a person and hosts follow the release it runs). Hosts go one at a time, nothing starts while the controller is fenced, and a failed update halts the rollout until an administrator resumes or cancels it on the Hosts page (or with zoomies updates resume or cancel), where a running rollout can be cancelled too.
updates.soak is how long a release must have been public before auto takes
it, counted from when GitHub published it. A newer release restarts the wait, so
a release that is replaced within the soak is never installed: with the default
of 24 hours, a v1.3.2 that replaces v1.3.1 the same afternoon means v1.3.1
is skipped and v1.3.2 is taken a day after its own publication. The cost is
that a project publishing faster than the soak would never be updated by auto.
manual ignores the soak, because a person pressing the button is the soak, and
0 removes the wait, which under auto raises the updates.auto_without_soak warning.
auto is never silent: it raises the updates.auto notice, which the startup
output, zoomies config check and the Settings page all show.
updates.check_interval is still the air-gap switch. At 0 the controller never
learns of a newer release, so it is never updated from here, and the
updates.mode_without_check warning says so. Hosts are another matter: in
auto, hosts whose update helper is installed are still taken to the release the
controller runs once you upgrade it by hand, and in manual their buttons work.
An assistant connected over MCP cannot change either setting. update_settings
accepts updates.check_interval from this section and refuses updates.mode and
updates.soak, so no assistant, however it was prompted, can be the one that
turns updating on.
Neither setting installs anything on a host. A machine is updated only through the update helper its owner installed, and Updating from the web UI says what the helper does, what happens to running jobs, and how to roll an update back by hand.
Deployment models¶
zoomies init can run Zoomies three ways, and offers only the ones your host can
actually do:
| What it writes | When to pick it | |
|---|---|---|
native |
a hardened systemd unit (or a launchd plist) | leanest, starts fastest, and needs no container runtime for the controller itself |
compose |
docker-compose.yml and a populated .env |
easiest to upgrade and to move to another host |
docker |
an env file and one docker run |
fewest files, but you manage the run command |
Pass --deployment native|compose|docker to skip the question. Compose is the
default when a compose command is present, native otherwise.
There is a fourth way in, for hosts you do not install on at all: a platform
that builds with Nixpacks reads nixpacks.toml from the repository root and
deploys the controller from the source. It has no Docker socket, so it runs no
runners itself and agents join it from elsewhere -- Deploying on a
PaaS is the whole of it.
A controller's settings go into its database, not the generated .env:
before the container first starts, the installer stores the external URL, bind
address, TLS mode, trusted proxies, backend, capacity, work directory and log
settings through zoomies config import-env in a one-off container of the same
image, so the Settings page can change every one of them. The .env holds only
what opens the database (a freshly generated encryption key, the database and
state paths) and what Compose or docker run reads itself: the image tag, the
published port and the host's real docker group id. An agent, which has no
database, keeps its settings in its .env. Every variable carries a comment.
The file is 0600, written atomically, and a re-run reuses the existing
encryption key rather than minting a new one, which would render every
stored secret undecryptable. A deployment an older release wrote them into
.env for has them moved by zoomies upgrade.
zoomies uninstall reads back which deployment was used and tears down the
right thing: <compose> down for a compose install (offering -v, and saying
plainly that this destroys the database), stop and rm for a container. The
data volume is kept unless you say otherwise, and so is the .env, the only
copy of the encryption key that seals it: a reinstall over a kept volume reuses
that key and picks up where it left off. Removing the volume removes the .env
and its backups with it.
Behind Cloudflare (or any reverse proxy)¶
docker-compose.yml is set up for this: Cloudflare terminates TLS and proxies
to the origin over plain HTTP on port 80.
server:
bind: 0.0.0.0:8080 # the container port; compose publishes it on 80
external_url: https://zoomies.sh
tls:
mode: off # Cloudflare holds the certificate
trusted_proxies: [cloudflare]
Three things follow from that, and getting any of them wrong is quiet rather than loud:
- The external URL is
https://, nothttp://. It is what the session cookie'sSecureflag is derived from, what the webhook URL is built from, and what the UI links to. The container serving HTTP does not change any of that. It does mean the UI has to be opened through the https address: a browser on a plain-http page --http://<ip>to check the container is up -- throws a Secure cookie away, so Zoomies refuses to sign you in from one and says why, rather than signing you in and out in the same second. To test over plain http, setcookie_secure: falsefor the duration. trusted_proxiesmust trust Cloudflare. Write the wordcloudflareand Zoomies expands it to Cloudflare's published ranges, embedded in the binary. Without them the origin sees Cloudflare's address on every connection, so the audit log records Cloudflare for every action and the login rate limiter throttles the whole internet as one client. With them, Zoomies takes the address fromCF-Connecting-IP(which Cloudflare sets and a client cannot override) falling back to the right-most non-proxy entry ofX-Forwarded-For. The ranges move when the binary does, andzoomies initoffers a "Cloudflare in front" choice that writes the token for you.- Only Cloudflare should be able to reach the origin. Publishing port 80 puts an unauthenticated webhook endpoint on the public internet with Cloudflare merely in front of it, not in the way. Firewall the origin to Cloudflare's ranges, or use a Cloudflare Tunnel and publish no port at all.
Zoomies will warn at startup that it is listening without TLS. In this deployment that warning is expected, and it is the reason the warning says "if a proxy already terminates TLS, this is expected" rather than treating it as an error. The controller cannot see the proxy from behind it, so it has no way to tell this deployment from an origin genuinely exposed in the clear.
The web UI does not repeat it: bind.public_no_tls is printed at startup and
by zoomies config check, but it is left off the UI's problems drawer
and the Settings page, because a count that is permanently amber on a
correctly configured fleet is a count nobody reads.
agent.capacity¶
The maximum number of concurrent runners on this host. It is a hard ceiling the
scheduler respects; a pool's max_runners cannot exceed the capacity actually
available across matching hosts.
Default is half the CPU count, on the reasoning that a job usually wants more than one core and the host still has to breathe.
The setting is read when the host first enrols -- for the embedded host, the first
time the controller starts with a given database -- and not again: from then on the
host's own row holds the capacity, which is what an operator's edit changes and what a
heartbeat deliberately never rewrites. Change it afterwards on the host's card or with
zoomies hosts edit <id> --capacity N; editing agent.capacity and restarting does
not move a host that has already enrolled.
With scheduler.default_runner_limits on, the capacity also sets each runner's
share: a runner whose pool sets no cpus or memory_mb is given the host's
allocatable CPU and memory divided by this number, as a real cgroup limit. A
capacity that is more slots than the machine has cores, or than it has 2 GB of
memory for, is warned about as host.overprovisioned, with the largest
capacity that fits.
agent.bootstrap_cpu_grace¶
New runners keep their normal CPU allocation for 2m before host-pressure throttling can reduce it. This protects registration on a loaded host without removing the CPU or memory limits. A running container does not prove its GitHub listener is ready, so the grace is bounded by age, including after an agent restart. It also covers a job accepted during that window.
Set agent.bootstrap_cpu_grace (or ZOOMIES_AGENT_BOOTSTRAP_CPU_GRACE) to
0s for immediate throttling, or up to 10m for slow hosts. Restart the agent
after changing it. Configure standalone agents on their own hosts; the
controller's instance setting configures its embedded agent. Existing stored
values and environment overrides take precedence over defaults.
This is separate from runners.docker_wait: that wait now holds the host's
startup slot until the DinD daemon passes a docker info health probe, before
creating the runner. The fleet default is 3m, with a pool's
env.ZOOMIES_DOCKER_WAIT taking precedence (whole seconds, 1–3600).
0s at fleet level selects the 2m fallback; it does not skip readiness.
The outer 10m agent create budget and scheduler.provision_timeout still
bound provisioning, including image pulls and queue time respectively.
The grace is not extra capacity. If normal limits themselves are too small, reduce host capacity or assign the pool more CPU/memory. Changing these settings affects new work; it does not resize existing containers.
agent.finished_retention¶
A finished runner is removed from its host on the reconcile pass after the
controller acknowledges the runner's terminal report. The default is 0s:
GitHub's job result and Zoomies' runner history remain available, but the
stopped container's writable layer, its Docker-in-Docker sidecar and any
Zoomies-created scratch directory do not accumulate from job to job.
Set a non-zero duration only when you deliberately want a window in which to read the stopped container's local logs, for example:
This setting does not delete a workload before its final state is safe. If the controller cannot acknowledge the terminal report, the agent keeps the workload and retries. A completed GitHub job also triggers removal of its ephemeral runner, including failed and cancelled jobs; this explicit removal does not wait for the log-retention window. Pending host removals are recovered after a controller restart and retried until the host confirms success.
New Docker runner and Docker-in-Docker containers rotate their local logs at 10 MiB, retaining three files per container. This bounds log growth while a runner is active or waiting for removal. Existing containers keep their original logging settings until replaced.
agent.docker_build_cache_mb¶
Target size in MiB for unused build cache in the host Docker daemon. The default
is 0 (disabled). When explicitly enabled, the agent requests cache cleanup at startup and every five
minutes in a separate loop, so a slow Docker daemon does not delay runner
removal or heartbeats. Failed cleanup is logged and retried.
Set 0 to disable this cleanup, for example when another service manages a
shared daemon's cache. The environment override is
ZOOMIES_AGENT_DOCKER_BUILD_CACHE_MB; changing it requires restarting the agent.
Both standalone and embedded agents use this setting.
This targets Docker's own builder cache, including cache left by cancelled
host-socket builds. On a shared daemon it includes unused cache from other
builds. Docker protects cache currently in use, so the target is not a hard
disk quota. Separate Buildx builders using the docker-container driver, job
images, named volumes and arbitrary files created through the host socket are
outside this cleanup. Podman and process backends do not use this setting.
Docker-in-Docker build data is discarded with each runner's sidecar instead.
The scheduler also stops placing work when the host's reported free disk falls
below its reserve (at least 2 GiB). That reading covers agent.work_dir's
filesystem; a separate Docker data filesystem and disk written by jobs already
running still need enough capacity.
agent.registry_auth¶
A base64 X-Registry-Auth value the container backends send when they pull an
image: the JSON {"username":"...","password":"..."} that docker login
writes, base64url-encoded. Empty means anonymous pulls, which is right for
public images.
Set it when a pool's image lives in a private registry. Without it such a pool
cannot use pull_policy: pinned-only at all; the pull it needs is the one the
registry refuses, and the runner never starts.
It is a credential, so prefer ZOOMIES_REGISTRY_AUTH in the service's
environment file to a value written into zoomies.yaml, which is world-
readable on plenty of hosts.
agent.docker_host¶
Empty autodetects, preferring a rootless socket, in this order:
$DOCKER_HOST$XDG_RUNTIME_DIR/docker.sock/run/user/<uid>/docker.sock~/.docker/run/docker.sock(Docker Desktop on macOS)/var/run/docker.sock
The group a service account must join to reach a root socket is read from the
socket itself, not from the name docker. In a containerised deployment that
gid is DOCKER_GID in the environment file: group_add reads it, the compose
files also pass it into the container's environment, and zoomies init checks
it against the socket before the container is started -- a container is given
a group when it is created, and no usermod on the host can add one
afterwards. A socket that exists but cannot be opened is diagnosed rather than
guessed at: the agent reports the account it runs as, the group that owns the
socket, and whether that account is already a member. If it is, the fix is a
restart of the agent and not another usermod -- supplementary groups are
fixed when a process starts. In a container it reports the group the container
holds against the one it needs, and names the DOCKER_GID line to change and
the docker compose up -d that recreates the container.
agent.labels and pool host selectors¶
Agent labels describe a host; a pool's host_selector requires them. Every
key/value in the selector must match.
A join token can carry labels too, and those win. Labels decide which pools' work a host is offered (and therefore which pools' runner registrations it is handed) so what the operator minting the token pinned is not something the machine being enrolled can talk its way out of. Keys the token says nothing about are still the agent's to declare.
Re-joining a host¶
Joining with the name of a host that already exists replaces it: the row keeps its ID, and the previous registration's runner records go with it. That needs the agent token the previous registration was issued, which the agent sends from its credentials file automatically. A rebuilt machine with its state directory intact therefore re-joins with no ceremony.
If the credentials really are gone, delete the host first:
zoomies hosts delete host_xxxxxxxx
zoomies agent join https://zoomies.example.com --token <join-token>
A join token on its own is deliberately not enough, otherwise anyone trusted to enrol one machine could seize any other machine by naming itself after it.
An empty selector matches any host, so once a specialised machine joins, give the general pools a selector of their own, otherwise they are eligible for the GPU box too. Hosts and pools works that shape through.
agent.runner_sha256 and the process backend's download¶
The process backend downloads the actions/runner release itself, and it
refuses to install an archive it cannot verify. Zoomies ships the SHA-256 of
every archive for the release it pins by default, so the common case needs no
setting at all. Pin a different release with github.runner_version and give
its digest here -- every actions/runner release lists them in its notes -- and
the download is verified against that instead.
agent.allow_unverified_runner_download accepts an archive with no known
digest. It is warned about at startup, because it means executing whatever the
network handed over. agent.runner_download_url points the download at an
internal mirror that keeps GitHub's release layout underneath it.
scheduler.scale_up_delay¶
How long a job must have been queued before it counts as demand. 0s reacts
immediately, which is what you want most of the time. Raise it if your workflows
arrive in bursts that resolve themselves and you would rather not churn runners.
scheduler.max_runner_lifetime¶
Drains a runner that has lived this long, the next time it is not busy. It
bounds how long a persistent runner's state and credentials live, so a pool that
keeps a minimum re-registers its runners this often. It never interrupts a
running job: a job that hangs keeps its runner busy, and ending that is what the
workflow's timeout-minutes is for. A runner that never finished registering is
provision_timeout's to fail, not this setting's.
scheduler.provision_timeout¶
Fails a runner that has been starting up this long without registering. It
covers provisioning and registering together (there is no second
"registration timeout") and the runners.not_progressing problem is raised at
half of it, so the fleet says something while there is still time to look.
It is the last of three bounds on a runner's start and the only one that gives
up, so it has to be the most patient of them. An agent allows itself fifteen
minutes to materialise a runner, because a first pull of a large image on a slow
link is minutes rather than seconds, and a runner on a pool that provides Docker
then waits up to runners.docker_wait for that daemon before it registers at
all. Set inside those two, the scheduler condemns runners the rest of the system
is patiently still making, and the pool replaces each one, which puts a second
pull of the same image on the link that was slow to begin with. A cold fleet is
where that bites, and a cold fleet is the one least able to absorb it. The
validator warns when the number is inside them.
The default of 20m is not the cost of a broken runner. A create that actually
fails (a missing image, a registry that refuses, a backend that will not
start) is reported by the agent and fails the row the moment the report lands.
This is the backstop for the create that is never reported at all, which is
worth being patient about because nothing else will notice it.
scheduler.drain_timeout¶
Fails a runner that has been draining this long with no job left on it.
A drain asks a runner to finish what it is doing and stop, and the agent carries that out. The queue those instructions live in is in memory on purpose, so a controller restart drops one that had already been issued. The row is left in draining, which is a state nothing else counts against: it holds its slot on the host, its pool sits one runner short, and both stay that way for as long as the controller runs. This bounds it; the runner is failed, so an operator sees it and its slot is taken back.
It does not touch a runner still finishing a job, however long that takes.
Waiting is what a drain is, there is no maximum job duration in Zoomies by
design, and a build ended from here would look to its owner like a failure with
no cause. Ending a job that hangs is the workflow's timeout-minutes.
0s leaves a drain unbounded, which is the behaviour before this setting
existed.
scheduler.default_runner_limits¶
Whether a runner whose pool leaves cpus or memory_mb unset is created with
one slot's share of its host's allocatable machine as a cgroup limit on that
field; the same share the scheduler already charges it. On by default.
The charge was always there; what was missing was the limit. A pool with no
limits was charged a slot's worth of the host, so the books balanced, and its
runners were created with no cgroup limit at all, so eight of them could each
take every core while the books said everything fitted. That is how a host's
Docker daemon stops answering, and it is what this setting prevents. The share
is given only where it would bind (a container backend whose daemon has said
it can apply the limit, on a host that has reported its size) and a pool's
own limits win on every field it set. Where a runner's limits came from is on
its page as allocation_source, and an out-of-memory kill on a defaulted
limit says so, with the two ways out. Default
allocations has the rules in full.
false restores unlimited containers for pools that set no limits, and raises
scheduler.default_runner_limits_off at startup and in the problems drawer
for as long as it stands. Set cpus and memory_mb on every pool if you turn
it off; a fleet whose pools all set their own limits is unaffected either way.
scheduler.host_auto_adjust¶
Whether the controller keeps hosts at the figures Adjust recommends. On a host's card, Adjust shows how many runners the machine can hold, and what it should keep for itself, worked out from its CPUs, memory and disk and from what a runner in this fleet asks for; Set to recommendations puts the numbers there. With this on, the controller does that by itself and keeps doing it: the capacity (how many runners), and the CPU, memory and disk reserves, are re-worked as the host reports a different machine and as the pools' runner sizes change.
It exists for hosts that nobody sized. A rented machine with a small disk and a capacity typed for a bigger one fills its disk and fails jobs part-way through. The recommendation for the number of runners is the smallest of what the CPUs, the memory and the disk allow (the disk gets 10 GiB of working room per runner beyond its reserve), and the page says which one set it.
On by default, so a machine nobody sized does not fill its disk. It replaces figures an operator typed, so a fleet that sizes its hosts by hand turns it off here, or sets the hosts it wants left alone to Never adjust in Adjust, where Follow the fleet setting, Keep at recommendations and Never adjust choose. This covers hosts a provider rents. Care is taken not to surprise anyone:
- A host at capacity 0 is paused, and is never un-paused. Only a person does that.
- A host that has not reported how big it is, or has not been heard from lately, is left alone.
- A move is audited under the system's name, and a host is moved at most every five minutes, so a pool being edited does not make a host's capacity flap.
- A change that would leave a pool with nowhere to run is refused, as it is for a person, and the host is left as it was.
- Running jobs are never stopped. Lowering capacity only stops new runners starting until the host is under it.
scheduler.host_throttling¶
Whether the controller steps a host down when its measurements say it is overwhelmed: fewer slots, and a lower CPU quota on the runners already on it, climbed back up one rung at a time after a stretch of calm. On by default.
The pressure holds refuse every new start while a host's CPU or memory is acutely short, and release the moment a sample says otherwise; a host that is overwhelmed on and off for an hour spends that hour being let back in at full capacity and given a fresh runner each time. The throttle is what outlasts a sample. Each rung takes a quarter of the host's slots and, down to half, a quarter of every runner's CPU quota; it climbs every two minutes while the pressure keeps coming back, to a top rung of three, and comes down after five minutes of calm. A running job is never slowed below half its allocation, so a throttle slows work and never fails it. Current usage and automatic holds has the ladder, what ends a throttle, and which hosts can be throttled at all.
false raises scheduler.host_throttling_off, and a throttle standing when
the setting is switched off is lifted rather than left on a rung nothing will
ever step down. Leave it on unless something outside Zoomies manages the
hosts' load.
scheduler.auto_rerun and scheduler.auto_rerun_limit¶
Whether a job this fleet broke is sent back to GitHub without anybody asking. Off by default, and the default is the recommendation for most deployments: the button on the job does the same thing when a person has looked at the failure and decided, whose failure was it? has it.
A runner that dies under a job (the host rebooted, the container was killed
for its memory limit, the daemon stopped answering) fails that job in a way
that is, on GitHub, indistinguishable from a test failure. The job did not
fail on its merits, and the ordinary remedy is to run it again. With
auto_rerun on, Zoomies asks as soon as GitHub reports the job over.
It is narrow on purpose:
- Only the fleet's own failures. The same split the Jobs page shows: a job Zoomies has confessed to breaking. A test that failed is never re-run.
- Only while GitHub's attempt number is at or below
auto_rerun_limit. That is what bounds a fault the fleet causes every time (a host out of disk, an image that will not pull) rather than re-running the same job until somebody notices the bill. The count is GitHub's own run attempt, so a controller restart does not reset it, and a re-run an operator asked for by hand counts against it too. - Only once per completion. A duplicate delivery does not change the job's state, so it never reaches the re-run.
Each one writes a line on the job's timeline, marked via recovery rather
than as an operator's doing, and counts
zoomies_job_reruns_total{trigger="fleet_fault"}.
Turning it on raises scheduler.auto_rerun_on, which is a warning rather than
an error: it is not a weaker security posture, it is a standing permission to
spend the installation's GitHub minutes. A job that got as far as running may
also have had side effects outside GitHub that its author expected to happen
once (a deployment, a published package) and Zoomies cannot know which.
runners.default_cpus and runners.default_memory_mb: how big a runner is¶
These two are where a pool's size sliders open when somebody chooses to set a fixed size. They are not what a pool with no size becomes: such a pool is given one slot's share of whichever host each runner lands on, which is the sizing most fleets want and what a new pool does.
They are also the size of a runner on a host that names none, for a pool that
takes its size from its hosts (size_from_profile): that pool is given the
standard size of each host's runner
profile, and
these two stand in for every host whose profile leaves the field out. The
controller names the hosts where that is happening
(pool.profile_default), because a fleet that meant different sizes on
different machines and got the same one on all of them has not got what it asked
for. A pool the controller keeps for a size class is not named
there: its runner on such a host is the class's.
The distinction matters because the two behave differently as a fleet grows. A share follows the machine (3.8 cores on a 16-core box with four slots, 7.6 on a 32-core one) so one pool is sized correctly on every host it reaches. A figure typed here is the same everywhere, so it fits the host it was chosen for and strands the machine on the ones that joined later.
What neither of them is, is no limit. A runner with no cgroup limit can take
every core and all of the memory on the machine it lands on, which is the shape
that stops the Docker daemon answering, times out every other create on that
host and reads on the Hosts page as a machine that is merely busy. The share is
applied as a real cgroup limit by scheduler.default_runner_limits, which is
on by default; with it off, a pool that names no size is charged the share and
given nothing, and the controller says so (pool.size_unlimited).
Two cores and four gigabytes suits most fleets, and a fleet of small boxes or of compilers is entitled to say otherwise once here rather than on every pool it creates. Changing them does not touch the pools that already exist, their figures are their own, and the recommended capacity on the Hosts page follows whichever is larger: what the enabled pools ask for, or this.
runners.minimum_cpus and runners.minimum_memory_mb: the least a runner will take¶
A fixed size is a standard, and machines are rarely exactly the size a standard was chosen for. A pool that asks for 32 GB has nowhere to run on a 30 GB host, and its jobs wait for a machine that is never coming. A minimum is the size the pool will still accept: when no host has room for the standard, the runner goes on the host that can spare the most, and is given as much of the standard as that host can spare, never less than the minimum. On the 30 GB host above it gets 30 GB, not 24.
An automatic pool has a standard too: one slot's share of whichever host a runner lands on. A host with a free slot but less than a whole share left (because other pools' runners hold more than a slot's worth of it) used to leave an automatic pool's job queued however much of the machine was idle. With a minimum, the runner takes what that host can spare instead, never less than the minimum; without one, an automatic pool still waits for a whole share, and the pool's page says so and names the minimum as the fix. A docker-in-docker slot is never cut below what a runner and its daemon need between them.
A host can have a minimum of its own in its runner profile, and the larger of the two applies to each field on that host: the host's figure is a floor under the pool's, and this setting is only what a pool and a host that say nothing follow. A host never lowers a minimum, and never raises one past a size a pool states; that pool is not used on the host at all.
The minimum is the least a runner is given, never the most. Where a host's slot share is larger, an automatic runner gets the whole share. Where the share is smaller (a 16 GB host cut into eight slots is 2 GB a runner) the runner is given the minimum instead, and charged it, so the host holds as many runners as its machine covers rather than as many as it has slots: four of a 4 GB minimum there, not eight thin ones and not none. A runner killed for want of memory on an automatic pool is fixed by raising the pool's minimum memory; the pool's page says which hosts hold fewer runners because of it. A docker-in-docker runner's minimum is per container, so its slot is raised to twice it and split.
The standard still wins wherever it fits. A minimum never shrinks a runner the fleet could have given the full size to; it only turns "no host has room" into a job that runs. Such a runner is recorded as reduced (the Runners page says what it was given against what its pool asks for, and its create's reason says why) and its host is charged what it was given, not the standard.
A host a pool runs on only at its minimum counts as one of the pool's hosts
everywhere a host is judged. The pool's room counts the runners the scheduler
really places there (as many at the standard as fit, then one more given what
is left if that covers the minimum) at the size they are given, so
pool.max_above_room and pool.host_overcommitted do not warn about room the
pool is using. host.overprovisioned judges a slot against the smallest
minimum every pool that reaches the host accepts, rather than a core and 2 GB,
and a pool that cannot be placed is told which resource is short of its
minimum, not of its standard.
These two settings are the fleet's minimum, and every pool that has not set one
of its own follows them, live. A pool's own minimum, set in the Size section of the pool editor or
as resources.min_cpus and resources.min_memory_mb, wins on the field it
sets; where it is 0, the pool's minimum is the fleet's, so changing these
settings moves every such pool on the next scheduling pass, with nothing
rewritten. The two fields are independent: a pool can set its own memory
minimum and still take the fleet's CPU one.
A fleet minimum at or above a fixed pool's own standard means nothing for that pool and is ignored there; a minimum is only a minimum below the size it is a floor under. On an automatic pool it applies as it stands: below a host's slot share it is the floor a runner there can be reduced to, and above it, it is what each runner there is given.
A pool's 0 therefore means the fleet's, not none. There is no per-pool
"no minimum" while the fleet sets one: a pool that must never run below some
size gives its own figure; its standard, if it should never be reduced at all.
Both settings default to 0, so a fleet that has never set them behaves exactly
as it did before they existed. The pool's page shows the minimum in force and
marks an inherited one (fleet default); the API returns it as
effective_minimum, beside resources, which keeps the pool's own figures so
that saving or exporting a pool never freezes the fleet's value into it. A
minimum is held to the same floor any runner is: a quarter of a core and 512
MB.
How big a runner is
has what the pool editor does with them, and what it checks the answer against.
agent.extra_ca_file: behind a proxy that re-signs TLS¶
A network whose proxy intercepts TLS presents its own certificate for every site, signed by the organisation's root CA. Until a runner trusts that CA, nothing in a job reaches GitHub, a registry or a package mirror. This names a PEM file on the agent's host holding that CA; it is a host setting, not a pool one, because the proxy belongs to the network the host sits on.
When it is set, the Docker and Podman backends mount the file read-only into
every runner, and the runner image's entrypoint adds it to the system trust
store before the runner starts listening: update-ca-certificates on Ubuntu
and Debian images, update-ca-trust on the DNF family, using the passwordless
sudo the image already gives the runner user. That covers git, curl, OpenSSL
and the package managers. The entrypoint also sets NODE_EXTRA_CA_CERTS (the
runner and every JavaScript action are Node, which carries its own roots) and
points REQUESTS_CA_BUNDLE and SSL_CERT_FILE at the updated system bundle
unless a pool already set them. A docker-in-docker sidecar, which is what
pulls a dind job's images, gets the same mount and trusts it through
SSL_CERT_DIR, keeping its own roots too.
A custom image with no sudo, or without either trust store tool, still gets
NODE_EXTRA_CA_CERTS and logs a warning that the rest of the job will not
trust the CA; add it to that image's trust store when you build it.
The validator refuses a path that is relative, unreadable or holds no PEM
certificate (agent.extra_ca_invalid), and a good one is reported once as
information (agent.extra_ca), because every job on the host now trusts
whatever that CA signs. The process backend ignores the setting: a bare
runner is a process on the host and already uses the host's own trust store,
so add the CA there.
The setting is about runners. The controller and agent processes themselves
are Go, and reach GitHub through the proxy by trusting its CA the standard Go
way (SSL_CERT_FILE=/etc/zoomies/acme-root-ca.pem replaces the system roots,
SSL_CERT_DIR adds a directory to them) or simply through the host's trust
store, which Go reads by default. The runner image's settings are not needed
for that and do not provide it.
runners.docker_wait and runners.env: what every runner starts with¶
Both are fleet-wide and both are live: the controller writes them into each
runner's environment when it builds the create task, so a change on the
Settings page is in the next runner without anything restarting. A pool's own
env is layered over them, so the fleet says what is usual and a pool says
what is different.
A value can contain a comma, as NO_PROXY does. In YAML or JSON nothing needs
escaping. Where the setting is written as one line of text (ZOOMIES_RUNNER_ENV,
zoomies config set, and the row the database keeps) items are separated by
commas, so write a comma inside a value as \, and a literal backslash as \\:
ZOOMIES_RUNNER_ENV="HTTPS_PROXY=http://proxy:3128,NO_PROXY=localhost\,.internal".
The same rule applies to every other list or key=value setting, such as
oidc.admin_groups and agent.labels. A backslash before any other character is
left as it is, so text written before this rule existed reads the same. A row the
database already holds in the broken form has to be set again.
runners.docker_wait is how long a runner on a pool with a docker_mode
waits for that daemon before it refuses to take a job. It reaches the runner
image as ZOOMIES_DOCKER_WAIT, in whole seconds. The fleet default is three minutes; the image and backend fallback for an
unset override remain two minutes: dockerd in a fresh
docker-in-docker sidecar sets up its storage driver and firewall rules before
it listens, and on a host that is also extracting images for the runners
queued behind it that takes longer than the thirty seconds an earlier default
allowed. A runner that gives up exits with code 69, and the Runners page says
so and points here. The image refuses anything outside one second to an hour,
so the validator refuses it first (runners.docker_wait); 0s leaves the
image's default.
Setting ZOOMIES_DOCKER_WAIT on the controller itself overrides
runners.docker_wait the way any ZOOMIES_* variable overrides its setting,
and a bare number there (120, not 120s) is read as whole seconds rather
than refused: that spelling named the runner image's own wait for years
before this setting existed, on a pool's env and often on the controller's
too, and an upgrade should not stop over a value that already meant something.
runners.env is for the variables every job on every pool needs; a proxy, a
package mirror, a GOFLAGS. Every job can read them, so a credential does not
belong there: a pool's env narrows the audience to that pool's jobs, and a
GitHub secret narrows it to the workflow. The variables the controller writes
for each runner individually (its name, labels, group and credentials) are
refused (runners.env_reserved), because one value for the whole fleet is
wrong for every runner in it. A pool's env is held to the same rule, and
the pools API answers a 422 on env when one of those names is set or
changed; a pool that already stores one is not refused for an unrelated edit, but
its value cannot be changed.
How a runner picks up a proxy¶
A runner inherits HTTP_PROXY, HTTPS_PROXY and NO_PROXY (in both
spellings, since curl reads only the lowercase ones) from the agent that
starts it. That holds on every backend: the bare-process runner, the Docker and
Podman runner container, and a pool's docker-in-docker sidecar, whose daemon
pulls the images a job's builds name and needs the proxy as much as the runner
does. Nothing else from the agent's environment reaches a runner.
A host that can reach GitHub only through a proxy has its agent configured with one already (it could not reach the controller or pull an image otherwise) so setting the proxy on the agent's service is usually all a proxied host needs. Where that is not what the runners should use, the more specific setting wins, variable by variable:
- a pool's
env, runners.env,- the agent's own environment.
Setting a variable to an empty string in either is how a pool, or the whole
fleet, sends its runners direct although the agent uses a proxy. The sidecar
takes the same proxy variables as its runner and none of the pool's other
env, which are the job's rather than the daemon's.
NO_PROXY needs nothing added for Zoomies itself: a runner never talks to the
controller, and the docker-in-docker daemon is reached on 127.0.0.1, which
proxy-aware clients already go to direct. Add your own internal registries and
package mirrors to it as you would for any other machine behind the proxy.
images.refresh_interval: keeping a moving tag current¶
Every pool's image is prepared on a host by prewarming it, and prewarming is
otherwise triggered by exactly three things: creating a pool, editing one, and
POST /pools/{id}/prewarm. None of those happen on their own, so a pool that
names a tag which moves (and the default
ghcr.io/eyupio/zoomies-runner:dev moves on every merge to main) would
reach a host once, at the first job it ever ran, and keep that image for as long
as the host lived.
This setting is what re-runs the prewarm. Every interval, each pool's image is prewarmed again on every healthy host that can run the pool, which pulls only when the tag has actually moved.
It is deliberately not the same thing as a pool's pull policy:
| Decides | Costs | |
|---|---|---|
pull_policy (per pool) |
what happens when a runner is created | a registry round trip in front of the job, which is the queue wait ephemeral runners exist to avoid |
images.refresh_interval |
how often the fleet catches up in the background | bandwidth, on nobody's critical path |
So the pairing that gets both a fast start and a current image is the default
one: if-not-present on the pool, and an hourly refresh here.
Runners that already exist are never touched, and neither is the image any of them was created from; a container keeps the image it started with until it is replaced. What changes is the image the next runner is created from.
Set it to 0 to switch it off. That is the right answer for an air-gapped
fleet, or one that pins every pool to a digest, and Zoomies says so once at
startup rather than leaving you to wonder.
provider.max_machines: the ceiling on what the fleet may rent¶
provider.enabled decides whether Zoomies may rent hosts at all. It is off,
and it stays off until somebody deliberately turns it on, because every machine
behind it is a bill.
It applies at once. The machine loop runs whether or not it is on, so switching
it on from the Settings page needs no restart: the loop is woken to act on it
straight away. Switching it off again stops the whole loop, which is more than
stopping the buying: machines already rented are not drained, deleted or checked
while it is off. To stop buying and keep tending what is rented, use
provider.paused (Pause new machines)
instead.
max_machines is the ceiling across every provider put together, and it is the
one number to set in the same edit as enabled. Each provider row carries its
own limit as well, but those bound one hypervisor each; this is what bounds a
mistake; a provider configured twice, a demand signal that never settles, a
pool whose jobs nothing can run so the shortfall never closes.
Zero rents nothing, exactly as a pool's max_runners of zero runs nothing,
and the validator says so rather than letting a fleet look enabled and do
nothing. That is deliberate: the alternative reading, where an unset number
means "as many as it takes", puts the one setting that decides the size of an
invoice behind a value somebody can forget.
max_creates_in_flight is the other half: how many machines may be being built
at once. A burst of two hundred queued jobs should not become two hundred
simultaneous clone requests, whatever the ceiling allows in total.
provider.paused: the kill switch¶
Holds the creation of new machines while leaving everything else running: machines already up keep working, idle ones still drain, drained ones are still deleted, an operation whose outcome was unknown is still resolved, and ownership is still verified. That asymmetry is the point. A switch that stopped deletion too would leave VMs running with nothing tending them, which is the opposite of what somebody reaching for a kill switch wants.
The same switch exists per provider, as a row, and that is the one the Hosts page presses, so one misbehaving hypervisor can be held without stopping the rest. This setting is the fleet-wide version, in the file, for the case where a restart should come back held.
provider.delete_grace: how long a quiet machine is left alone¶
A machine's host is counted unhealthy after 90 seconds without a heartbeat and lost after five minutes. This is how much longer still a machine is left alone before the fleet treats it as gone, and it must comfortably outlast both; a machine destroyed for a network blip takes the job it was running with it, and that job's owner sees a failure with no cause. The validator warns when it is set at or below the 90 seconds, which is the setting most likely to be tuned down by somebody impatient with a slow scale-down.
idle_timeout and scale_down_cooldown are the other end of the same
question: how long a machine's host must have been empty before it is drained,
and how long that emptiness must hold continuously before anything is deleted.
Set the cooldown below one idle period and a fleet pays the creation cost again
in every gap between two bursts.
kennel.enabled: what Kennel Club reads and what it spends¶
Kennel Club looks at the repositories your fleet serves and says which of them could hurt it or stop its CI: a public repository whose jobs run on a pool a stranger's pull request could damage, a job waiting for a label no pool serves. It is off by default, and off means off. Nothing is read from GitHub, nothing is stored, and AI Context carries on exactly as it was.
What it asks of GitHub is held to a rule and not left to hope. It never starts a
read for an installation GitHub has rate-limited, it spends at most
api_budget_percent of the limit that installation last reported, and it stops
altogether when less than half of that limit is left. A read GitHub answers with
"not modified" costs nothing against the limit, so a refresh that finds nothing
changed is free. A private repository costs no read beyond the repository
listing Zoomies already makes.
refresh_interval is how stale what it read may get. What your own fleet
observed (which pool ran a job, which label nothing served) is not subject to
it: that is re-checked as the fleet changes, and costs GitHub nothing.
disabled_checks turns off a check by its code, such as
exposure.fork_code_ran, or a whole area, such as capacity. A misspelt name
is refused when it is saved, because a name that matches nothing would leave
running the check you meant to turn off. A check that is turned off is shown as
turned off, not hidden, so nobody reads its silence as a clean bill of health.
A repository with every check turned off is never given the "Best in show"
badge.
Repository setup advice¶
Set kennel.repository_setup: true to add a default-branch file inventory to
Kennel Club. It is off by default, including for installations already using
Kennel Club. It does not grant or request App permissions. Private repositories
need Contents: read, which a new App asks for only if you answer yes to
Also let Kennel Club read repository files? in the installer or the Connect
GitHub dialog, and which you can add to an App that exists on its Permissions &
events page; GitHub holds the change until the account's owner accepts it. A
denied, hidden or unavailable tree is a coverage gap, never a claim that a file
is missing.
| Check | What its absence makes harder |
|---|---|
setup.readme |
Understanding the project and finding build, test and run instructions. |
setup.licence |
Understanding permitted use and redistribution of a public project. |
setup.security |
Finding a private vulnerability-reporting route and supported versions. |
setup.contributing |
Preparing and validating a contribution to a public project. |
setup.code_of_conduct |
Finding participation expectations and an enforcement contact for a public project. |
setup.issue_template |
Collecting useful reproduction details for a public project. |
setup.pull_request_template |
Recording the purpose and validation of a proposed change. |
setup.codeowners |
Routing review requests to the maintainers of workflows and other files. |
setup.dependency_updates |
Maintaining dependencies where a recognised root manifest or Actions workflow exists. |
setup.workflows |
Running repository-local GitHub Actions validation. |
These are informational findings: they do not lower a repository's standing.
Turn off individual checks, or the whole setup area, with
kennel.disabled_checks. Turning off every setup check stops its inventory
reads. Licence, contribution, conduct and issue-template advice applies only to
public repositories.
This is a presence check, not a content audit. It recognises non-empty regular files in documented locations; it does not validate their contents, ownership rules, licences, workflow triggers or branch enforcement. Community-file findings explicitly say repository-local: account defaults may provide the guidance, and external services may provide CI or dependency updates. Confirm those arrangements before adding duplicate files. Empty files and symlinks do not satisfy these checks.
Each refresh takes one conditional Git tree request per repository, behind the same budget and rate-limit holds as other Kennel Club reads. No file contents are fetched or stored. Inspection stops at 10,000 entries; a truncated or capped tree produces incomplete coverage and no missing-file findings. The retained facts are recognised presence flags, not repository paths or text.
GitHub's tree API describes the Contents permission and truncation behaviour. Account community defaults explain why a local file's absence is advice rather than proof of missing setup.
Workflow best-practice checks¶
Set kennel.workflow_checks: true to inspect the contents of default-branch
workflow files. This is a separate opt-in from the setup inventory, off by
default, and does not grant App permissions or edit any repository files.
Private repositories need Contents: read, which a new App asks for in the
same way as the setup inventory above does.
| Check | What it detects | Severity |
|---|---|---|
ci.no_timeout |
Executable job declarations without timeout-minutes. Reusable-workflow callers are excluded; a declared expression is not treated as missing. |
Warning |
ci.no_concurrency |
Workflows triggered only by pull-request events without cancelling concurrency at workflow level or on every job. Mixed push/PR, release and deployment triggers are excluded. | Info |
ci.action_not_pinned |
External action or reusable-workflow references without full commit pins, and Docker actions without image digests. Local actions are excluded. | Warning; info if only actions/* or github/* references are affected |
token.permissions_unset |
Job declarations that inherit permissions without a declaration at workflow or job level. An explicit empty permissions map is a declaration. | Warning for public repositories; info otherwise |
These findings report counts across the files successfully inspected, not runtime or matrix-expanded job counts. They use fixed advice and retain no job names, file paths, expressions or workflow contents in the stored evaluation. Token advice does not claim the inherited default is writable unless the repository settings checks are on and have read it: with them on, a workflow that sets no permissions is not reported while the default token is read-only, and says so when it can write. A declaration also does not prove least privilege.
Reads are pinned to immutable Git blobs selected from the default-branch tree. Every tree and blob request passes through the installation budget and holds. A refresh inspects at most 50 workflow files, each at most 256 KiB; the tree inventory is capped at 10,000 entries. YAML inspection is limited to 20,000 nodes and 50 levels, and rejects aliases, anchors, merge keys, duplicate keys, multiple documents and unsupported job shapes. A malformed, oversized, truncated or unreadable file leaves incomplete coverage. Findings from other successfully read files remain visible, but an incomplete inspection cannot earn an all-clear. The raw YAML is never executed or persisted.
Turn individual checks off with kennel.disabled_checks, or disable both ci
and token to stop workflow reads. Changing the switch takes effect on the
next pass; enabling it starts its first read without waiting for the ordinary
refresh interval.
GitHub workflow syntax defines timeout, concurrency and permission declarations. Secure use of Actions explains commit pinning. These checks advise rather than edit: a safe timeout, cancellation policy and permission grant depend on the project.
Enforced reviews, required status checks and repository token defaults still need a separate repository-settings reader. File or workflow declarations alone cannot establish those properties.
Pool settings¶
Pools live in the database, not in the config file; they are created in the UI, the CLI or the API. These are their fields:
| Field | Meaning |
|---|---|
name |
Unique. Appears in scaling reasons and in the UI. |
installation_id |
Which GitHub App installation this pool registers against. |
labels |
What runs-on must ask for. Normalised to lowercase, and always includes zoomies, which Zoomies adds to every pool. |
runner_group |
GitHub runner group. New organisation pools default to the managed zoomies group; set an explicit name or an empty value to choose another group or GitHub Default. Repository targets have no runner groups. |
backend |
docker, podman or process. |
image |
Runner image for the container backends. Changing it replaces the pool's idle runners: one made from the old image is drained and a new one takes its place, and a busy one finishes its job first. |
pull_policy |
if-not-present (the default), always, or pinned-only, which refuses to run anything but the digest the pool names. |
platform |
The machine these runners need: os (a distribution, not a kernel), os_version and arch. It picks the runner image, and it keeps the pool off hosts running something else. Every field is optional; an empty one constrains nothing. See Naming and platforms. |
runner_version |
Pin an actions/runner release instead of tracking the latest. |
min_runners |
Kept warm even with nothing queued. 0 is usually right. |
max_runners |
Hard ceiling. Always set this; it is your backstop against a runaway workflow. |
repository_scale_up_limit |
Best-effort limit on new capacity attributed to one repository; 0 disables it. This is a creation throttle, not a strict concurrency or isolation boundary: GitHub can assign any matching queued job to an existing compatible idle runner. Strict isolation requires repository-specific pools and corresponding repository-specific runs-on labels in workflows. |
priority |
Higher-priority pools are given creation capacity first when the fleet cannot satisfy every pool at once. Pools at the same priority share it fairly. Under scheduler.max_creates_per_tick, a lower-priority pool whose oldest queued job has waited a full scheduler.interval is first given one create, so a busy top tier cannot starve it; the higher pool's scaling reason then says it was deferred for fairness across priorities. |
idle_timeout |
How long an idle runner waits before being drained. |
ephemeral |
One job per runner. Leave it on. |
no_default_labels |
Off. On, runners register with only the pool's labels (no self-hosted, operating system or architecture) as config.sh --no-default-labels does, and a job asking for one of those no longer matches the pool unless it lists it. Only a non-ephemeral pool may set it: GitHub adds those labels to every just-in-time runner itself. See Leaving out the default labels. |
docker_mode |
none, dind, or host-socket. Anything but none switches a pool on the stock runner image to its Docker variant, under the same tag, see below and security.md. |
resources |
cpus, memory_mb, disk_gb, pids_limit per runner. Leaving cpus and memory_mb out is how a pool says “the host decides”: each runner is then given one slot's share of whichever machine it lands on, charged against that host and applied as a real cgroup limit. Set them for the same size on every host. disk_gb is advisory, enforced only where the backend can, and independent of that choice. |
cpu_burst |
The elastic CPU policy: mode is off, observe (decide and publish metrics, move nothing) or automatic (lend the host's spare CPU to a busy runner above its guarantee), and max_cpus is the most one runner may be lent up to, with 0 meaning the host's allocatable CPU. Needs automatic sizing on docker or podman; a new pool that qualifies starts on observe, an existing one stays off. See Elastic CPU zoomies. |
memory_burst |
The elastic memory policy: mode is off, observe (decide what would be lent and record it, change nothing) or automatic (raise a running container's memory limit out of memory the host has not promised to a runner), max_memory_mb is the most one runner may hold, its own share and what it is lent together, with 0 meaning half as much again as it starts with, and spill_mb is the swap each container may use as the last resort, with 0 allowing none. Needs the docker or podman backend; a new pool on one starts on observe, an existing one stays off. The only setting of a pool the controller keeps that is yours beyond its warm count, cap, pause and idle timeout. A host's runner_profile.standard.burst_max_memory_mb can lower the ceiling and never raise it. |
runner_settings |
The fleet timings this pool overrides: provision_timeout, drain_timeout, max_runner_lifetime, scale_up_delay and docker_wait. Every field is optional and a pool follows the fleet on the ones it leaves alone, including after the fleet's own figure changes. Zero is an answer in each rather than an absence, so an absent field, an explicit null and "0s" are three different things. See Runner settings a pool can override. |
tmpfs |
Keeps the runner's work folder, and tmp if asked, in memory instead of on the host's disk. Off for every pool. Each is {enabled, size_mb}; size_mb: 0 fits the folder to the memory limit. daemon is a Docker-in-Docker pool's image store. A tmpfs is charged to that limit, so it is proposed raised rather than raised for you, see Keeping the work folder in memory. Docker and Podman only. |
cache |
A disposable accelerator directory mounted at /opt/zoomies-cache, scoped pool or repository, with an enforced size_limit. It is not workflow storage and may be evicted, see below. |
cost_per_runner_hour |
An optional rate you supply, used only to estimate what the fleet costs. Zoomies never embeds prices of its own. |
host_selector |
Restricts the pool to matching hosts. |
env |
Injected into every runner. A proxy variable here wins over the one the runner would otherwise inherit from its agent, see How a runner picks up a proxy. |
run_as_root |
Off. Turning it on is warned about. |
enabled |
A disabled pool drains to zero and creates nothing. |
The labels to give a pool¶
Give it one branded label of its own (zoomies-linux-x64, zoomies-gpu) and
let a workflow write that alone:
One label is enough to reach a pool. Branding it means a reviewer of the pull
request that introduces it can tell the job has left GitHub's runners, which
runs-on: [self-hosted, linux, x64] does not say. Zoomies also adds zoomies to
every pool, so runs-on: zoomies means "anywhere in this fleet", useful for a
repository nobody has assigned a pool to yet.
Runners are named for the brand too, for their pool's shape and for the host they
were placed on: zoomies-4vcpu-ubuntu-2404-fido-a3f9qz2m, which is what GitHub shows
in its runner list and in every job's log header, so a failed job names its machine. See
Naming and platforms.
The pool cache¶
cache mounts a directory at /opt/zoomies-cache inside every runner the pool
creates, and keeps it between runners. It exists to stop an ephemeral fleet
paying for the same download twice; a package or layer cache, a toolchain, a
warm module directory.
It is not workflow storage. Nothing guarantees a hit, an operator may empty
it at any time, and a job that cannot run without it is a job that will fail one
morning. Use actions/cache for anything a workflow depends on.
cache:
enabled: true
scope: pool # or: repository
size_limit: 0 # bytes; 0 is unlimited
source: "" # a named-volume prefix, or an absolute host path
repository: "" # owner/name, for a repository cache under an org installation
tools: false # keep the setup-* actions' tool cache too; see below
scope decides who shares it. pool gives every runner in the pool the same
cache, which is the faster of the two and assumes the repositories in the pool
may see each other's build artefacts. repository gives each repository its own,
which is what to use when the pool serves repositories that should not.
A repository cache needs to know which repository it is for. An installation
scoped to a single repository says so by itself and repository stays empty.
An installation scoped to a whole organisation (one App over one shared fleet,
which is the usual deployment) does not, so name it there as owner/name
under that organisation. Without this a shared fleet would need a separate
installation per repository to give each one a cache.
Naming the repository does not stop GitHub giving this pool's runners another
repository's job. A runner registered to an organisation takes any queued job
whose runs-on matches its labels, and that job reads and writes the cache.
So a repository cache under an organisation installation is only as private as
the pool's labels: give such a pool a branded label that only that repository's
workflows use, and keep it that way. Zoomies warns about the combination on the
pool's page and in the problems panel, because the cache's privacy rests on
something it cannot see, see security.md.
source is left empty for a daemon-managed volume, which is the easy answer. An
absolute path puts the cache on a disk you chose; anything else is treated as a
volume-name prefix. Zoomies appends the scope's own identity to whichever you
give, so two pools never collide, and refuses a source containing ...
size_limit is enforced, not advisory. As a runner starts, whole cache entries
are removed, least recently modified first, until the cache is back under the
limit. Deleting from a cache is only safe while nothing is reading it, so a
runner that starts to find another runner still using the same cache leaves it
alone and the next start tries again, on a pool with max_runners above one,
that is most starts, and the eviction happens at the one that finds it idle.
This bounds how far the cache drifts over the limit from one job to the next.
It is not a filesystem quota: a single job can still fill the disk before the
next runner starts, and if that matters, give the cache its own filesystem.
Only a directory can be measured, so a non-zero size_limit requires source
to be an absolute host path. On a named volume the bytes are the daemon's, on a
filesystem the agent may not even share, and a limit there would be a number in
a form that controlled nothing, so it is refused rather than accepted.
Using the pool cache from a workflow¶
Point package managers at /opt/zoomies-cache only when it is writable, so a
missing cache leaves the job using its normal defaults. The
persistent cache guide
is the single source for the Go, npm, pip, Maven and local BuildKit recipes,
including concurrency limits and pull-request trust boundaries. For reuse
across hosts, use its registry cache recipe.
Keeping a tool cache¶
cache.tools keeps the pool's tool cache between runners as well: what
setup-python, setup-node, setup-go and setup-java download, so the first
job to ask for a version downloads it and the rest find it there. It is a
setting of the pool, stored with it (the Keep a tool cache as well box
under the cache in the pool editor's Speed-ups section) and not an environment variable to
remember.
It is kept in each host's shared folder, under
cache/tools, in a folder named for the pool (and the repository, for a
repository-scoped cache) so it is shared with exactly the runners the pool
cache is. It is mounted read-only at /opt/zoomies-tools-shared, and each
runner is pointed at a folder of its own, /opt/zoomies-tools, holding a link
to every version in the kept cache whose install finished. That is its own
folder rather than the image's /opt/hostedtoolcache, so an image that ships
toolchains, such as zoomies-runner-full, keeps them. A pool
whose env sets AGENT_TOOLSDIRECTORY itself keeps its own.
Only the fill writes to the kept cache. A job that asks for a version it does not have yet downloads it into the runner's own folder, which is removed with the runner, so two jobs never unpack into the same folder at once and neither sees the other's half-written copy. The next scan puts that version in the kept cache for the jobs after it.
This shares more than a build cache does. What is in a tool cache is run, not
just read. A job cannot change the kept cache, but whatever the fill put there
is what every job on the pool runs, so only turn it on for a pool whose
repositories already trust one another, and prefer scope: repository when
they do not.
It needs the cache on and a container backend: a process runner uses the host's own tool cache already. A host whose shared folder cannot be created starts the pool's runners without the kept tool cache and says so in its log, rather than not starting them.
Filling the tool cache¶
A kept tool cache starts empty, and each host's copy fills one job at a time. The toolchain scan knows what the pool's jobs will ask for, so when a scan finishes, each host that can run the pool is asked to put those versions in its copy before the jobs arrive: the twelve asked for by the most jobs, if there are more.
The fill runs in a short-lived container of the pool's own image, as its runner
user, with only the tool cache mounted, one pool at a time per host, and never
in a runner's slot. It resolves each version the way the setup action would
(the newest stable release that matches) from the source that action reads,
checks the download against the checksum its publisher lists (Node.js, Go and
Temurin do; the Python manifest lists none, and setup-python checks none
either), and lays it out where the action looks first:
| Toolchain | Resolved against | Filled |
|---|---|---|
| Python | the actions/python-versions manifest |
plain versions (3.12, 3.11.x), on Ubuntu images only, because those are the only builds it publishes |
| Node.js | nodejs.org's release index | plain versions, lts/* and lts/<name> |
| Go | go.dev's release list | plain versions and stable |
| Java | Adoptium's API | the temurin distribution only |
| .NET | - | never: setup-dotnet installs outside the tool cache |
A version already there is left alone. A range such as >=3.10, a version read
from a file, and a distribution the fill does not serve are listed as skipped
with the reason, not guessed at: a toolchain installed under a name the action
does not look for is a download the job still makes. GET /api/v1/toolchains
reports each host's latest fill under fills, with what became of every
version. It downloads through the pool's proxy, or the agent's, and trusts the
host's extra CA as the
runners do.
A host whose agent predates the fill is not asked, and one added since the last
scan is filled by the next. With github.toolchain_scan_interval at 0, the
default, that is the next scan you start.
The shared folder¶
Every host keeps the data runners have to reach in one folder:
/var/lib/zoomies/shared on a Linux host where Zoomies runs as root, and
shared under the state directory everywhere else. It has one folder per
purpose:
| Folder | What is in it |
|---|---|
cache/tools |
each pool's kept tool cache |
cache/pools |
pool caches whose source is pointed here, as /var/lib/zoomies/shared/cache/pools |
The agent creates any of these that are missing each time it starts, which is
how a later release adds a folder: it needs the shared folder to be writable by
the account Zoomies runs as, zoomies for a native install, uid 65532 in the
container images. The installer creates it that way.
A container deployment mounts the host's /var/lib/zoomies/shared at the same
path inside the controller's container. The host's Docker daemon binds a
runner's cache folders from it, and a folder the controller named by any other
path would be one the daemon cannot see. An installation made before the shared
folder existed has neither the folder nor the mount; zoomies upgrade and
install.sh --upgrade offer to add both.
For a Compose file you maintain by hand, create it before starting:
A cache folder Zoomies creates for a runner is writable by every account (the runner is uid 1001 in the stock images, and something else in your own) while the folders above it keep everyone else out. One that is already there is left as you made it.
Jobs that build container images¶
A job that runs docker, docker buildx or docker compose (which includes
docker/setup-qemu-action, docker/setup-buildx-action and
docker/build-push-action, and any job with a container: or a services:
block) needs a Docker daemon, and its pool decides whether it gets one. Set the
pool's docker_mode to dind or host-socket; the default, none, gives the
job no daemon at all, and its first Docker step fails. Both alternatives weaken
the pool's isolation and both are warned about at startup;
security.md says what each costs, and
dind is the one to prefer.
That is the one setting. A daemon is worth nothing to a job whose image has no
client to reach it with, and the stock runner image deliberately carries none
(most pools never build an image, and a client on every runner is cold-start
time spent for nothing) so a pool that asks for a daemon while on
ghcr.io/eyupio/zoomies-runner is switched to
ghcr.io/eyupio/zoomies-runner-docker under the same tag as it is saved. That
covers every tag this build publishes: the channels (latest, dev, main, or
no tag at all), the release it was cut from, and the operating-system aliases a
pool gets by naming a platform (ubuntu-2404, debian-12-dev). One step of one
workflow publishes both images, so a tag that exists for one exists for the
other. The response, the audit row and the pool's page all show
the image that runs; the pool editor says so in the section that decides it and
shows it in the pool written out at the foot of the page; and a pool saved before this rule existed is moved by a
migration the first time a controller that has it starts. Idle runners made
from the old image are replaced, so a warm pool does not keep handing Docker
jobs to runners that cannot run them. The switch is not reversed when the
daemon goes away again: the variant runs everything the stock image does.
Three kinds of image are left exactly as you set them. A tag this build does
not hand out, a commit pin (sha-<commit>), or a release older than the
controller running it, promises nothing about the variant: a run whose second
build did not finish published the stock image and not its Docker one, and the
first prerelease predates the variant altogether. Moving a pool onto a tag the
registry may not have would stop every job on it, not only the Docker ones. A
digest reference (…@sha256:…) names one exact image and cannot be moved to
another. An image of your own (a mirror of the stock image under another
registry included) is yours to equip, and only has to put docker on the
runner's PATH.
A pool left on either of the first two is not left to find out: it raises
pool.docker_client_missing, on its own page and in the
problems drawer, naming the reference to pin instead,
ghcr.io/eyupio/zoomies-runner-docker:sha-<commit>, or a digest of the variant.
A runner that starts with a daemon it has no client for also says so in its own
log, at the top, before any job runs; the job itself fails at its first Docker
step with
which names the missing binary and not the reason.
ghcr.io/eyupio/zoomies-runner-docker is the stock runner image plus
docker-ce-cli, docker-buildx-plugin and docker-compose-plugin; the client
only. It never runs a daemon of its own; that is what docker_mode is for.
Whichever image a pool runs is pulled under its pull_policy, and the default,
if-not-present, fetches a tag only when the host does not already have it. On
its own that would leave a pool on latest running the build it first pulled
for as long as its hosts lived, which is what
images.refresh_interval
exists to prevent: the image is prewarmed again on a timer, in the background,
so the tag moves without a registry round trip in front of any job. Setting
pull_policy: always puts that round trip back on every runner created (with
the layers still cached, so the cost is a manifest check and not a download)
and is only worth it when an hour is too long to wait.
On a host-socket pool Zoomies also adds the group that owns the host's
docker.sock to the runner container, because the runner is not root inside it
and a socket it cannot open is the same failure with a different message.
How a job finds a pool¶
- The job's
runs-onlabels are normalised. - Labels every runner advertises anyway (
self-hosted,linux,windows,macos,x64,arm,arm64) do not constrain the choice, except that a pool declaring a contradicting os/arch label is excluded. - A pool matches when it provides every remaining label the job asked for.
- Among matching pools, the most specific wins (fewest surplus labels), with a deterministic tie-break by name, so the same job always lands in the same pool.
A queued job that matches no enabled pool appears in the Overview's problems
panel. That is almost always a typo in runs-on or a label missing from a pool.
Validation¶
Every start prints what it found:
$ zoomies controller
level=INFO msg="configuration warning" code=bind.public_no_tls setting=server.bind
title="listening on 0.0.0.0:8080 without TLS"
detail="session cookies, API tokens and the GitHub App private key you paste during setup all cross the network in cleartext."
fix="put a TLS-terminating reverse proxy in front, or set server.tls.mode to self-signed or files."
Check a file without starting anything:
zoomies config check --config /etc/zoomies/zoomies.yaml
zoomies config print # the effective config, secrets blanked
The same findings are served at GET /api/v1/problems and rendered in the UI's
problems drawer, so a warning cannot be missed just because nobody was reading
the logs the day it appeared. An operator can dismiss one they have read; that
is kept on their account, so it follows them to another browser, and changes
nothing the API or zoomies status reports.
scheduler.registration_concurrency¶
The default of 1 serialises credential minting within each installation. Other installations and cleanup keep progressing. Rate-limit responses establish an installation hold until GitHub's reset or retry deadline. Excess demand stays with the scheduler without creating a provisioning row or waiter goroutine. Raise this to at most 16 only when measured registration throughput needs it; an outage may then leave that many requests already in flight. Lowering it takes effect as existing requests finish. Admission resumes on a scheduling pass after the hold expires. Registration quota exhaustion does not increase pool failure backoff.
agent.prewarm_timeout¶
Background image preparation has a 5m budget, configurable from 1s to 15m. This bounds how long background work can occupy the host startup slot. Foreground creates keep their independent budget and have priority over pending background work. An already-running pull is not pre-empted. Restart standalone agents with their own setting; changing controller configuration only configures its embedded agent.
agent.prewarm_jitter¶
Before background image preparation, each agent waits a random duration from zero to 30s, without holding a lifecycle or startup slot. Set 0s to disable or up to 5m to spread a large fleet further. This applies to manual prewarming too. It does not change GitHub/provider retry deadlines or foreground image pull policies.
Repository settings checks¶
Set kennel.settings_checks: true to read each tracked repository's Actions
settings. It is a separate opt-in from the workflow files and the setup
inventory, off by default, and it reads settings only: no request Kennel Club
makes for it changes one.
| Check | What it detects | Severity |
|---|---|---|
token.default_write |
The repository's default workflow token can write, so every workflow that sets no permissions runs with write access. | Warning |
exposure.fork_approval_weak |
A public repository this fleet serves asks for approval of fork pull requests only from contributors new to GitHub. | Warning; error once a fork's code has run here |
exposure.private_fork_secrets |
A private repository lets fork pull requests run workflows and sends them secrets or a token that can write. | Warning |
protection.required_check_never_reports |
The default branch requires a status check that GitHub Actions should post, and none of the jobs this fleet was told about in the window has that name. | Warning |
The settings are read with the App's Administration: read permission. GitHub offers no narrower permission for them, so it also lets an App read collaborators and repository configuration that Kennel Club has no use for. Kennel Club makes the requests listed under what it may call and no others, and a test holds it to that list. Without the permission the read is shown as not granted and names the permission; nothing else about Kennel Club changes.
A new organisation App asks for it only if you answer yes to Also let Kennel Club check repository settings?, in the installer or in the Connect GitHub dialog. A repository App holds Administration: write already to register its runners, which includes read, and is never asked. To add the permission to an App that exists, open the App's Permissions & events page on GitHub and set Administration to Read-only; GitHub holds the change until the account's owner accepts it on the installation, and the repository shows as not granted until they do.
A repository's private fork rules and public approval policy are read once a refresh, with the other reads and through the same request budget and holds. A GitHub that lacks the fork-policy endpoints leaves the repository partly checked and still reads the default token. The four checks are also off, and shown as turned off, while this setting is.
protection.required_check_never_reports reads the status checks the default
branch requires, from classic branch protection and from the rulesets that
apply to it, and compares them with the jobs this fleet was told about. Three
rules keep it from guessing:
- Only a check pinned to the GitHub Actions app is judged. A check with no app, or another app, could be posted from somewhere the fleet cannot see, so it is counted and never judged.
- A check counts as posted when any job in the window has its exact name,
whichever runner ran the job. Jobs on GitHub-hosted runners count, because a
required check does not care who ran the job. A matrix job is named
<job> (<values>), so a requirement for the bare job name is not met by it, and that mismatch is the commonest cause of a check that never reports. - Fewer than twenty jobs in the window is too few to tell a check nobody posts from a window the fleet barely saw, so the repository is shown as not fully checked and no finding is made.
The name of a required check is a repository's own text. It is kept in Zoomies' database so the next evaluation can compare it, and the finding says how many checks are affected and never which: the name is not shown, logged, sent to an assistant or put in a prompt.
Agent guidance checks¶
Set kennel.agent_guidance: true to inspect agent instruction files in tracked
repositories. This is independent of repository setup, workflow checks and
AI Context freshness. The default is off.
Kennel Club reads a default-branch inventory of at most 10,000 entries and up to
32 instruction files, each at most 64 KiB. It recognises root and scoped
AGENTS.md, CLAUDE.md and GEMINI.md, .github/copilot-instructions.md and
Zoomies' .zoomies/AI_CONTEXT.md. Contents read permission is enough for checks
and preview. Background reads share Kennel Club's budget and rate-limit holds.
Missing guidance and exact duplication are informational. Broken local references, Claude import cycles and unreadable files are warnings. Missing guidance or paths are never inferred from a truncated inventory. A single supported file passes; there is no requirement to split a small project's guidance into folders.
Administrators can preview changes from the repository's Agent guidance tab and open a draft pull request. This needs Contents write and Pull requests write, without Workflows write. The preview shows current and proposed contents and rejects changes made after review. Merge the proposal, then press Recheck. See Agent guidance for the repair limits.