How to Test GitHub Actions Locally With act
Quick answer
Stop pushing broken YAML just to find out it's broken. Run your GitHub Actions workflows in a local Docker container with act and see the failure in seconds instead of three minutes and a red X on your PR.
- Step 1: Install act
- Step 2: List the Jobs act Discovers
- Step 3: Run Your First Workflow Locally
- Step 4: Run a Specific Job
- Step 5: Simulate a Different Event
beginner · 20 min
Before you begin
- Docker Desktop (or Docker Engine) installed and running
- A repo that already has at least one `.github/workflows/*.yml` file
- Basic familiarity with GitHub Actions YAML syntax
The usual loop for iterating on a GitHub Actions workflow looks like this: edit the YAML, commit, push, wait for a runner to pick up the job, wait for it to fail on a typo you'd have caught in two seconds if you could just run it, fix the typo, push again. Multiply that by however many times it takes to get a workflow right, and you've burned real minutes on nothing but waiting.
act runs your workflows locally, in Docker, using the same .github/workflows/*.yml files GitHub does. No push required. A workflow that takes three minutes on a hosted runner comes back in seconds on your machine, because you're not waiting in a queue and you're not re-cloning the repo over the network.
Under the hood, act parses your workflow file the same way GitHub's own runner does, spins up a Docker container that stands in for the hosted runner environment, and executes each steps: entry inside it — including third-party actions pulled straight from the Marketplace, which act clones and runs as-is. From the workflow's point of view, it's running on "a runner." It just happens to be a container on your laptop instead of a VM in GitHub's infrastructure.
What You'll Build
actinstalled and working- Your first workflow running locally against a simulated
pushevent - A specific job targeted directly, instead of the whole workflow file
- A
pull_requestevent simulated from a JSON payload, so you can test PR-triggered logic without opening a PR - Secrets passed in safely, without committing anything sensitive
Step 1: Install act
On macOS, via Homebrew:
brew install actOn Linux, the official install script drops the binary into /usr/local/bin:
curl --proto '=https' --tlsv1.2 -sSf https://raw.githubusercontent.com/nektos/act/master/install.sh | sudo bashOn Windows, via Chocolatey:
choco install act-cliVerify it's on your PATH:
act --versionact drives everything through the Docker daemon, so confirm Docker is actually running before you go further:
docker infoStep 2: List the Jobs act Discovers
From the root of a repo that has a .github/workflows/ directory:
act -lThis prints every job act found, which event triggers it, and which workflow file it came from — without running anything. It's the fastest way to confirm act is parsing your workflows correctly before you commit to a full run.
Step 3: Run Your First Workflow Locally
With no arguments, act simulates a push event, which is the default trigger for most CI workflows:
actThe first time you run it, act asks which default runner image size you want:
? Please choose the default image you want to use with act:
- Micro (~200MB)
- Medium (~500MB)
- Large (~17GB)
Medium is the right default for almost everyone — it has the common toolchain (Node, Python, a recent-enough Git) without a 17 GB download. Large is closer to what GitHub's hosted runners actually have preinstalled, but you'll wait a long time on the first pull and it eats a lot of disk. Start with Medium and only reach for Large if a workflow step fails locally because a tool it assumes is preinstalled genuinely isn't in the smaller image. Your answer is saved to ~/.actrc so you're only asked once.
Step 4: Run a Specific Job
Most repos have more than one job per workflow, and you rarely want to run all of them while you're debugging one. Target a single job by name:
act -j buildThe job name here is whatever comes after jobs: in your YAML (e.g. build:, test:, lint:), not the human-readable name: field. act -l from Step 2 shows you the exact job IDs available.
Step 5: Simulate a Different Event
Plenty of workflows trigger on pull_request, release, or workflow_dispatch rather than push, and some steps read fields out of the event payload (a PR title, a tag name, an input). act lets you hand it a JSON file shaped like the real GitHub event payload:
1{
2 "pull_request": {
3 "number": 42,
4 "title": "Add local CI testing docs",
5 "head": {
6 "ref": "feature/local-ci-docs"
7 },
8 "base": {
9 "ref": "main"
10 }
11 }
12}Save that as event.json, then run:
act pull_request -e event.jsonNow any step that reads ${{ github.event.pull_request.title }} or similar sees the values you put in the file, so you can exercise PR-only logic without ever opening a real pull request.
Step 6: Pass Secrets Safely
Workflows that reference ${{ secrets.SOME_TOKEN }} need that value available locally too, and you should never hardcode it into the YAML or export it into your shell history. act reads a secrets file:
act --secret-file .secretsWhere .secrets looks like a .env file:
NPM_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxx
DOCKERHUB_PASSWORD=xxxxxxxxxxxxxxxx
Add .secrets to .gitignore before you create it, not after:
echo ".secrets" >> .gitignoreA secrets file with real credentials sitting in a repo, even briefly, is exactly the kind of mistake this step exists to prevent.
Step 7: Understand GITHUB_TOKEN
act automatically provides a placeholder GITHUB_TOKEN so steps that reference it don't outright crash, but that placeholder can't actually call the GitHub API — it's not a real, scoped token. If a step needs to genuinely hit the API (creating a check run, commenting on a PR, pushing a tag), pass a real personal access token through the same secrets file mechanism from Step 6 and reference it as secrets.GITHUB_TOKEN in your workflow, or via act -s GITHUB_TOKEN=ghp_... for a one-off run.
Step 8: Debug a Failing Step With Verbose Output
When a step fails and the default output doesn't tell you why, add -v for verbose logging:
act -j build -vThis surfaces act's own internal steps too — which image it pulled, how it mounted your workspace, the exact environment variables it injected — on top of your workflow's normal step output. It's noisy, but it's usually enough to tell "my shell script is wrong" apart from "act's environment doesn't match what this step expects," which are two very different problems with two very different fixes.
If you just want to poke around inside the same container act would run your job in, --reuse (covered below) keeps it alive after the run so you can docker exec into it directly.
Common Issues
A step passes on GitHub but fails locally, or the reverse — act's runner images are not bit-identical to GitHub's hosted runners. The Medium image is deliberately smaller and doesn't carry every language version and CLI tool GitHub preinstalls. If a step assumes something is already on PATH, check whether it's actually in the image you chose, or install it explicitly as an earlier step in the workflow.
A step that posts to the GitHub API silently does nothing or errors — this is usually the placeholder GITHUB_TOKEN from Step 7. Anything that needs to genuinely reach GitHub (PR comments, check runs, releases) needs a real token passed in as a secret.
Matrix builds take much longer locally than you'd expect — act runs each matrix combination in its own container, and container startup isn't free. A 3×3 matrix means nine container spin-ups in sequence by default, which adds up fast on a laptop.
Apple Silicon Macs fail to pull certain images — some third-party action images only publish linux/amd64 builds. Force emulation with:
act --container-architecture linux/amd64It runs under Rosetta/QEMU and is slower, but it works.
Frequently Asked Questions
Is act a perfect replica of GitHub's hosted runners?
No, and it's worth being honest about that up front. act gets you close enough to catch the overwhelming majority of mistakes — bad YAML syntax, wrong job dependencies, broken shell logic, missing env vars — before you push. It won't catch everything a hosted runner's exact toolchain and network environment would. Treat a clean local run as high confidence, not a guarantee.
Can act run actions that require a self-hosted runner?
Not meaningfully. Actions or steps written specifically around self-hosted-runner infrastructure (accessing an internal network, a runner-local cache only the real self-hosted machine has) won't behave the same way in a generic local Docker container, because that infrastructure simply isn't there.
Does act cost anything to use?
No. act is free and open source (MIT licensed), and everything it does runs entirely on your own machine through your local Docker daemon — no calls back to GitHub, no usage limits, no account required.
How do I speed up repeated runs?
Docker's layer caching already helps a lot once you've pulled the runner image once. For iterating on the same job repeatedly, the --reuse flag keeps containers around between runs instead of tearing them down and rebuilding each time, which cuts a meaningful chunk of the startup overhead.
Official References
- nektos/act on GitHub — source, issues, and the full flag reference
- act documentation — installation, usage guide, and runner image details
- act: Events — supported event types and payload structure
- act: Secrets — every way to pass secrets into a local run
Once your workflow passes locally, the next place to tighten things up is the pipeline itself — see Securing CI/CD Pipelines With OIDC for replacing long-lived cloud credentials in your Actions workflows with short-lived tokens.
We built Podscape to simplify Kubernetes workflows like this — logs, events, and cluster state in one interface, without switching tools.
Struggling with this in production?
We help teams fix these exact issues. Our engineers have deployed these patterns across production environments at scale.