Conda Environments on GitHub Actions
Conda jobs on GitHub Actions spend most of a cold run in the solver. Cache the environment keyed on environment.yml, then size the runner for the suite.
Last verified:
A conda job on GitHub Actions spends most of a cold run building the environment rather than running tests, because conda env create fetches channel repodata and solves the dependency graph before a single package is downloaded. Cache the environment keyed on the hash of environment.yml, pin an explicit lockfile when the solve stays slow, and put the suite on a warp- label sized for the tests instead of the solver.
Overview
A conda job runs four phases before the first test executes. It downloads repodata for every channel in the environment file, solves the requested specs against that repodata, downloads the resolved packages, and links them into the environment directory. Only the last two phases move data; the first two are the ones that surprise people.
The solve dominates a cold run when the environment file lists loose version ranges across conda-forge and defaults together. Channel repodata for a large channel is tens of megabytes of index that has to be parsed before the resolver starts, and the resolver itself is close to serial, so buying more vCPUs does not shorten it. The libmamba solver, distributed as conda-libmamba-solver, cuts the resolution cost substantially compared with the classic solver, and it still runs on every job that has no lockfile and no restored environment.
WarpBuild runners register against your organization under warp- labels, run as ephemeral virtual machines allocated for one job, and carry the same tooling as GitHub-hosted images, so setup-miniconda, setup-micromamba, and your environment.yml behave the same way. The label list and sizes are in the cloud runners documentation.
The rest of this page covers a working cached workflow, the sizing question for solve time against test time, the bottlenecks that a bigger runner does not fix, and the option of a prepared machine when the solve stays expensive. For the pip and uv side of a Python codebase see faster Python test suites on GitHub Actions.
Configuration
The workflow below runs on warp-ubuntu-latest-x64-8x (8 vCPU, 32 GB, 150GB SSD). It restores the environment from cache, activates it through a login shell, and runs the suite.
name: conda-tests
on:
push:
branches: [main]
pull_request:
defaults:
run:
shell: bash -el {0}
jobs:
test:
runs-on: warp-ubuntu-latest-x64-8x
steps:
- uses: actions/checkout@v4
- name: Create or restore the environment
uses: mamba-org/setup-micromamba@v2
with:
environment-file: environment.yml
environment-name: app
cache-environment: true
cache-downloads: true
- name: Show what got resolved
run: micromamba list
- name: Run tests
run: pytest -n auto --dist workstealThree details in that job carry the weight.
shell: bash -el {0} is the activation contract. Conda writes its activation hook into the login shell profile, so a step running under the default non-login shell finds the base interpreter rather than your environment. Both setup-micromamba and setup-miniconda document the login shell as a requirement, and setting it once under defaults applies it to every run step in the job.
cache-environment: true keys the cache on the contents of environment.yml, so the entry survives until a spec changes and invalidates the moment one does. That is the strategy that pays: keying on the environment file makes a warm run skip repodata, solve, download, and link together, while keying on a branch name or a date leaves you restoring an environment that no longer matches the file.
micromamba list after creation is a two-second step that saves debugging sessions later. When a warm run and a cold run disagree, the printed package list tells you whether the cache handed back a stale environment.
If your project is on setup-miniconda, cache the package directory instead and swap in the WarpBuild cache action:
- name: Cache conda packages
uses: WarpBuilds/cache@v1
with:
path: ~/conda_pkgs_dir
key: ${{ runner.os }}-conda-${{ hashFiles('environment.yml') }}
restore-keys: |
${{ runner.os }}-conda-
- uses: conda-incubator/setup-miniconda@v3
with:
environment-file: environment.yml
activate-environment: app
channel-priority: strict
use-only-tar-bz2: trueWarpBuilds/cache is a drop-in replacement for actions/cache, so path, key, and restore-keys behave the same way. The cache is enabled by default on Linux runners, entries expire 7 days after last use, storage is $0.20 per GB-month, and each write, restore, or list operation is $0.0001. Details are in the caching documentation. The use-only-tar-bz2 input in the setup-miniconda README exists because the package cache is keyed by archive format, and mixing .conda and .tar.bz2 artifacts produces misses that look random.
What each strategy actually removes
| Strategy | What a warm run skips | What it still pays for | Invalidation key |
|---|---|---|---|
conda env create with no cache | nothing | repodata, solve, download, link | none |
Package cache on ~/conda_pkgs_dir | package downloads | repodata revalidation, solve, link | hash of environment.yml |
Environment cache (cache-environment: true) | repodata, solve, download, link | cache restore time | hash of environment.yml |
| Explicit lockfile via conda-lock | repodata, solve | download and link unless also cached | hash of the lockfile |
| Snapshot runner or prebuilt container image | all four phases | refresh cadence for the snapshot or image | rebuild schedule |
The row that gets skipped most often is the lockfile. A package cache does nothing for solve time, so a project with a slow solve and a healthy cache still pays the solve on every job. Generating conda-lock.yml and passing it as the environment-file removes resolution from the job entirely and makes the run reproducible across machines.
Sizing
Solve time and test time want different machines. The solve is close to serial and bounded by parsing and resolution, so it barely improves above 4 vCPUs. Package extraction and link are disk-bound, and every Linux size ships 150GB SSD storage. Test execution is the phase that scales with cores. Size for the tests, then remove the solve with caching rather than paying for cores it cannot use. Rates are from the pricing page, billed per minute, checked on 2026-08-13.
| Label | vCPU | RAM | Fits | USD per minute |
|---|---|---|---|---|
warp-ubuntu-latest-x64-4x | 4 | 16 GB | environment build jobs, lint, small suites | $0.008 |
warp-ubuntu-latest-x64-8x | 8 | 32 GB | the default for a cached environment plus pytest -n auto | $0.016 |
warp-ubuntu-latest-x64-16x | 16 | 64 GB | large scientific suites holding arrays per worker | $0.032 |
warp-ubuntu-latest-arm64-8x | 8 | 32 GB | the same job where every channel publishes linux-aarch64 | $0.012 |
The ARM64 row is worth checking before you take it. conda-forge publishes linux-aarch64 builds for most of the scientific stack, and a smaller channel or an internal channel may not, in which case the solve fails or falls back to a source build. Run micromamba create --dry-run against the ARM64 platform once to confirm before switching a whole matrix.
What the phases cost
Take a job where a cold environment build costs 4 minutes, a restored environment costs 1 minute, and the suite takes 6 minutes on 8 vCPUs, across 300 runs a month. Substitute your own two measurements. GitHub's per-minute list price for the 8-core Linux larger runner is $0.022, per the GitHub Actions billing reference, checked on 2026-08-13.
| Setup | Wall clock | Rate per minute | Cost per run | Cost per month |
|---|---|---|---|---|
| GitHub hosted larger runner, 8 vCPU, cold environment | 10 min | $0.022 | $0.220 | $66.00 |
warp-ubuntu-latest-x64-8x, cold environment | 10 min | $0.016 | $0.160 | $48.00 |
warp-ubuntu-latest-x64-8x, cached environment | 7 min | $0.016 | $0.112 | $33.60 |
warp-ubuntu-latest-arm64-8x, cached environment | 7 min | $0.012 | $0.084 | $25.20 |
Two things fall out. The cache is the larger lever, since it removes 3 of 10 minutes from every run at a carrying cost of about $0.40 a month for a 2 GB environment cache plus $0.06 for 600 cache operations. And the equal-shape rows differ on rate: warp-ubuntu-latest-x64-8x (8 vCPU, 32 GB) costs $0.016 per minute against $0.022 per minute for the 8-core Linux larger runner (8 vCPU, 32 GB): 27 percent lower list price. GitHub list price checked on 2026-08-13.
To confirm a sizing choice rather than guess at it, WarpBuild CI observability reports CPU and memory percentiles from the runner agent next to the job, which shows whether the solve phase leaves seven cores idle while the suite saturates all eight.
Bottlenecks
Solve time on every cold run
This is the conda-specific one. Loose specs such as python>=3.10 and numpy across two channels give the resolver a large search space, and the cost lands before any test runs. Tighten the specs in environment.yml, set channel-priority: strict so the resolver stops considering lower-priority channels for a package it already found, and generate a lockfile when the solve still runs long. A lockfile is the only fix that removes the phase rather than shortening it.
Cache misses that look random
An environment cache keyed on environment.yml misses whenever the file changes, which is correct. It also misses when the key omits the platform, so an x64 job and an ARM64 job fight over one entry. Put ${{ runner.os }} and the architecture in the key, and add a restore-keys prefix so a spec bump restores the previous environment and updates the delta rather than rebuilding from scratch.
Pip installs layered on top of conda
Environments that list a pip: section under dependencies run a second resolver after the conda solve finishes, and the pip half is invisible to conda's cache. Pin those entries with hashes or move them into the lockfile, and keep the pip cache directory in the same cache entry as the environment so both halves warm together. The same keying rules apply to the pure-pip toolchains; how to cache uv or poetry environments covers the paths and keys for those.
When the solve stays expensive
Some environments resolve slowly no matter how they are pinned, usually because of a large internal channel or a matrix that spans several Python versions. Two options remove the build from the job entirely.
A snapshot runner captures the machine after the environment is built and boots later jobs from that snapshot. Add snapshot.key=<alias> to the label, and jobs on that alias start with the environment already on disk:
jobs:
test:
runs-on: warp-ubuntu-latest-x64-8x;snapshot.key=conda-envSnapshots are supported on WarpBuild Cloud Ubuntu runners, boot in 45 to 60 seconds, and are deleted after 15 days, and /tmp does not persist across the boot. Clean credentials with rm -rf $HOME/.ssh $HOME/.aws before the WarpBuilds/snapshot-save step, since anyone running a job under the alias inherits the disk. The snapshot runners documentation covers the save action and the security notes in full, and the snapshot runner page covers the labels and where the feature fits.
The other option is a container image with the environment baked in, built once and pulled by the job through the container key. That gives you the same skip with an artifact you can version and scan, at the cost of an image build pipeline and a pull on every job. The guide to container jobs on GitHub Actions covers the tradeoff.
When an environment resolves on a laptop and fails in the job, the Action Debugger pauses the workflow and opens an SSH session on the live runner so you can run micromamba repoquery against the real machine.
Proof
Public repositories are checkable evidence for the pattern on this page. The closest published cases are Python jobs with compiled dependencies running on warp- labels, which stress the same download and link path as a conda environment build.
nobodywho-ooo/nobodywho runs its Python lint and test jobs on warp-ubuntu-latest-x64-4x and its macOS wheel build on warp-macos-latest-arm64-6x, with dependency caching keyed on a lockfile, in python-ci.yml (checked on 2026-08-13). lance-format/lance runs its Python macOS 3.14 ARM job on warp-macos-14-arm64-6x, building the macOS wheel and running the suite, in python.yml (checked on 2026-08-13). Both labels come from the catalog on the cloud runners page.
Moving a conda job over is a one-line change to runs-on plus the cache action swap.
FAQ
How do I cache a conda environment in GitHub Actions?
Cache the environment directory and key it on the hash of environment.yml, so the entry invalidates the moment the file changes. setup-micromamba does this with cache-environment: true, and setup-miniconda pairs with a cache action on ~/conda_pkgs_dir. On WarpBuild runners, swap actions/cache for WarpBuilds/cache and the inputs stay the same.
Why is conda env create so slow on a cold GitHub Actions run?
A cold run pays for four phases: repodata download, dependency solve, package download, and link into the environment. The solve dominates when the environment file lists loose version ranges across several channels, and it is close to serial, so a larger runner does not shorten it. An explicit lockfile removes the solve entirely.
What does a conda job cost on WarpBuild runners?
Start with $10 in free credits
Change the runner label in your workflow and keep the rest of your GitHub Actions setup. Runner time is billed per minute.