Bekzod Erkinov
Listen to Article
Loading...Table of contents · 9 sections
Run GitHub Actions Locally with act: What Works and What Breaks
You can iterate on a CI workflow without pushing a commit and waiting for the hosted run: act runs your .github/workflows file in Docker on your own machine. In my test a Node 18/20 matrix with a secrets file, a Redis service container and an artifact upload passed locally once I made four changes: max-parallel: 1, a fixed 6379:6379 port mapping, 127.0.0.1 instead of the service name, and 127.0.0.1 instead of localhost. Without them, parallel matrix legs collided on the host port, redis did not resolve, job.services.redis.ports came back empty, and Node 18 got ECONNREFUSED on localhost.
Setup: Windows 11, Docker Desktop, act v0.2.89, run on 2026-10-11. Timings will differ on your machine.
docker --version
# Docker version 29.7.2, build a7dcaa6
act --version
# act version 0.2.89
Install act on Windows
I used the prebuilt binary from the releases page (the installation page lists package-manager routes too):
curl -sL -o act.zip https://github.com/nektos/act/releases/download/v0.2.89/act_Windows_x86_64.zip
unzip act.zip
In the repo, act -l lists what it found:
Stage Job ID Job name Workflow name Workflow file Events
0 test test ci ci.yml push
Pick a runner image for act
The runners page maps ubuntu-latest to node:16-buster-slim (micro), catthehacker/ubuntu:act-latest (medium) or catthehacker/ubuntu:full-latest (large), and warns that the default images "do not contain all the tools that GitHub Actions offers by default in their runners". I used medium. docker images shows it at 2.3GB (DISK USAGE) and 567MB (CONTENT SIZE), which are Docker's two figures for the same image. I did not pull or measure the large image or a hosted runner image, so I have no size comparison; the runners page gives no sizes for these three either. I passed --pull=false once images were local.
The test workflow
.github/workflows/ci.yml:
name: ci
on: push
jobs:
test:
runs-on: ubuntu-latest
strategy:
max-parallel: 1
matrix:
node: [18, 20]
services:
redis:
image: redis:7-alpine
ports:
- 6379:6379
env:
API_KEY: ${{ secrets.API_KEY }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
- run: node ping.js
env:
REDIS_HOST: 127.0.0.1
- run: echo "built on node ${{ matrix.node }}" > out-${{ matrix.node }}.txt
- uses: actions/upload-artifact@v4
with:
name: out-${{ matrix.node }}
path: out-${{ matrix.node }}.txt
ping.js (no npm packages, so nothing is dependency install):
const net = require('net');
const host = process.env.REDIS_HOST || 'localhost';
const s = net.connect(Number(process.env.REDIS_PORT) || 6379, host);
s.on('connect', () => s.write('PING\r\n'));
s.on('data', d => { console.log(`node ${process.version} -> ${host}: ${d.toString().trim()}`); s.end(); });
s.on('error', e => { console.error('connect failed:', e.code, host); process.exit(1); });
console.log('API_KEY length:', (process.env.API_KEY || '').length);
Use a .secrets file with act
Add .secrets to .gitignore first, then create it. The act usage docs say the file uses the .env format:
API_KEY=s3cr3t-local-value
The passing run
Bash:
act push -W .github/workflows/ci.yml \
-P ubuntu-latest=catthehacker/ubuntu:act-latest \
--pull=false \
--secret-file .secrets \
--artifact-server-path .artifacts
PowerShell (backslash continuation fails there; use backticks):
act push -W .github/workflows/ci.yml `
-P ubuntu-latest=catthehacker/ubuntu:act-latest `
--pull=false `
--secret-file .secrets `
--artifact-server-path .artifacts
Output:
[ci/test-1] 🧪 Matrix: map[node:18]
[ci/test-1] | API_KEY length: 18
[ci/test-1] | node v18.20.8 -> 127.0.0.1: +PONG
[ci/test-1] 🏁 Job succeeded
[ci/test-2] 🧪 Matrix: map[node:20]
[ci/test-2] | API_KEY length: 18
[ci/test-2] | node v20.20.2 -> 127.0.0.1: +PONG
[ci/test-2] 🏁 Job succeeded
The secret reached the job (length 18) and .artifacts/1/ held out-18/out-18.zip and out-20/out-20.zip. The act usage docs say "Currently the artifacts server is not started automatically with act" and enable it with --artifact-server-path, which is why the flag is there.
Timings are one run each, on this machine, with images already cached; I did not repeat them. Bash run: real 1m56.864s for the whole workflow. Step times as printed by act:
| Leg | checkout@v4 |
setup-node@v4 |
node ping.js |
upload-artifact@v4 |
|---|---|---|---|---|
| node 18 | 0.21 s | 26.8 s | 1.13 s | 9.69 s |
| node 20 | 0.16 s | 20.5 s | 0.67 s | 8.72 s |
setup-node was the slowest step even though both legs logged Found in cache @ /opt/hostedtoolcache/node/....
Service containers in act
Parallel matrix legs fight over a fixed host port
Reproduce by deleting the max-parallel line and renaming the workflow (Git Bash):
sed -e 's/^name: ci$/name: ci-par/' -e '/max-parallel/d' \
.github/workflows/ci.yml > .github/workflows/par.yml
act push -W .github/workflows/par.yml -P ubuntu-latest=catthehacker/ubuntu:act-latest \
--pull=false --secret-file .secrets --artifact-server-path .artifacts
Both legs start at once and one dies (the error is cut here; the full line also carries the endpoint ID):
[ci-par/test-1] failed to start container: Error response from daemon: failed to set up container networking: driver failed programming external connectivity on endpoint act-ci-par-test-1-...: Bind for 0.0.0.0:6379 failed: port is already allocated
[ci-par/test-1] ❌ Failure - Set up job
[ci-par/test-2] | node v20.20.2 -> 127.0.0.1: +PONG
Locally the legs share your host's ports, so two legs cannot both publish 6379:6379. max-parallel: 1 fixes it, as the passing run shows.
A failed leg leaves containers behind
In a run where leg 1 failed (REDIS_HOST: redis, see below), docker ps -a --filter name=act- afterwards showed leg 1's two containers Up and leg 2's in Created state. Leg 1's Redis container was still publishing 0.0.0.0:6379->6379/tcp, so the next run on that port would collide. That is what v0.2.89 did in my run. Clean up with:
docker ps -aq --filter name=act- | xargs -r docker rm -f
docker ps -aq --filter name=act- | ForEach-Object { docker rm -f $_ }
job.services.redis.ports was empty
GitHub's docs say that if you give only a container port, Docker assigns a host port that you read from job.services.<id>.ports. To reproduce, start from ci.yml, change the mapping to - 6379, add a debug step before node ping.js, and pass the mapped port in:
services:
redis:
image: redis:7-alpine
ports:
- 6379
steps:
# ... checkout and setup-node as before ...
- run: echo "port=[${{ job.services.redis.ports['6379'] }}] id=[${{ job.services.redis.id }}]"
- run: node ping.js
env:
REDIS_HOST: 127.0.0.1
REDIS_PORT: ${{ job.services.redis.ports['6379'] }}
Output:
[ci-port/test-1] | port=[] id=[]
[ci-port/test-1] | connect failed: ECONNREFUSED 127.0.0.1
[ci-port/test-2] | port=[] id=[]
[ci-port/test-2] | connect failed: ECONNREFUSED 127.0.0.1
In act v0.2.89 the context was empty on this machine. The fix I used is the fixed 6379:6379 mapping.
Why the service hostname redis fails
Reproduce with REDIS_HOST: redis:
sed -e 's/^name: ci$/name: ci-host/' -e 's/REDIS_HOST: 127.0.0.1/REDIS_HOST: redis/' \
.github/workflows/ci.yml > .github/workflows/host.yml
[ci-host/test-1] | connect failed: ENOTFOUND redis
GitHub's service-container page says a job running in a container reaches a service by its label, and a job running directly on the runner uses localhost:<port>. My job has no container: key, so the name was never going to resolve; use 127.0.0.1 with the mapped port. The act log shows the setup: the job container is created with network="host", while Redis is created on a separate per-job network named like act-ci-test-1-<hash>-test-network.
Node 18 ECONNREFUSED on localhost
.github/workflows/diag.yml is ci.yml without REDIS_HOST (so ping.js uses localhost), with fail-fast: false and diagnostic steps in place of the artifact steps. Two steps force the address: ::1 and 127.0.0.1.
name: diag
on: push
jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
max-parallel: 1
matrix:
node: [18, 20]
services:
redis:
image: redis:7-alpine
ports:
- 6379:6379
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
- run: getent hosts localhost
- run: node -e "require('dns').lookup('localhost',{all:true},(e,a)=>console.log(process.version, JSON.stringify(a)))"
- run: node ping.js
env:
REDIS_HOST: "::1"
continue-on-error: true
- run: node ping.js
env:
REDIS_HOST: 127.0.0.1
- run: node ping.js
continue-on-error: true
- run: node --enable-network-family-autoselection ping.js
Run it with act push -W .github/workflows/diag.yml -P ubuntu-latest=catthehacker/ubuntu:act-latest --pull=false. This is output from a fresh run of exactly this file (real 1m19.363s), trimmed to the lines that matter. Node 18.20.8 leg:
[diag/test-1] | ::1 localhost ip6-localhost ip6-loopback
[diag/test-1] | v18.20.8 [{"address":"::1","family":6},{"address":"127.0.0.1","family":4}]
[diag/test-1] | connect failed: ECONNREFUSED ::1
[diag/test-1] | node v18.20.8 -> 127.0.0.1: +PONG
[diag/test-1] | connect failed: ECONNREFUSED localhost
[diag/test-1] | node v18.20.8 -> localhost: +PONG <- with --enable-network-family-autoselection
Node 20.20.2 leg:
[diag/test-2] | v20.20.2 [{"address":"::1","family":6},{"address":"127.0.0.1","family":4}]
[diag/test-2] | connect failed: ECONNREFUSED ::1
[diag/test-2] | node v20.20.2 -> 127.0.0.1: +PONG
[diag/test-2] | node v20.20.2 -> localhost: +PONG
[diag/test-2] | node v20.20.2 -> localhost: +PONG <- with --enable-network-family-autoselection
(API_KEY length: 0 lines omitted: this run had no secrets file.) What this shows: both Node versions resolve localhost to ::1 first, and in the job nothing answers on ::1:6379 (both legs got ECONNREFUSED for an explicit ::1) while 127.0.0.1:6379 answers. Node 18 tries only the first address by default and fails; Node 20 gets to the IPv4 address. I did not investigate why nothing answers on ::1 inside the job, so treat that part as an observation, not an explanation. The Node net docs explain the Node-side difference: the autoSelectFamily option (a family autodetection algorithm loosely implementing RFC 8305, "Happy Eyeballs") was added in v18.13.0, and in v20.0.0 "the default value for the autoSelectFamily option is now true". Using 127.0.0.1 avoids the problem on both.
Other behaviour that differs from a hosted run
actions/checkoutis a copy. The log showsdocker cp src=C:\...\act-demo\. dst=/mnt/c/...and checkout took about 0.2 s. It copies your working directory, so it tests whatever is on disk.- Actions are cloned on your host.
git clone 'https://github.com/actions/setup-node' # ref=v4appeared in the log, and the files were copied from~/.cache/act/actions-setup-node@v4/. - Actions ran on a different Node than the matrix. In my log the
setup-node@v4step executed/opt/acttoolcache/node/24.19.0/x64/bin/node. I did not look into why. - Cross-run artifacts. The usage docs list downloading artifacts from a different run, workflow or repository as a not-supported v4 feature.
- Windows and macOS jobs: not tested. The runners page says that if you run act inside that OS you can opt out of Docker and run on the host, e.g.
act -P windows-latest=-self-hosted. - Actions that need hosted-only services: not tested. I only ran
checkout,setup-nodeandupload-artifact.
The medium image is not the hosted image; the tools preinstalled on hosted runners are listed in actions/runner-images, so re-verify on a hosted runner anything that leans on them.
Next step
Decision rule from these runs: run step logic, secrets handling and artifact wiring in act; leave service-container networking and anything that leans on tools from the hosted image to a hosted run, or re-verify there after it passes locally. To start, copy your workflow into a repo, put .secrets in .gitignore, set max-parallel: 1 and 127.0.0.1 for any service container, and run the block from "The passing run" with your workflow path in -W. If a leg fails, run the cleanup command before the next attempt.
Keep reading
Complete Solution: Scaling a Node.js Application with Kubernetes and Docker
29 min · 902 views
Data ScienceMastering CI/CD Pipelines with Jenkins and Docker: A Deep Dive into Automated Deployment and Testing
14 min · 887 views
DevOpsDocker Layer Caching Strategies That Actually Work
23 min · 248 views
Bekzod Erkinov
AuthorFounder of NextGenBeing. Software engineer working with Laravel, Python, and cloud infrastructure. Writes about patterns that actually hold up in production. Based in Tashkent, Uzbekistan.
Get the AI-Assisted Developer's Field Guide
The workflow, prompts, and tools I use to ship faster with AI — free when you subscribe. Plus new deep-dives in your inbox. No spam, unsubscribe anytime.
Comments (0)
Please log in to leave a comment.
Log In