Local Docker CI: isolated container runs for pipelines
Run pinned pipeline jobs in isolated local containers, with per-run checkouts, no host mounts, shared resource limits and recorded platform facts.
Last updated
What it does
Local Docker CI runs one job of a portable pipeline in a container on your own machine, instead of on a hosted runner. Every run is isolated by construction:
- The source is a pinned commit, exported fresh. The job never sees your primary checkout, your uncommitted edits, or another mission's worktree. Orgabot exports the exact commit from the registered repository into a directory that belongs to that run alone, and records the commit's tree digest.
- Images are pinned by digest. A tag on its own is refused, because a tag can be re-pointed between two runs.
- Nothing from your home directory is mounted. A job gets exactly three kinds of mount: its own source copy, its own outputs directory, and its own caches. A definition that asks for any other host path is refused before anything starts.
- The Docker socket is never exposed. Privileged mode is refused too. A job that must build an image uses an approved, isolated BuildKit service instead. That service runs rootless on the run's own network, and its build state lives in size-limited memory, not a Docker volume. It is the one container that keeps the setuid helper rootless BuildKit needs to map its user IDs; the job that drives it stays fully locked down.
- Containers are locked down. All Linux capabilities are dropped, privilege escalation is disabled, the root filesystem is read-only, and CPU, memory and process counts are hard limits. Toolchain version probes get the same treatment, capped at 1 CPU, 256 MiB and 64 processes. Every container's Docker log rotates at 3 files of 10 MB, and that space counts toward the job's disk claim.
- Disk limits are enforced by the filesystem. A job's workspace and outputs live on a volume of their own, created for that run and capped at the run's disk claim. Each cache is its own volume too, capped at the cache's size limit. A write past the cap fails with "No space left on device" inside the container, however the job makes it, including through a file it has already deleted but still holds open. These volumes are APFS disk images, so local Docker CI needs macOS, and Docker Desktop must be allowed to read removable volumes (see Before your first run).
- There is no network unless policy grants it. Service containers share an internal per-run network with no route out.
- Service storage is bounded. A service container's root filesystem is read-only. It can write only to
/tmp, to each volume its image declares, and to the paths it lists inwritablePaths. Each of those is a memory-backed mount capped at the service'sresources.storageBytes(default 256 MiB, never more than its memory). Together they can never exceed the service's memory limit, so a runaway service cannot fill Docker's disk. An image that declares a volume path which cannot be mounted this way is refused.
Before your first run
macOS treats the disk images that hold each run's storage as removable volumes, so Docker Desktop needs permission to read them:
- Open System Settings > Privacy & Security > Files and Folders.
- Find Docker and turn on Removable Volumes. If macOS shows a prompt saying Docker would like to access files on a removable volume, choosing Allow does the same thing.
- Run
orgabot ci-docker probeand check thatdisk boundsaysverified.
Until the probe can read a bounded volume from inside a container, preflight refuses every job with the reason and this remedy, and nothing is launched.
Commands
orgabot ci-docker probe
orgabot ci-docker examples
orgabot ci-docker preflight <example> --project <id> [--commit <sha>] [--arch arm64|amd64] [--execution native|emulated|cross] [--egress]
orgabot ci-docker run <example> --project <id> [same options]
orgabot ci-docker sweepprobereports what the runtime has actually verified: the container OS, the native architecture, which foreign architectures run under emulation, and whether a size-bounded volume can be read from inside a container. Emulation is listed only after Orgabot has executed a pinned probe image under that platform and read the architecture back.preflightexplains eligibility and prints the exact plan. It starts no container, copies no source, and resolves no secret.runexecutes one job and prints its result, the platform it ran on, its outputs and its caches.sweepremoves containers, networks, volumes and workspaces left by a controller that died.
Without --commit, the project's current HEAD is resolved once and pinned for the run.
The shipped examples
| Example | What it runs |
|---|---|
node-checks | npm ci, the lint script if one exists, and npm test, with an isolated npm cache |
rust-checks | cargo fmt, cargo clippy and cargo test, with isolated cargo registry and target caches |
dependency-scan | OSV-Scanner over the checkout's lockfiles; needs --egress for the vulnerability database |
integration-postgres | An integration test against a health-checked Postgres service container |
Each example declares the tools it depends on (for example node and npm) and records their versions from the image, so nothing is inherited from a developer shell.
Platforms: native, emulated and cross
Every job states its architecture and how it runs:
nativeruns on the host's own architecture.emulatedruns a foreign architecture under the runtime's emulation. It is correct but slower, and the run records it as emulated.crossruns natively and cross-compiles for declared targets. Those targets are built, not tested.
On an Apple silicon Mac, a linux/amd64 job declared native is refused with a reason and a next step. It is never quietly run under emulation, and never dropped from a matrix. A Windows or macOS target, an MSI build, a GPU test, native UI tests or GitHub OIDC is refused in preflight by name, because a Linux container cannot provide them.
What every run records
Each run writes a manifest, its redacted structured logs, and its declared outputs to the shared artifact store under ci-artifacts/<project>/<run>/. The manifest records:
- the status, exit code and reason;
- whether the image was already present (warm) or pulled (cold);
- the Linux distribution, the container's architecture, the image digest and the toolchain versions;
- each output's digest and, for JUnit reports, its test counts;
- each cache's isolation key and size;
- what cleanup removed.
A job that exits 0 while its JUnit report records failures is a failed run. So is a job whose required output is missing.
Each output is capped at 50 MiB unless its definition sets maxBytes. A declared maxBytes must be between 1 byte and 64 MiB, or the job is refused before launch. The limit is checked again when outputs are collected, and a larger file is refused rather than truncated.
Outputs are read only after the job's container has been removed and Docker confirms it no longer exists, so nothing in the job can still be changing the files while they are read. If Docker cannot confirm the container is gone (for example, it stopped answering), no output is read and the run is recorded as an infrastructure failure with that reason.
Caches
Caches are isolated three ways:
- By project. Another project's cache is never mounted.
- By trust tier. Untrusted code never writes a cache that trusted jobs read.
- By architecture. amd64 and arm64 build output never mix.
Every cache declares a size limit, and the cache lives on its own volume capped at that limit, so it can never grow past it. After each run, the least recently modified entries are trimmed until the cache is back under its limit, so the next run starts with room to write.
One run uses a cache at a time. If another run already holds the cache, the new run gets an empty cache of the same size that is discarded when it finishes, so it still runs, just without the head start. A cache held by a controller that crashed is released by the next sweep.
Resource limits are shared with missions
Local CI does not get its own scheduler. A heavy job takes the same host-wide full-verify slot that missions' full test runs wait on, and a light job takes a light slot. Before the slot is taken, the CPU, memory and disk claims are checked against the live capacity reading. Those claims cover the job together with every service and the BuildKit service, since they all run at once. Each service's limits are held to the same policy bounds as the job's, a job may start at most 8 services, and the combined CPU and memory must fit the policy limits too. When they do not fit, the run waits with the reason rather than launching and hoping. See Budgets and quota.
The volumes are the hard limit. On top of them, the run's single disk claim covers its workspace, its outputs and what it adds to its caches together, and it is checked while the job runs. Every two seconds, Orgabot reads how full each volume is, which counts deleted files that are still open, and counts the files the job has created. A job that goes over its byte claim, or creates more than 200,000 files, is killed and the run fails with that reason. If part of a mount cannot be read (for example, a job removed the permissions from one of its own directories), the job is killed, because its disk use can no longer be checked. Orgabot never changes permissions inside a mount while a job can still write to it. Once the job's containers are gone, it restores owner access so the leftovers can be cleaned up. A run's retained log is capped too: the oldest lines are dropped first, and the log says how many were dropped.
Image storage
Images are pinned by digest, which fixes their content but not their size, and a pulled image stays in Docker's storage after the run. A registry states only an image's compressed size, and compression can shrink a layer a thousandfold, so the registry cannot tell Orgabot how much disk unpacking will take. So images Orgabot pulls have their own budget:
- A job pulls only vetted images. Before a job can pull an image that is not already on the machine, an operator vets that exact digest and platform once:
``bash orgabot ci-docker vet-image <image@sha256:digest> --platform linux/arm64 ``
Vetting pulls the image, measures its unpacked size, and records it in ~/.orgabot/ci-image-vetting.json. A digest never changes, so that measurement is its size for every later pull. A job whose image is not vetted is refused before anything downloads, with the command to run. An image that is already on the machine is measured in place and needs no download.
- Vetting checks the size first. An image whose compressed layers alone are over the per-image limit (8 GiB by default) is refused without downloading anything, and so is one whose size cannot be read from its registry. One that unpacks past the limit is removed and not vetted.
- Room is made first. All images Orgabot pulled together stay under 40 GiB by default. A job's pull reserves the vetted size before it starts. To fit it, the least recently used images are removed. An image a container still uses, or one used in the last 30 minutes, is never removed. If there is still no room, the run is refused with the reason. A removed image stays vetted, so a later run pulls it again under the same size.
- After a pull, the unpacked size is checked again, and an image over its vetted size is removed and the run refused. A pull that fails or is cancelled has whatever it left removed.
Images that were already on the machine before Orgabot needed them are reused and never removed. The list of pulled images is kept in ~/.orgabot/ci-images.json.
Cancellation, timeouts and crashes
Cleanup runs on every path: success, failure, timeout and cancellation. Every Docker object a run creates is labelled with its run and its controller. When a controller dies mid-run, the next one sweeps that run's containers, networks, volumes and workspace before it starts new work, detaching the run's disk-image volumes and releasing any cache it held; orgabot ci-docker sweep does the same on demand. A sweep never removes work whose controller is still alive.
Secret values are passed to the container through the Docker client's environment, never on the command line. They are redacted from logs, including each line of a multiline value such as a PEM key, and an output that contains one is refused rather than stored. This includes binary outputs.
Trust
Local Docker CI runs trusted verification by default. It refuses signing and production deployment jobs, because those need an identity a local container must not hold. Untrusted code (for example from a fork) runs only when your organization has approved this runtime for it. Even then it gets no secrets and no outbound network, and it writes only its own cache tier. See Governance and approvals.