Haskell Builds on GitHub Actions
Haskell builds on GitHub Actions need a cabal store cache keyed by GHC version, a warp- Linux label sized for parallel GHC, and tests split from the build.
Last verified:
A Haskell build on GitHub Actions spends most of its wall clock rebuilding dependencies that an earlier run already compiled, because every job gets a fresh VM and the cabal store or stack root arrives empty. Two changes fix that: point runs-on at a warp- Linux label with enough memory for parallel GHC processes, and restore the store with a key whose prefix pins the GHC version so a dependency bump still lands on a warm store.
Adoption is a label change plus a restore step. The sections below cover the directories worth caching and the key shape that survives a version bump, a workflow that splits the dependency build from the test run, which runner size fits a compile-bound build at which rate, and the bottlenecks that a larger machine cannot solve.
Overview
A cabal or stack run spends GitHub Actions minutes in six places: installing the toolchain, downloading the Hackage index, resolving and building the dependency set into the store, compiling the modules of local packages, linking executables and test binaries, and running the test suites.
Two of those parallelize on vCPU count. cabal builds independent packages concurrently with -j, and GHC compiles independent modules inside one package concurrently with its own -j. Everything else is serial: linking is one process, Template Haskell splices run compiled code during compilation and force their dependencies to finish first, and a long chain of modules that each import the previous one compiles one module at a time no matter how many cores are free. Package graph shape decides how much of the runner a build can use.
Memory is the constraint that catches people out. Each parallel GHC process holds its own heap, and a large module compiled with -O2 can hold several GB on its own. Running cabal build -j8 with ghc-options: -j4 starts up to 32 compiler threads, which is how a build that passes locally gets killed with signal 9 on a small runner.
Haskell work belongs on the Linux runners, which run Ubuntu images from 2 to 32 vCPUs with 150GB SSDs and carry the same tooling as GitHub-hosted runners, as listed in the cloud runners documentation. Runners are ephemeral VMs, allocated per job and destroyed afterward, which is why the store is empty unless a cache step fills it. Cache is enabled by default on Linux runners.
Two tools matter here. Snapshot runners carry the local build tree that a cache archive handles badly, and CI observability shows whether a slow job has all cores busy or one core pinned in a serial module chain.
Configuration
Start with what to cache. Haskell build tools keep compiled output in two separate places, and only one of them belongs in a cache archive.
| Path | Tool | Contents | Cache it |
|---|---|---|---|
${{ steps.setup.outputs.cabal-store }} | cabal | Compiled dependency packages, keyed by ABI | Yes |
~/.cache/cabal/packages | cabal | Hackage index and downloaded source tarballs | Yes |
dist-newstyle | cabal | Local package objects and interface files | No |
${{ steps.setup.outputs.stack-root }} | stack | Snapshot package database and compiled snapshot packages | Yes |
.stack-work | stack | Local package objects and interface files | No |
~/.ghcup | ghcup | GHC, cabal, and stack binaries | Handled by the setup action |
Read the store path from the setup action output rather than hardcoding it. cabal moved to XDG directories, so the store is ~/.local/state/cabal/store on a cabal 3.12 machine with no pre-existing ~/.cabal, and ~/.cabal/store everywhere else. steps.setup.outputs.cabal-store and steps.setup.outputs.stack-root resolve to whichever layout the pinned version uses.
Here is a working cabal pipeline on WarpBuild runners.
name: haskell
on:
push:
branches: [main]
pull_request:
jobs:
build-and-test:
runs-on: warp-ubuntu-latest-x64-8x
steps:
- uses: actions/checkout@v4
- id: setup
uses: haskell-actions/setup@v2
with:
ghc-version: '9.8.2'
cabal-version: '3.12.1.0'
cabal-update: true
- name: Restore cabal store
id: store
uses: WarpBuilds/cache/restore@v1
with:
path: |
${{ steps.setup.outputs.cabal-store }}
~/.cache/cabal/packages
key: >-
${{ runner.os }}-ghc${{ steps.setup.outputs.ghc-version }}-cabal-${{
hashFiles('**/*.cabal', 'cabal.project', 'cabal.project.freeze') }}
restore-keys: |
${{ runner.os }}-ghc${{ steps.setup.outputs.ghc-version }}-cabal-
- name: Build dependencies
run: cabal build all --enable-tests --only-dependencies -j
- name: Save cabal store
if: steps.store.outputs.cache-hit != 'true'
uses: WarpBuilds/cache/save@v1
with:
path: |
${{ steps.setup.outputs.cabal-store }}
~/.cache/cabal/packages
key: ${{ steps.store.outputs.cache-primary-key }}
- name: Build project
run: cabal build all --enable-tests -j
- name: Test
run: cabal test all --test-show-details=directFour details matter.
The GHC version belongs in the key prefix. Store entries are keyed by ABI hash, and objects built by another GHC are unusable, so a restore-key that falls back across GHC versions restores gigabytes that the build then ignores. Keeping the version in the prefix and the package set hash in the suffix means a bumped dependency restores the newest store for that same compiler, and only the packages that actually changed rebuild.
Save the store before the tests run. The split restore and save form writes the archive right after the dependency build, so a failing test suite still leaves a warm store for the next run. The cache-hit guard skips the upload when nothing changed, which also keeps the per-operation cache charge off the bill. WarpBuilds/cache@v1 is a drop-in replacement for actions/cache@v4 with identical syntax, documented in the caching documentation.
Build the test suites, then run them. cabal build all --enable-tests compiles test binaries without executing them, so a compile error surfaces in the build step and a rerun of the test step costs no recompilation. Passing --enable-tests to the dependency step matters too, since test-only dependencies otherwise land in the store on the second pass.
Pin index-state in cabal.project. A pinned index state makes resolution reproducible and keeps the cached Hackage index valid instead of being refreshed on every run.
For stack, set enable-stack: true and stack-no-global: true on the setup action, cache ${{ steps.setup.outputs.stack-root }} with a key hashing stack.yaml, stack.yaml.lock, and package.yaml, then run stack build --system-ghc --test --no-run-tests followed by stack test --system-ghc.
Sizing
The Linux x64 catalog, with per-minute rates from the WarpBuild pricing page:
| Runner label | vCPU | Memory | Storage | Price per minute |
|---|---|---|---|---|
| warp-ubuntu-latest-x64-2x | 2 | 8 GB | 150GB SSD | $0.004 |
| warp-ubuntu-latest-x64-4x | 4 | 16 GB | 150GB SSD | $0.008 |
| warp-ubuntu-latest-x64-8x | 8 | 32 GB | 150GB SSD | $0.016 |
| warp-ubuntu-latest-x64-16x | 16 | 64 GB | 150GB SSD | $0.032 |
| warp-ubuntu-latest-x64-32x | 32 | 128 GB | 150GB SSD | $0.064 |
Pick the size by memory per compiler process and by how wide the package graph is.
8 vCPU, 32 GB, at $0.016 per minute. This is the default for a build and test job. Give the two parallelism layers one shared budget instead of multiplying them: on GHC 9.8 with cabal 3.12, cabal build all --semaphore -j8 lets package-level and module-level parallelism draw from a single pool of 8. On older toolchains, set -j8 at the cabal level and leave ghc-options without a -j, which keeps the peak near 8 compiler heaps.
16 vCPU, 64 GB, at $0.032 per minute. Move up when the observability CPU chart shows 8 vCPUs saturated through the whole build, which in practice means dozens of local packages with independent subtrees. warp-ubuntu-latest-x64-16x (16 vCPU, 64 GB) costs $0.032 per minute against $0.042 per minute for the 16-core Linux larger runner (16 vCPU, 64 GB): 24 percent lower list price. GitHub list price is from the GitHub Actions minute multipliers reference, checked on 2026-08-13. If the chart instead shows one core busy while the rest idle, the build is walking a serial module chain and the larger machine changes nothing.
4 vCPU, 16 GB, at $0.008 per minute. hlint, fourmolu, and haddock jobs reuse the same store and rarely hold more than a couple of cores. A GHC version matrix is also better sharded across parallel 4 or 8 vCPU jobs than run sequentially on one large machine, because each version needs its own store anyway.
ARM64 is available at $0.024 per minute for the 16 vCPU size and $0.012 for 8 vCPU on the pricing page. ghcup publishes aarch64 Linux bindists, so a native ARM64 job is possible; confirm that the setup action version you pin resolves an aarch64 GHC before switching the label. The same method applied to a cargo build is written out in the guide to sizing runners for Rust builds.
Worked cost model
Take a Haskell service consuming 12,000 runner-minutes per month on 16 vCPU machines, holding 8 GB of cabal store in cache, and running 3,000 cache operations:
| Line item | Rate | Volume | Monthly cost |
|---|---|---|---|
| 16-core Linux larger runner (GitHub-hosted) | $0.042 per minute | 12,000 minutes | $504.00 |
| warp-ubuntu-latest-x64-16x | $0.032 per minute | 12,000 minutes | $384.00 |
| Cache storage | $0.20 per GB-month | 8 GB | $1.60 |
| Cache operations | $0.0001 per operation | 3,000 operations | $0.30 |
The WarpBuild total is $385.90 against $504.00 for the same minutes on the GitHub-hosted 16-core Linux larger runner, with the cache line items already included.
Every rate above is on the pricing page.
Bottlenecks
Cold store. A fresh VM has no compiled dependencies, so every transitive package in the plan builds from source before a single local module compiles. On a service with a few hundred packages in its plan this dominates the run. The cabal store plays the role that a compiler cache plays in other toolchains, and the configuration above is the fix. The dependency step is deliberately separate so the store is saved even when later steps fail.
Hackage index download. cabal update fetches the package index on a machine that has never seen it. Caching ~/.cache/cabal/packages alongside the store keeps that off the network, and an index-state pin in cabal.project keeps the cached copy usable instead of forcing a refresh.
Local build tree lost between runs. dist-newstyle and .stack-work hold the interface files and object code that make a rebuild incremental, and an ephemeral runner deletes both. A cache archive is a poor container for them: they change on every commit, so the archive is rewritten each run, and the key can only be built from inputs known before compilation starts. A snapshot runner carries the whole disk instead. The label syntax appends to the runner label:
jobs:
build:
runs-on: >-
${{ github.ref == 'refs/heads/main'
&& 'warp-ubuntu-latest-x64-8x;snapshot.enabled=true'
|| 'warp-ubuntu-latest-x64-8x;snapshot.key=haskell-main' }}snapshot.enabled=true boots from the base image, which is what a main-branch job wants before saving a clean snapshot with WarpBuilds/snapshot-save@v1. snapshot.key=<alias> boots from the existing snapshot and falls back to the base image when none exists. Three constraints apply: snapshots are supported on WarpBuild Cloud Ubuntu runners only and the labels are ignored on BYOC, Windows, and macOS runners; every snapshot is deleted after 15 days; and /tmp is cleaned on boot. Details are in the snapshot runners documentation, and the same technique for other toolchains is covered in the guide to persistent caches on GitHub Actions.
Template Haskell and optimization level. A splice runs compiled code during compilation, so every module it depends on has to be built first, and that serializes a section of the graph. -O2 compounds it by making each of those modules expensive. Build test jobs at -O1 and reserve -O2 for release jobs, and keep splice-heavy code in its own package so the rest of the graph compiles alongside it.
Memory pressure from parallel GHC. A job killed with signal 9 and no error output is usually the OOM killer, and the cause is the product of the cabal -j value and any -j in ghc-options. Use the shared semaphore, lower the parallelism, or take the next size up with double the RAM.
Proof
No public Haskell repository publishes a workflow file with warp- labels today, so the citable evidence is compile-bound builds in other statically typed languages running on the same Linux images.
near/nearcore runs the NEAR protocol node's pipeline on warp-ubuntu-2404-x64-16x for its stable, nightly, and large pytest matrix jobs, with smaller jobs on warp-ubuntu-2404-x64-8x. FuelLabs/sway builds and tests the Sway compiler across warp-ubuntu-latest-x64-4x and warp-ubuntu-latest-x64-8x jobs. Both are compiler and toolchain workloads with the same shape as a Haskell build: a large dependency graph compiled into a shared store, parallelism limited by graph width, and memory per compiler process as the real ceiling. The runner labels, cache action, and sizing arithmetic on this page are the same ones those workflows use.
FAQ
Which WarpBuild runner size should a cabal build start on?
Start the build and test job on warp-ubuntu-latest-x64-8x at $0.016 per minute, which gives 8 vCPUs and 32 GB for parallel GHC processes. Move to warp-ubuntu-latest-x64-16x at $0.032 per minute when the package graph is wide enough to keep 8 vCPUs busy through the whole build. Keep hlint, fourmolu, and haddock jobs on warp-ubuntu-latest-x64-4x at $0.008 per minute.
What cabal cache key survives a dependency bump?
Put the GHC version in the key prefix and the package set hash in the suffix: key on runner.os plus the GHC version plus a hash of **/*.cabal, cabal.project, and cabal.project.freeze, with a restore-key that stops after the GHC version. The cabal store is additive and ABI-keyed, so a bumped dependency set restores the newest store built by the same GHC and only the changed packages compile again.
Should I cache dist-newstyle or .stack-work?
No. Both directories churn on every commit, so the archive is rewritten each run, and the key can only be computed from inputs known before compilation starts. Use a snapshot runner instead, which boots the job from a disk image where dist-newstyle or .stack-work is already populated. Snapshot runners are supported on WarpBuild Cloud Ubuntu runners only and every snapshot is deleted after 15 days.
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.