Skip to content

Setup time, caching and billing

The job environment covers what a job can run with. This page covers what it costs: how the environment reaches the job’s virtual machine, what is reused between runs, how to keep setup short, and which part of a job’s time you are billed for. It separates what happens today from what is planned; nothing marked planned is in effect yet.

A hosted job goes through these phases in order:

Phase What happens Billed
Queue The job waits for a runner with room for its size: no
Host preparation The runner node forks the repository’s cache disk and prepares the read-only disk of the job’s actions no
VM boot The job’s VM starts and its guest connects to the runner no
Services services: containers start under the VM’s dockerd yes
Steps Your steps run, with the packages: or flake: environment set up before the first step that needs it yes

Environment setup sits among the steps rather than before them: a job whose environment comes from the workspace (flake: .#ci) sets it up after actions/checkout, just before its first run: step. The log shows it as its own ==> setup: phase. The step list does not show it as a step yet, so its time is counted inside the job’s step timings.

Part of the environment Where it comes from in each job Reused between jobs
The base environment and its toolchains The VM’s root image yes: nothing is downloaded
setup-* tool versions not in the image A checksum-verified mirror neosource keeps yes, through the mirror
Actions (uses:) A SHA-pinned mirror, mounted read-only into the VM yes, through the mirror
packages: and flake: (Nix) cache.nixos.org, downloaded and unpacked into the VM’s own empty Nix store; anything cache.nixos.org has not built is built in the job no (preloading is rolling out)
Build output and download caches The repository’s cache disk (below) yes, from the last trusted run on a protected branch
actions/cache entries The platform’s cache service yes, within the repository

Nix environments are not reused between jobs today. Every hosted job starts with a Nix store that holds only Nix itself and the source of the platform’s pinned nixpkgs revisions, so evaluating at the pin needs no download. Everything the environment contains is then downloaded from cache.nixos.org, in every job, even when the previous job of the same repository used the same flake.lock. Measured on neosource’s own CI:

  • a small set of bare packages: is ready in about 2–4 s (about 620 MiB in about 3.5 s for a deploy job);
  • multi-GiB flake dev shells take 8–28 s, of which evaluating the flake is 3–5 s;
  • every derivation cache.nixos.org does not have is built again in every job.

The cache disk. Each repository’s jobs (every matrix leg on its own) get a copy-on-write disk on the runner node’s NVMe that persists between runs: Cargo’s build output ($CARGO_TARGET_DIR), ~/.cache, Cargo’s registry and git caches, Go’s module cache, the npm, bun, pnpm and Yarn download caches, and Docker’s images and build cache. Not on it: your checkout, node_modules, ~/.cargo/bin and the Nix store. Every job starts from a private fork of the last committed disk. A job’s copy becomes the next generation only when the run is trusted, was triggered by a push, a schedule or workflow_dispatch, and builds a branch that has a branch protection rule. Pull request runs (from a fork or not) and merge queue runs read the cache and never write it back. The disk lives on one runner node; the scheduler prefers a node that already holds it, and a job placed elsewhere starts cold.

actions/cache works against the platform’s cache service, scoped to the repository and evicted least recently used first. Runs from forks can read entries and cannot write them. The repository’s Actions → Caches page lists what is stored.

On your own machine (neo runner start, neo push --runner, neo run) the job uses the machine’s own Nix store, which persists between jobs, so on a warm machine the setup line reads already in the local store. A self-hosted VM runner behaves like the hosted pool: each job’s VM starts with an empty store.

Rolling out: environments recorded from your default branch

Section titled “Rolling out: environments recorded from your default branch”

This is being rolled out and is not in effect yet: until it is, no job gets a preload and setup works as described above. Once it is:

  • A trusted run records its environment. When a run on a protected branch (a push or workflow_dispatch, not a pull request) sets up a flake: environment from your repository, or a set of bare packages:, it records the environment’s store paths. A later job whose environment matches gets the paths cache.nixos.org has signed preloaded from a read-only disk on the runner node, pull requests included. A flake environment matches when flake.nix, flake.lock and the files flake.nix names with ./ paths are byte-identical to the recorded commit’s; bare packages: match when they name the same packages at the same platform pin, in any order. A flake job’s log then says, before setup, environment: N store paths preloaded from the runner's cache; for bare packages: the preload shows only as fewer downloads in the setup line.
  • The first matching job on a node prepares the disk in the background and sets up as before; it never waits for the disk, and neither does any later job. Only jobs on that node after the disk is ready get the preload.
  • Not preloaded: anything cache.nixos.org has not signed (your local builds, fixed-output downloads, flake inputs), environments a pull request changes until the change reaches the default branch, versioned or mixed packages:, flakes from outside the repository, and self-hosted runners. A node keeps every repository’s disks in one packages budget, evicting the least recently used first; one environment may take at most a fifth of it.
  • Your environment is still yours. The job still evaluates its own environment and downloads or builds whatever the disk lacks, so a record that is out of date makes setup slower, never different.

These are planned and not in effect:

  • What your repository builds or fetches itself. The preload above covers only content signed by cache.nixos.org; it is stored once per runner node and shared by every repository. Local builds and fixed-output downloads (flake inputs included) will be kept private to your repository, under a per-repository cap with least recently used eviction, and written only by trusted runs. A heavy flake therefore costs its owner the first run’s time, not other tenants.

Bare packages: stay the cheapest choice under the plan: what they need is almost entirely cache.nixos.org content, which is the shared tier.

  • Use what is already there. The base environment’s toolchains cost nothing. Prefer them, then bare packages:, then versioned packages:; reach for flake: when you need its pins or its custom tools.
  • Keep dev shells lean, one per kind of job. A lint job does not need the browser your end-to-end tests use. Every path in a shell is downloaded in every job that uses it today, so split a large shell into devShells.lint, devShells.test and so on, and point each job at the one it needs.
  • Avoid derivations cache.nixos.org has not built. An overlay that changes a compiler flag, an overrideAttrs, a toolchain assembled from components, your own mkDerivation: each is built in every job today, and the setup line names the slowest ones. Prefer the package as nixpkgs ships it.
  • Pin nixpkgs to a revision cache.nixos.org has built. Follow a channel (nixos-unstable, nixos-25.05) rather than nixpkgs master: a channel advances only after Hydra has built it, while a master commit can leave many packages to build in your job. Pinning your flake’s nixpkgs to the platform’s revision (neo version prints it) also skips the nixpkgs source download, because the VM image already carries that source.
  • Mind the number of jobs. Each job sets up its own environment and is rounded up to a whole minute separately (see What you’re billed for), so splitting work into many short jobs multiplies both.

Every job that uses packages: or flake: logs one header and one summary:

==> setup: nix flake shell .#ci
nix environment ready: substituted 133 paths (3.4 GiB unpacked): cache.nixos.org 133 (3.4 GiB); built 2 derivations in 14.2s (inputs 0.8s · eval 4.1s · substitute 8.9s · build 1.0s: nix-shell-env 0.6s, bun-1.4.4 0.4s)
Field Meaning
substituted 133 paths Store paths downloaded instead of built
(3.4 GiB unpacked) Their size on disk after unpacking, not the smaller compressed download
cache.nixos.org 133 (3.4 GiB) The same, per source. A source named node store is the runner machine’s own store
built 2 derivations Derivations built in the job, because no source had them
in 14.2s Wall-clock time of the whole setup
inputs 0.8s Fetching flake inputs (such as a nixpkgs source) while evaluating
eval 4.1s Time no download or build covers: evaluating the flake or packages, plus Nix’s startup and cache lookups
substitute 8.9s Downloading and unpacking the environment’s paths
build 1.0s: nix-shell-env 0.6s, … Wall-clock time during which a build ran, with the slowest builds named (five at most, then +N more). build 3.0s wall, 5.6s summed means builds ran in parallel

Once preloading is in effect, paths preloaded from the runner node’s disk (the preloaded from the runner's cache line before setup) are already in the store when Nix starts, so they appear in no count here: a preloaded job shows fewer paths and bytes from cache.nixos.org, not a new source.

Downloads and builds can overlap, so the phases add up to roughly, not exactly, the total. A phase under 0.05 s is left out. already in the local store means nothing had to be downloaded or built. nix environment ready in 6.3s with no detail means Nix reported nothing to break the time down by.

Read the setup line and start with the largest phase:

  1. build is large. The named derivations are built in every job. Use the package as nixpkgs ships it, or move the tool out of the shell the job uses.
  2. substitute is large. The environment is big. Split the shell per job, or replace the flake with bare packages: for jobs that need only a few tools.
  3. inputs is large. Flake inputs are being fetched. Pin nixpkgs to the platform’s revision, and drop inputs the shell does not use.
  4. eval is large. The flake takes long to evaluate. Importing nixpkgs once rather than several times, and avoiding import-from-derivation, both help.

Hosted CI minutes count from the moment the job’s VM is up and its guest has connected, until the job finishes, rounded up to the next whole minute per job. That covers everything that runs inside your VM: starting services:, checkout, environment setup, your steps and any post-steps. It does not cover time spent waiting in the queue, the runner preparing the job’s disks, or the VM booting. A job that runs for 61 seconds counts as 2 minutes.

Jobs on your own self-hosted runners are not billed.

Where to see usage:

  • Your workspace’s usage page (/<owner>/billing, Usage in its settings) shows the month’s minutes against the plan’s allowance.
  • A repository’s Actions → Usage page (/<owner>/<repo>/actions/usage) breaks that down for one repository.
  • The same figures are in the API: GET /api/owners/{handle}/ci/usage and GET /api/repos/{owner}/{repo}/actions/usage.

A per-job billed figure and a setup timeline in the job view are planned. A run created while the workspace has used up its monthly allowance is held as quota_blocked and does not start; runs created after the next month begins, or after a plan upgrade, run as usual.