# The CI runner environment

## 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:`](/docs/ci-workflows#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:

```yaml
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](mailto:zach@oak.space) — 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:

```bash
#!/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](/docs/ci#secrets) — 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:

```yaml
# .oak/workflows/build.yml
steps:
  - name: build on the remote builder
    run: bash scripts/ci/remote-build.sh
```

```bash
# 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 build@builder.example.com 'make -C /srv/src all'
rsync -e "ssh -i ~/.ssh/builder" build@builder.example.com:/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](mailto:zach@oak.space). Knowing which of these blocks real work is how they get built.
