Skip to main content
A container build runs each item in a locked-down container of its own. The runner starts it, serves its only network exit, and holds every credential the build uses. Set both the provision and the cleanup hook to builtin:container:
Name both hooks or neither. The host needs Docker Engine 26 or later. If it is older or Docker is not running, the build fails at provision and says which.

MicroVM builds

With both hooks set to builtin:vm, each build runs in a Firecracker microVM with its own kernel. The host must be x86_64 Linux, with Docker Engine 26 or later and Docker Buildx 0.18 or later. The runner’s account must be able to open /dev/kvm. runner init names builtin:vm on such a host; the full rule is in outerlayer runner init. outerlayer doctor fails builtin:vm hooks on any other host, and says why. The microVM is made from the recipe’s image, with the same tunnel and tokens a container build has. The tunnel, the destinations, the branch rule and the failure reasons below apply to it too, except disk_limit_exceeded and disk_check_failed. Its build limits work differently: see The build block. It has no network device: its connections leave over vsock to the runner’s tunnel. A recipe that adds the docker-in-docker feature gets its own Docker daemon inside the microVM. That is how a build that needs Docker runs. Containers and docker build steps reach public hosts through the tunnel too. Provision writes the build user’s Docker client configuration. The Docker CLI and Compose use it to give each container and each docker build step proxy variables. The variables name the tunnel at http://172.17.0.1:3128. The same allowHosts list and refusals apply. The tunnel answers 403 to a host the list omits, and each host a container reaches is among the hosts reached. A direct connection from a container fails, because the microVM has no network device. Three limits apply:
  • A container that calls another container by service name over HTTP must list that name in NO_PROXY. The defaults skip the proxy for localhost, 127.0.0.1, ::1, 10.0.0.0/8, 172.16.0.0/12 and 192.168.0.0/16 only.
  • A tool that ignores proxy variables needs its own proxy setting. Node’s fetch is one: set NODE_USE_ENV_PROXY=1 in the container.
  • A container that you start through the Docker API, not the Docker CLI or Compose, gets no proxy variables. The Supabase CLI and Testcontainers do this.
The first microVM build downloads a pinned Firecracker release, a guest kernel and a small guest helper from github.com. The runner checks each file against a sha256 pinned in the CLI, and fails the build at provision if one differs.

What a build gets

The runner builds the repository’s image, as outerlayer runner image does, and starts one container from it. The container has no network but loopback, a read-only root filesystem, and one volume for the clone. Your home directory, other builds and the Docker socket are out of reach. The runner mounts no folder of its own into the container: the build’s files are copied into /outerlayer/job through docker exec. Commands run as the recipe’s remoteUser, else as the image’s own user. When that is root, or neither names one, they run as ol-agent. The name ol-relay and the user and group id 60100 belong to the relay that carries the build’s connections to the runner. A recipe image that already uses that id fails the image build, and a remoteUser named ol-relay or with that id fails the build. A lifecycle command that exits non-zero fails the build at provision, with a reason naming the command.

The repositories in a build

A container or microVM build checks out every repository connected to the factory, each on the item’s branch, so one session can change several. The item’s own repository is the primary. In a build with more than one repository, every repository is at /build/work/repos/<owner>/<repo>, with the owner and repository written in lower case, such as /build/work/repos/acme/web for Acme/Web. The primary is one of them, and is also at /build/work/checkout. A factory with one repository gets one checkout at /build/work/checkout and no repos directory. In each repository the build starts on the branch the claim named there. That is the head branch of the item’s open pull request in that repository. When there is no such pull request, it is outerlayer/<factory>/<item number>. See The branch a claim names. OUTERLAYER_REPOSITORIES is a JSON list of the checkouts. Each entry has repository, path, branch and primary:
The agent decides from the issue which repositories to change. It then opens one pull request per changed repository with outerlayer work open-pr --repository <owner/name>. The primary decides where the agent starts. The governed context is delivered to a directory outside its checkout, and GH_REPO and OUTERLAYER_BRANCH name the checkout. gh holds a read token for the primary only. Each repository’s push token reaches the build only through the runner, which limits it to that repository. The image and the setup commands do not come from the primary. They come from the repository that governs the build: the control plane, or the repository itself when it governs itself. The setup commands run once, in that repository’s checkout. See Build a repository’s image from its devcontainer file.

Host checks across repositories

After a build, the runner runs the checks a control plane declares with where: host in a new container or microVM started from the build’s image. Every repository of the claim is checked out there at the same fixed paths as in the build. Each is checked out at the branch the build pushed there, or at the repository’s default branch where it pushed none. Host checks therefore run with every pushed branch checked out, and a failing test in any of them fails the item. The setup commands run first, read at the commit the build’s instructions came from. A pull request that edits the control plane’s devcontainer changes nothing until it merges. A script a setup command calls is read from the checkout, which can be a build’s branch. A build that pushed no branch in any repository fails every check, with a reason that says the build pushed no branch. A runner older than protocol 16 cannot build in more than one repository. A factory with more than one connected repository refuses its claim with runner_protocol_too_old, and the item stays available to a newer runner. See Runner protocol. Builds that run your own hooks, and builtin:process, work in one repository.

The build block

A microVM build uses the first four keys in its own way:
  • memory is the microVM’s memory. Under the service that outerlayer runner install --service writes, the host also limits the microVM to memory plus 1 GiB, with no swap. See Memory limits.
  • cpus is the number of virtual CPUs the microVM gets. Give a whole number. Any other value stops the microVM from starting, and the build fails at provision.
  • pids does not apply. The runner sets no process limit in a microVM build.
  • disk is the size of the microVM’s build disk, a sparse file on the host. The disk holds /build, /tmp and the data of the build’s Docker daemon, if it has one. A write past it fails with “No space left on device”. The runner does not measure the disk, so it does not end the build with disk_limit_exceeded or disk_check_failed.
variables is the only way a host variable enters a build. Claude’s credential reaches it only as a placeholder. Repository tokens (GH_TOKEN, GITHUB_TOKEN, GH_ENTERPRISE_TOKEN, GITHUB_ENTERPRISE_TOKEN) are never passed; the runner logs each name it skipped. A name the runner sets itself, such as HOME, PATH or a proxy variable, is refused. To size the host for these limits, see Memory limits.

The gateway and Claude

Every build the runner provisions with a built-in hook (builtin:vm, builtin:container or builtin:process) gets two built-in destinations. The build holds neither the item key nor Claude’s credential. The runner reads CLAUDE_CODE_OAUTH_TOKEN (from claude setup-token), or else ANTHROPIC_API_KEY, from its own environment; restart it after a change. A terminal claude login is not forwarded. With neither set, each build fails at provision as agent_credential_missing. See Give the runner Claude’s credential. A build your own hooks run gets no built-ins. It holds the item key as OUTERLAYER_API_KEY; see The build’s variables.

The tunnel

The container’s only way out is a tunnel the runner serves for that build. A container build reaches the runner through docker exec, so the tunnel is not a socket in the build’s directory. The runner sets the standard proxy variables to it, http://127.0.0.1:3128. A tool that ignores them cannot connect; set pip’s --proxy or Maven’s settings.xml to that address in the recipe. Not every tool reads those variables. A tool’s own documentation says where it takes its proxy from. A tool that ignores them fails with a connection or name-resolution error, because the build has no other way out. A host the tunnel refuses gets a 403 instead. So a download that fails without a 403 usually means a tool that needs its own proxy setting. A task runner that filters the environment hides the proxy variables from every task it starts. Turborepo does this by default. List HTTP_PROXY, HTTPS_PROXY, NO_PROXY, their lowercase forms and NODE_USE_ENV_PROXY in globalPassThroughEnv in turbo.json, or a task that downloads anything, such as a Next.js build that fetches Google Fonts, cannot connect.
A build's only exits are four ports on 127.0.0.1. Port 3126 reaches the gateway destination and the OuterLayer gateway, 3127 the claude destination and api.anthropic.com, 3129 and up your destinations and services, and 3128 the runner's tunnel. The tunnel carries git and gh to github.com with the build's tokens added, and reaches public hosts, limited by allowHosts when set. The host itself and private, loopback, link-local, Tailscale and several other special-purpose addresses are always refused with a 403.A build's only exits are four ports on 127.0.0.1. Port 3126 reaches the gateway destination and the OuterLayer gateway, 3127 the claude destination and api.anthropic.com, 3129 and up your destinations and services, and 3128 the runner's tunnel. The tunnel carries git and gh to github.com with the build's tokens added, and reaches public hosts, limited by allowHosts when set. The host itself and private, loopback, link-local, Tailscale and several other special-purpose addresses are always refused with a 403.
A build cannot reach these addresses:
  • the host’s own addresses
  • private and loopback addresses
  • link-local addresses, including the cloud metadata address 169.254.169.254
  • Tailscale addresses
  • the Azure wire server 168.63.129.16
  • the ranges 192.0.0.0/24 and 198.18.0.0/15
  • NAT64 addresses
  • 6to4 addresses (2002::/16) that carry a refused IPv4 address
A refusal is a 403 naming the range. Listing such a host in allowHosts does not change that. To reach a private service, make it a destination.

Limiting the hosts a build reaches

By default a build can reach every public host. It can send anything it reads to any of them, such as a build.variables value or its read token. Set build.allowHosts to close that.
An entry is a host name, an IP address, or a pattern such as *.githubusercontent.com, which matches names under the domain but not the domain itself. An empty list refuses every public host. An unlisted name gets a 403 with the range not-allowed. The list does not limit the gateway, Claude, your destinations or the git remote. It does not limit the image build either, which runs outside the tunnel. See What an image build reaches. To find the hosts your builds need, run a few without the list, then run outerlayer doctor on the host. While the list is unset, its “Build hosts” check warns and lists every host past builds reached, ready to paste into allowHosts. Review it first: a host a build should never have reached is in it too. Each build’s own list is hosts in its environment (see What a build records).

The git remote and the build’s tokens

A container build holds no credential that can write to its repository. Git and gh reach GitHub through the runner, which adds short-lived read and push tokens the gateway issues (GitHub App permissions and build tokens). gh can read, such as gh pr view, but not write. The branch rule. A push may update only the claim’s branch in the repository it goes to. A push to a repository the claim does not name is refused before it reaches GitHub. On an outerlayer/… branch any update goes through, force-pushes included; elsewhere only creating the branch and fast-forwards. A refused push updates no ref, the runner logs push refused: <refs>, and git prints:
A gateway with no GitHub App configured refuses every read token with repository_tokens_unavailable, so every container build fails at provision. Configure the App first.

A governed repository’s context

When a control-plane repository governs the item’s repository, a container build receives the control plane’s instructions, skills, agents and hooks. A governed build reads its control plane from a directory outside the checkout. Nothing from the control plane is written into the checkout, so a commit cannot pick it up. The runner fetches the control plane on the host and copies it, write-protected, to ~/.outerlayer/context/<owner>/<repo>/<commit>/ in the build user’s home. The directory holds the control plane’s AGENTS.md, .claude/ and .outerlayer/, the maintained skills, a CLAUDE.md and a settings.json. It also holds the control plane’s .agents/ folder, and each skill in .agents/skills/ is delivered under .claude/skills/ too. Paths that start with .outerlayer/ or .claude/ in the delivered CLAUDE.md, skills and agents point into that directory. In a governed build, claude runs outerlayer claude. The runner puts a claude program ahead of the image’s Claude Code on the command’s PATH. That program starts Claude Code with the directory attached. The main session and its subagents then follow the control plane. A configured command needs no change. A command such as claude -p "Build the issue" works as it is. Every build puts that claude program first, including a build of a repository that governs itself. There it starts the image’s Claude Code with the arguments alone, asks the gateway nothing, and does not check the Claude Code version. It adds the checkout’s own .agents/skills/ skills, so Claude Code loads them under their own names. The release records the control-plane repository and the commit that was delivered. The work item then shows which version of the shared rules a build ran under. The build is refused before the agent starts in two cases:
  • The repository has a skill in .claude/skills/ or .agents/skills/ with the same name as a shared skill, so a repository could replace a shared one. Rename or remove it.
  • The image’s Claude Code is older than 2.1.284. Rebuild the image.
A build is also refused when the control plane declares a layout the runner’s CLI does not read. See context_layout_unsupported below. The runner reads the control plane through the GitHub App. The gateway issues a read-only token for that one repository, and the runner uses it for the fetch. The token is never written to a file or a log, and the build never sees it. The App’s installation must include the control plane. A host git login is needed only without a GitHub App. A gateway that has none issues no token, so the runner reads the control plane with the host’s git login. The host also needs a git login for outerlayer runner image on a private repository. outerlayer doctor checks all of this as Control plane access.

Failure reasons

A read token that fails for a temporary reason, such as github_unavailable, releases the claim with no outcome; the runner retries on its next poll. A refused push token fails the push, and the build fails at its next health check.

Stopping a build

Stopping a build stops its container. Cleanup removes the container and volume; a runner that crashed finishes it on restart. A container build keeps running when its runner restarts or crashes. Its command runs inside the container and keeps its output and exit code in the build’s volume. When the runner starts again, it picks the build up. If the command ended while no runner was there, the build ends with the exit code the volume holds, or as interrupted when it holds none. The item can then be built again. A build whose runner never comes back stops when its lease expires. The runner tells the container when the lease ends, and the container stops itself at that time. If the runner logs cleanup failed, its next housekeeping pass removes what the build left, within the hour. To free the space sooner, remove what it names with docker rm --force and docker volume rm.

What a build records

outerlayer work status --json shows each build’s environment under claims: the isolation, image, hosts reached and refused, variable and destination names, and CLI versions. It never holds a secret.