Skip to content

The job environment

Every hosted job runs in its own virtual machine on an Ubuntu 24.04 base. You can add tools on top of that base in three ways, all through Nix. This page helps you choose one, describes each, and shows how to find package names. Setup time, caching and billing covers how the environment reaches the job, what that costs, and how to keep it fast; Actions, secrets and variables covers uses: steps, secrets and environment variables.

You need Use Setup today
What GitHub’s ubuntu-latest has on PATH: Go, Node.js, Java, .NET, Rust, Python, Ruby, Docker, the usual build tools nothing: the base environment none: it is part of the VM image
A few extra tools, or a language toolchain at the version nixpkgs ships bare packages: [jq, nodejs_26] small: typically 2–4 s
A language toolchain at a version you name versioned packages: { go: "1.24" } small, like bare packages
The exact environment you use locally, pinned by your own flake.lock, or tools nixpkgs does not carry flake: .#ci the largest: the whole closure is downloaded in every job (8–28 s for multi-GiB shells), and anything cache.nixos.org has not built is built in every job

Start at the top of the table and move down only when you need to:

  1. Nothing. The base environment already has the ubuntu-latest toolchains, so many GitHub workflows run unchanged.
  2. Bare packages: are the first choice for anything else. A bare name means the package’s default version at the nixpkgs revision neosource pins. The name is checked before the run starts, cache.nixos.org has already built everything at that revision, and a small set of tools is ready in a few seconds. The deploy jobs of neosource’s own CI, for example, set up about 620 MiB of tools in about 3.5 s.
  3. Versioned packages: when the version number matters to you (go: "1.24", node: "20") and you would rather not know which nixpkgs attribute carries it.
  4. flake: for a custom or pinned environment: a dev shell you maintain in your repository, with your own flake.lock. It is the most flexible option and the most expensive one today. See Keeping setup fast.

The same choices in YAML:

jobs:
# The base environment: no environment keys at all.
test-go:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: go test ./...
# Bare packages: default versions at the platform's nixpkgs revision.
lint:
packages: [shellcheck, actionlint, jq]
steps:
- uses: actions/checkout@v4
- run: shellcheck scripts/*.sh
# Versioned packages: name the version, the catalog picks the package.
test-node-20:
packages:
node: "20"
steps:
- uses: actions/checkout@v4
- run: npm ci && npm test
# A flake dev shell from your own repository.
build:
flake: .#ci
steps:
- uses: actions/checkout@v4
- run: cargo build --release

packages: and flake: are mutually exclusive: declaring both is an error. Declaring neither is fine. Either works on every job, whatever its runs-on says: runs-on selects where a job is scheduled, never its environment, and no label gives you a different base image. runs-on is optional; a job without it runs on ubuntu-latest.

jobs:
test:
runs-on: ubuntu-latest # scheduling label only
packages: [nodejs_26] # still honoured
steps:
- run: node --version

Replace the job’s container image with a list of tools:

# GitHub Actions
jobs:
build:
container: node:26
steps:
- run: npm ci
# neosource
jobs:
build:
packages: [nodejs_26]
steps:
- run: npm ci

image: on a job is a parse error that prevents the whole workflow from running. container: is ignored with a warning; the job uses the standard environment. Remove both when migrating. On the Actions tab, click a workflow’s warning or error badge to check its diagnostics.

A job that declares no packages: and no flake: still gets a rich Ubuntu 24.04 userland out of the box:

  • Standard build essentials (git, curl, build-essential, pkg-config, cmake, jq, ripgrep, gh, unzip, zstd) and a rootful dockerd with buildx and compose.

  • A pre-populated tool cache at /opt/hostedtoolcache ($RUNNER_TOOL_CACHE) carrying common versions of Go (1.24, 1.26, 1.27), Node.js (22, 24, 26), Python (3.14, 3.15), Temurin Java (17, 21, 25), and Ruby (3.4, 4.0), plus .NET (8, 10) in /usr/share/dotnet. Standard actions/setup-* steps find these versions immediately without downloading.

  • The toolchains GitHub’s ubuntu-latest (Ubuntu 24.04) has on PATH without a setup step, at the same default versions (except Node.js: 26, not 22), so a bare run: go test or run: ./gradlew build works as it does there:

    Tool Default on PATH
    Go 1.24 (go, gofmt); GOROOT_1_24_X64, GOROOT_1_26_X64, GOROOT_1_27_X64
    Node.js 26 (GitHub: 22), with npm, npx, corepack and Yarn 1 (yarn)
    Java Temurin 17; JAVA_HOME, plus JAVA_HOME_17_X64, JAVA_HOME_21_X64, JAVA_HOME_25_X64
    .NET the newest SDK (10) as dotnet
    Rust stable via rustup, with clippy and rustfmt
    Python Ubuntu’s 3.12 as python/python3, with pip and pipx
    Ruby Ubuntu’s 3.2, with gem and bundle

    They are ordinary writable installs: rustup component add, npm install -g, corepack enable, pip install and gem install work. Go, Node.js, Java, .NET, Python and Ruby also work through sudo. rustup and cargo do not: they live in ~/.cargo/bin, which sudo’s fixed PATH leaves out, so run them without sudo, as on GitHub. Anything you declare wins over them: a setup-* step, $GITHUB_PATH, packages: and flake: all come first on PATH. A Java you declare in packages: or flake: also takes over JAVA_HOME in run: steps. When it comes from Nix, an action (uses:) still sees the default JAVA_HOME. Set JAVA_HOME in env:, or use actions/setup-java, if an action needs the declared one.

When a job declares packages: or flake:, the VM adds the declared Nix environment to this base before the first step that needs it, with the packages you declare first on PATH. Where there is no Ubuntu userland under the job (a self-hosted runner without the base image, or neo run on your machine), a job with packages: (and no flake:) also gets a baseline Nix toolset (bash, coreutils, git, jq, curl, cacert).

Three forms. The first two are list entries and mix freely in one list; the third replaces the list with a map.

Bare nixpkgs attributes: nodejs_26, rustc, cargo, python312, jq, python3Packages.numpy. Each resolves to that attribute at the workflow’s nixpkgs revision: the platform’s pinned revision unless the workflow sets nixpkgs:. See Finding packages for how to look a name up.

Full flake references: anything carrying a source, such as github:nix-community/fenix#complete.toolchain or .#mytool. These are passed through as their own installable. They cost what a flake: costs: their own flake is evaluated, and its inputs are fetched, in the job.

The rule that decides which is which: an entry containing #, : or / is treated as a flake reference; anything else is a bare nixpkgs attribute. A dotted name like python3Packages.numpy is still a bare attribute.

packages:
- jq # nixpkgs attribute
- python3Packages.numpy # dotted nixpkgs attribute
- github:nix-community/fenix#complete.toolchain # flake reference

Versioned map: instead of a list, give a map of toolchain name to version string. Names resolve against a built-in catalog (node, python, go, java, ruby, deno, bun, zig, dotnet, rust, cargo), so you ask for a version rather than knowing the nixpkgs attribute that carries it:

packages:
go: "1.24"
node: "20"

Values must be strings: quote them, or YAML turns 20 into a number and 1.24 into a float. Use the list form for anything outside the catalog. A version the catalog has no entry for fails the run before it starts, with the versions it does have; it is never rounded to a nearby one.

Unknown names are rejected before anything runs

Section titled “Unknown names are rejected before anything runs”

Without a nixpkgs: override, bare attributes are checked against the platform’s pinned revision when the run is created. A name that revision does not carry, or one nixpkgs has removed, fails the run up front and nothing runs. The same error is shown on the workflow when you push it:

unsupported packages: ripgrepp — cannot run workflow.
lint: packages.ripgrepp: 'ripgrepp' is not a nixpkgs package at the platform's pinned revision (39ad350a0602). Check the name at /api/packages?q=ripgrepp
Nothing was run.

The revision in the message is the pin the name was checked against. The /api/packages search it suggests covers the toolchain catalog only today (see Finding packages), so an empty result there does not mean the name is wrong. For a removed attribute the message says so and suggests a current package or an older nixpkgs: pin. One exception: a removed attribute that the toolchain catalog still carries from an older pinned revision resolves through the catalog, so packages: [nodejs_20] runs the same Node 20 as packages: { node: "20" }.

The same check applies to names built from a matrix (packages: ["${{ matrix.pkg }}"]): each leg is checked when it is created, and a leg whose name is unsupported fails alone with the same error while the other legs run. Names the server cannot see before the job starts (a matrix expanded at run time) are checked when the job requests them, with the same outcome.

A current runner evaluates the job’s bare attributes at the platform’s revision, even if the runner was built against a different default.

Bare attributes resolve against a default nixpkgs revision pinned by the platform. Override it per workflow with a top-level nixpkgs:, preferably an exact commit:

name: CI
nixpkgs: github:NixOS/nixpkgs/<commit>
jobs:
build:
packages: [rustc, cargo]
steps:
- run: cargo build

This only affects bare attributes. Flake references carry their own source and are unaffected. An override has two costs: names are not checked before the run (a wrong one fails in the job’s setup instead), and the job downloads that nixpkgs revision’s source, which the VM image carries only for the platform’s own revisions. A branch name such as nixos-25.05 also works, but it moves: two runs of the same commit can get different tools.

Versioned catalog toolchains. neosource.dev/packages lists the toolchain catalog: every tool the versioned map accepts and the versions it resolves. The same data is public at GET /api/packages?q=<prefix> and GET /api/packages/<attr> (see the API reference). It does not cover all of nixpkgs yet.

Bare names. A bare name must exist at the revision the platform pins. To find that revision:

  • neo version prints the nixpkgs pin your CLI was built with; it matches the platform’s while your CLI is current (neo upgrade).
  • A job’s log names the revision it used, in its setup header: ==> setup: nix shell jq ripgrep (nixpkgs github:NixOS/nixpkgs/39ad350…).

Then look the name up:

  • search.nixos.org searches the current heads of the nixpkgs channels, not the platform’s exact revision. A name listed there is almost always present at the pin, but a package added or renamed very recently can differ.
  • To check exactly, ask Nix about that revision: nix eval github:NixOS/nixpkgs/<revision>#ripgrep.version.
  • When the workflow is indexed (a push to the default branch), every bare name is checked, and the workflow’s error badge on the Actions tab names any the pin does not carry.

If your repository already has a dev shell, point at it instead of listing packages:

jobs:
build:
flake: .#ci
steps:
- uses: actions/checkout@v4
- run: cargo test

The job runs nix develop .#ci. Because .#ci names your workspace, the environment is set up after actions/checkout has filled it, just before the first run: step; steps before that, such as the checkout itself, run on the base environment. The environment is then captured once (nix print-dev-env) and every later step runs inside that captured environment instead of entering nix develop again; a shellHook still runs at the start of each step, as it would under nix develop. If the shell can’t be captured faithfully (for example it uses __structuredAttrs), or flake.nix/flake.lock changes during the job, steps fall back to their own nix develop and the log says so in one line.

The flake is yours: it pins its own nixpkgs in your flake.lock, can carry overlays, custom derivations and tools nixpkgs does not have, and gives you the same shell locally with nix develop. That freedom is also its cost: everything the shell needs is downloaded in each job today, and anything cache.nixos.org has not built is built in each job. Keep one lean shell per kind of job rather than one shell with everything; see Keeping setup fast.

A job resolved through Nix starts with a labelled, timed log phase before the first step that needs it: ==> setup: nix flake shell <ref> for flake:, or ==> setup: nix shell <entries> (nixpkgs <revision>) for packages:. It ends with one summary line:

nix environment ready: substituted 133 paths (3.4 GiB unpacked): cache.nixos.org 133 (3.4 GiB); built 2 derivations in 14.2s (eval 4.1s · substitute 8.9s · build 1.0s: nix-shell-env 0.6s, …)

Reading the setup line explains each field. When the flake or nixpkgs source lives in your repository, the phase runs after checkout. It counts against the job’s timeout, and a failure there fails the job before the step that needed the environment runs, naming the environment and carrying Nix’s error.

Because the toolchain is a value rather than an image, a matrix can vary it directly:

strategy:
matrix:
node: [18, 20, 22]
runs-on: ubuntu-latest
packages: ["nodejs_${{ matrix.node }}"]
steps:
- run: node --version

This is the direct replacement for a matrix over container images.

The no-images rule applies to the job environment only. Service containers still take ordinary container images, because that is the right shape for a database:

services:
postgres:
image: postgres:16
env:
POSTGRES_PASSWORD: test
ports:
- 5432
healthcheck:
tcp: 5432

Every hosted job runs in its own virtual machine (see Runners), and each service is a container under that VM’s own dockerd, started before the first step. Services share the VM’s network, so a service is reachable as localhost:<port> and by its service key: postgres:5432 works as it does on GitHub. Any image works; there is no allow-list.

Two differences from GitHub:

  • ports: does not remap. Both the bare and the "host:container" forms are accepted, but a service always listens on its container port; a remap to a different host port is logged and not applied.
  • The neo CLI’s runner does not start services. A job that declares services: is never handed to a neo runner start agent, and neo run refuses it before the first step. A self-hosted runner on the VM backend (see Runners) runs them as the hosted pool does.

shell: accepts bash, sh, python, and pwsh (or powershell), or any command template containing {0} (bash -euo pipefail {0}, nu {0}). bash is the default. Any other value is a parse error.

On hosted runners shell: python works without packages:: the base environment has Python 3.12 (python3). pwsh is not in it; declare it in packages:. A self-hosted runner without the base image has neither unless you declare them.