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, andcargo-nextest. A repo that pins a different channel inrust-toolchain.tomlstill 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
ProxyJumpif 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.