Contents

Act: Running GitHub Actions Locally Before You Push

Stop using your CI as a slow, public debugger

Contents

We have all done the thing. You tweak a workflow file, you can’t run it locally, so you push a commit called “fix ci”, watch it fail, push “fix ci 2”, fail, “actually fix ci”, fail, and by the time it’s green your git history reads like a cry for help. The feedback loop on GitHub Actions is dreadful precisely because the only way to test it is to use the production system itself, slowly, in public. act fixes that by running your workflows locally in Docker.

How act works

Advertisement

act reads your .github/workflows/*.yml files, builds the dependency graph of jobs, and executes them inside containers chosen to approximate GitHub’s runners. It pulls the same actions from the marketplace, sets up the same GITHUB_* environment variables, and runs your steps. It is not a perfect emulator — more on that honesty below — but it catches the overwhelming majority of “I made a typo in my YAML” and “this step’s command is wrong” mistakes in seconds instead of minutes.

Installation is a single binary. On most systems:

1
2
3
4
5
# macOS / Linux via the official install script
curl -s https://raw.githubusercontent.com/nektos/act/master/install.sh | sudo bash

# or via your package manager
brew install act          # macOS

The only hard requirement is a working Docker (or Podman) daemon, because every job runs in a container. If you’ve already gone the rootless route, act runs perfectly happily against Podman — point DOCKER_HOST at the Podman socket and it never knows the difference. I cover why you might want that daemonless setup in running containers without Docker; for act it’s a transparent swap.

First run and picking a runner image

The first time you run act, it asks which size of runner image you want. This matters more than it looks. The default act images are stripped down to keep them small; the full GitHub-equivalent images are enormous (20+ GB) because they ship every tool GitHub preinstalls. I use the medium image and accept that I occasionally have to install a tool myself.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# List what act thinks it will run, without executing
act -l

# Run the default push event
act push

# Run a specific job by name
act -j build

# Pin a runner image so behaviour is reproducible
act -P ubuntu-latest=catthehacker/ubuntu:act-22.04 push

That -P flag maps a GitHub runner label to a Docker image. I keep these mappings in a .actrc file at the repo root so everyone on the team gets the same images without remembering the flag:

1
2
3
-P ubuntu-latest=catthehacker/ubuntu:act-22.04
-P ubuntu-22.04=catthehacker/ubuntu:act-22.04
--container-architecture linux/amd64

That last line is essential on Apple Silicon — without it, half the marketplace actions fall over because they ship amd64-only binaries and the arm64 emulation confuses them.

Secrets, events, and the bits that differ

Advertisement

Real workflows need secrets and react to specific event payloads. act handles both. Secrets come from a file or flags, and you can feed a synthetic event JSON to test, say, a pull-request workflow without opening a pull request:

1
2
3
4
5
# Provide secrets from a gitignored file
act -j deploy --secret-file .secrets

# Simulate a pull_request event with a crafted payload
act pull_request -e event.json

Where event.json is a trimmed-down version of the webhook payload GitHub would send. You only need the fields your workflow actually reads. This is brilliant for debugging the if: conditions that gate jobs by branch or label, which are otherwise a nightmare to test because triggering them for real means manufacturing the exact event. A minimal payload for testing a “only run on PRs targeting main” guard is just:

1
2
3
4
5
6
{
  "pull_request": {
    "base": { "ref": "main" },
    "head": { "ref": "feature/x" }
  }
}

Feed that to act pull_request -e event.json and you can watch the conditional resolve without ever opening a real PR.

For variables and inputs, act mirrors the GitHub model closely. Repository and organisation variables (the non-secret vars.* context) come from --var-file .vars or repeated --var KEY=value flags; workflow_dispatch inputs come from --input. Keeping the secret and non-secret files separate, both gitignored, mirrors how you’d structure the real repository settings:

1
2
3
4
5
# Run a manually-dispatchable workflow with inputs and split secret/var files
act workflow_dispatch \
  --input environment=staging \
  --secret-file .secrets \
  --var-file .vars

Speed, caching, and reusing your shell

The first act run is slow because it pulls a multi-gigabyte runner image. After that, the image is cached and only your job runs. Two flags matter for keeping the loop tight. --reuse (or -r) keeps the job container alive between runs instead of destroying and recreating it, which turns a ten-second startup into nothing — invaluable when you’re iterating on a single failing step. And --bind mounts your working directory straight into the container rather than copying it, so edits land instantly.

1
2
# Tight inner loop: keep the container, bind the workdir, run one job
act -j test --reuse --bind

The trade-off is honesty about state: a reused container carries side effects from the previous run, so when something behaves inexplicably, drop --reuse for one clean run to rule out leftover state. It’s the same discipline as a clean-room build — fast iteration most of the time, a from-scratch run to confirm. Catching mistakes this early is the same philosophy as pre-commit hooks: the cheaper and earlier the feedback, the less it costs you.

A realistic first session

The shape of a real debugging session with act is worth walking through, because it’s where the value becomes obvious rather than theoretical. Say you’ve written a workflow that lints, tests, and builds, and the test job is mysteriously failing on GitHub but you can’t reproduce it. The old loop is: edit, commit, push, wait two minutes, read logs, repeat. With act it’s:

1
2
3
4
5
6
7
8
9
# What jobs exist and what triggers them?
act -l

# Run only the failing job, keeping the container so re-runs are instant
act -j test --reuse --bind

# It fails. Drop into the container as it was, and poke around
docker exec -it $(docker ps -lq) bash
#   ls, cat the file the step expected, check $PATH, run the failing command by hand

That last move — opening a shell inside the exact environment the step ran in — is the thing CI normally denies you. On GitHub you get logs and nothing else; the runner is gone the moment the job ends. Locally the container is right there, and ninety percent of the “works on my machine, fails in CI” mysteries dissolve the instant you can cat the file the step couldn’t find. More often than not the answer is a path assumption, a missing tool the default image didn’t ship, or an environment variable you set in your shell but never in the workflow.

Composite actions and artifacts

Two slightly more advanced features behave well enough locally to be worth testing there. Composite actions — your own reusable action.yml made of steps — run under act exactly as they do on GitHub, so if you maintain shared actions across repos, act is the fastest way to validate a change before every consumer repo inherits the breakage. Artifacts (actions/upload-artifact / download-artifact) work too, but they need somewhere to live: act writes them to a local path you point at with --artifact-server-path.

1
2
# Exercise upload/download-artifact steps against a local artifact store
act -j build --artifact-server-path /tmp/act-artifacts

The honest caveat is that the artifact server is a local approximation of GitHub’s, so cross-job artifact passing works but the retention and download-URL semantics don’t match. Good enough to prove your upload/download steps are wired correctly; not a substitute for the real thing if your workflow does something exotic with artifact metadata.

Where it lies to you

Honesty time, because act will eventually bite you if you trust it blindly. It is an approximation, and the gaps are real. The runner images are not byte-identical to GitHub’s, so a step relying on a preinstalled tool may pass on GitHub and fail locally, or vice versa. services: containers work but networking can differ. Anything that calls the GitHub API for real — creating releases, posting comments, OIDC token exchange for cloud auth — either needs a token or simply can’t be exercised locally. And matrix builds run sequentially rather than in parallel, so timing-dependent behaviour won’t surface.

The way I treat it: act is for validating workflow logic — syntax, step ordering, conditionals, that your scripts run — not for certifying that a deploy will succeed against live infrastructure. It moves the cheap, common failures left to your laptop and leaves the genuinely environment-specific ones to the real runner. That is exactly the right division of labour.

A useful mental model: act answers “is my workflow well-formed and internally correct?” and GitHub answers “does it work against the real world?” Those are different questions, and conflating them is how people get burned. The YAML typo, the step that references an output that doesn’t exist, the conditional that never fires, the script with a bad path — act catches all of those on your laptop in seconds. The expired cloud credential, the rate-limited API, the runner image that quietly changed last Tuesday — those only ever show up on the real runner, and no local tool will save you from them. Knowing which bucket a given failure lives in is most of the skill of using act well, and it stops you from either over-trusting a green local run or wasting time trying to reproduce an inherently-remote failure on your machine.

Is it worth it?

If you write or maintain GitHub Actions workflows of any complexity, absolutely. The amount of “fix ci” commit-spam it eliminates pays for the setup ten times over in the first week, and your colleagues stop having to scroll past your failed-pipeline history. The cost is a Docker dependency and the discipline to remember it’s an approximation rather than gospel.

It is not worth it if your workflows are three trivial steps that never break, and it won’t help you debug a deploy that only fails against production. But for the iterative grind of getting a non-trivial pipeline working, running it locally first is the obvious move, and I’m mildly annoyed it took me as long as it did to adopt. Keep a .actrc checked in, treat green-locally as “probably fine” rather than “guaranteed”, and you’ll push far fewer embarrassing commits. The one habit that makes it stick is running act before you push by reflex, the way you’d run the test suite — not as a thing you remember to do only after the third red pipeline. Wire it into your muscle memory and the “fix ci” graveyard simply stops appearing in your history, which is reason enough on its own.

Advertisement
Advertisement
Smarc
Written by Smarc

Founder and editor of vo.rs. A lifelong tinkerer who self-hosts far more than is sensible, hardens Linux boxes for fun, and prods the latest AI tools to see what they can really do. The how-to guides here are the notes Smarc wishes had existed the first time round.