DevOps & Platform

How to Test GitHub Actions Locally With act

Beginner20 min to complete7 min readSeptember 17, 2026

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.

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
GitHub Actions
act
CI/CD
Docker
DevOps

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

  • act installed and working
  • Your first workflow running locally against a simulated push event
  • A specific job targeted directly, instead of the whole workflow file
  • A pull_request event 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:

bash
brew install act

On Linux, the official install script drops the binary into /usr/local/bin:

bash
curl --proto '=https' --tlsv1.2 -sSf https://raw.githubusercontent.com/nektos/act/master/install.sh | sudo bash

On Windows, via Chocolatey:

powershell
choco install act-cli

Verify it's on your PATH:

bash
act --version

act drives everything through the Docker daemon, so confirm Docker is actually running before you go further:

bash
docker info

Step 2: List the Jobs act Discovers

From the root of a repo that has a .github/workflows/ directory:

bash
act -l

This 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:

bash
act

The 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:

bash
act -j build

The 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:

json
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:

bash
act pull_request -e event.json

Now 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:

bash
act --secret-file .secrets

Where .secrets looks like a .env file:

NPM_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxx
DOCKERHUB_PASSWORD=xxxxxxxxxxxxxxxx

Add .secrets to .gitignore before you create it, not after:

bash
echo ".secrets" >> .gitignore

A 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:

bash
act -j build -v

This 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:

bash
act --container-architecture linux/amd64

It 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

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.