Docs menu

The CI runner environment

.md

What's installed on the runner, how to add more, and how a job reaches your own infrastructure.

The machine#

Every run gets a fresh container on Linux, x86_64: 4 vCPU, 12 GB RAM, and about 18 GB of disk, running as root on a Debian-based image. Your repo is checked out at the exact commit under test, in /workspace. The container is destroyed when the run ends โ€” nothing carries over except what you declare in cache:.

There's no macOS or Windows runner, and no self-hosted runner, today.

Disk is usually the tightest limit. The repo, your build output, and a restored cache all have to fit in the ~18 GB. If a big build runs out of space, cache less (drop target/ or node_modules/ from cache: first) or turn off incremental build artifacts you don't need in CI, such as CARGO_INCREMENTAL=0.

Pre-installed#

Baked into the image, because installing them on every run costs real minutes:

  • Languages: Python 3.11, Node 20, Bun, and Rust via rustup โ€” a recent stable toolchain with rustfmt, clippy, and cargo-nextest. A repo that pins a different channel in rust-toolchain.toml still works; rustup downloads that channel during the run.
  • Build tooling: git, build-essential (gcc, g++, make), cmake, pkg-config.
  • Services and transport: PostgreSQL server and client (start it in a step when your tests need a database), sqlite3, openssh-client, rsync, zstd, curl.

Installing anything else#

The package manager is apt and steps run as root, so a plain install works โ€” as do pip, npm, cargo install, bun add, and anything else that fetches over the network:

steps:
  - name: deps
    run: apt-get update && apt-get install -y --no-install-recommends libssl-dev

Budget about a minute for an apt-get update && install, and remember it's a network dependency your build now has. Guarding installs with command -v tool || install-it keeps them free when a tool is already there. If a package matters enough that you'd rather it were pre-baked, ask โ€” the image is ours to extend. A job's image: key is accepted but currently ignored, so a custom base image isn't an option yet.

Starting PostgreSQL#

PostgreSQL is installed but not running. Start it in a step with a script in your repo โ€” this is adapted from the one Oak's own CI uses:

#!/usr/bin/env bash
# scripts/ci/start-postgres.sh
set -euo pipefail
ver="$(ls /usr/lib/postgresql | sort -V | tail -1)"
conf="/etc/postgresql/$ver/main/postgresql.conf"
# The sandbox has no usable /dev/shm; use mmap for shared memory.
grep -q '^dynamic_shared_memory_type = mmap' "$conf" || echo "dynamic_shared_memory_type = mmap" >> "$conf"
pg_ctlcluster "$ver" main start || true
for _ in $(seq 30); do su postgres -c "pg_isready -q" && break; sleep 1; done
su postgres -c "psql -c \"CREATE ROLE ci LOGIN PASSWORD 'ci' SUPERUSER;\"" || true
su postgres -c "createdb -O ci ci_test" || true
echo "DATABASE_URL=postgres://ci:ci@localhost:5432/ci_test"

Processes you start keep running for the rest of the run, so later steps can connect to localhost:5432.

Network, and reaching your own infrastructure#

Steps have outbound internet access, and outbound TCP is the transport that works: HTTPS to package registries, and ssh / rsync to hosts of yours. Oak's own deploy workflow does exactly this โ€” it rsyncs a built binary to a production server over SSH, with the key held as a CI secret โ€” so a job reaching out to a remote builder or server is a supported, in-production pattern.

Because a step is one shell command, put multi-line logic in a script in the repo:

# .oak/workflows/build.yml
steps:
  - name: build on the remote builder
    run: bash scripts/ci/remote-build.sh
# scripts/ci/remote-build.sh โ€” BUILDER_SSH_KEY comes from repo Settings โ†’ CI
set -e
mkdir -p ~/.ssh && chmod 700 ~/.ssh
printf '%s\n' "$BUILDER_SSH_KEY" > ~/.ssh/builder && chmod 600 ~/.ssh/builder
ssh-keyscan builder.example.com >> ~/.ssh/known_hosts 2>/dev/null
ssh -i ~/.ssh/builder [email protected] 'make -C /srv/src all'
rsync -e "ssh -i ~/.ssh/builder" [email protected]:/srv/out/ ./out/

What doesn't work is a kernel-level VPN client: the container has no TUN device and no NET_ADMIN, so WireGuard, OpenVPN, and anything else that creates a network interface will fail, and UDP tunnels aren't available. Nothing can connect in to the runner either. The shapes that do work:

  • SSH straight out to a bastion or the target itself โ€” with ProxyJump if it's behind one. Simplest, and what we run ourselves.
  • A userspace tunnel that speaks TCP and needs no TUN โ€” for example Tailscale in userspace-networking mode, or cloudflared / a SOCKS-style outbound connector โ€” with its auth key in CI secrets.
  • An mTLS- or token-authenticated HTTPS endpoint in front of the target, which avoids tunnelling altogether.

The runner's outbound IP address is neither stable nor published, so allow-list by credential, not by address.

Need something this can't do?#

A different architecture, a persistent runner inside your network, a much larger machine โ€” tell us what it is. Knowing which of these blocks real work is how they get built.

Something here wrong or missing? Run oak feedback -m "โ€ฆ" or email [email protected].