Running GitHub Actions Service Containers Locally
A job that talks to a postgres or redis service container passes every time in GitHub Actions but throws could not connect to server: Connection refused the moment you try to run the same workflow on your laptop — or the reverse, where it works locally and only the runner fails. This guide is part of CI/CD pipeline parity checks within the environment sync, secrets and CI parity baseline, and it closes the gap left by reproducing CI-only test failures locally with act: once you can run the workflow at all, the next thing that diverges is how the sidecar databases are networked and how the job waits for them to become ready. The fix is to make nektos/act stand up the same service containers on the same job network so a connection or readiness failure reproduces under your fingers instead of only in the runner logs.
Diagnostic
Run the workflow locally with act and watch how it treats the services: block. On a GitHub-hosted runner, each entry under services: becomes a sidecar container attached to the job's Docker network and addressable by its label as a hostname — postgres, redis — on its container port. act reproduces that, but only if your build supports service containers and your job addresses the service the way the runner does. First confirm the tool version, then run the failing job.
#!/usr/bin/env bash
set -euo pipefail
# Service-container support has been stable since 0.2.40; confirm yours.
act --version
# Dry run: list the jobs act would execute without starting containers.
act -n -W .github/workflows/ci.yml
# Run the job that connects to the service containers.
act push -j test -W .github/workflows/ci.yml
The workflow under test declares two services with health options and addresses them by label, which is the shape a real runner expects:
# .github/workflows/ci.yml
name: CI
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:15-alpine
env:
POSTGRES_PASSWORD: devpass
POSTGRES_DB: app_test
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U postgres"
--health-interval 5s
--health-timeout 5s
--health-retries 5
redis:
image: redis:7-alpine
ports:
- 6379:6379
options: >-
--health-cmd "redis-cli ping"
--health-interval 5s
--health-timeout 5s
--health-retries 5
env:
DATABASE_URL: postgres://postgres:devpass@postgres:5432/app_test
REDIS_URL: redis://redis:6379/0
steps:
- uses: actions/checkout@v4
- name: Run tests
run: |
npm ci
npm test
Expected BAD output (the connection fails locally the way it does in CI):
[CI/test] 🐳 docker run image=ghcr.io/catthehacker/ubuntu:act-latest ...
[CI/test] ✅ Success - Set up job
[CI/test] ❌ Failure - Main Run tests
[CI/test] exitcode '1': psql: error: connection to server at "postgres" (172.18.0.3), port 5432 failed: Connection refused
Error: Job 'test' failed
That reproduction is the whole point: the same Connection refused the runner reported now appears locally in seconds, so you can iterate against it instead of pushing speculative fixes and waiting for the runner to grade them.
Root cause
On a GitHub-hosted runner, the job and its service containers share a user-defined Docker network that the runner creates for that job. Docker's embedded DNS then resolves each service's label to its container IP, so postgres:5432 and redis:6379 work from inside the job without any port mapping. The ports: mapping in the workflow is a second, separate path — it publishes the service to the runner host's localhost, which matters only for steps that run directly on the host rather than inside the job container. Two hostnames, two code paths, and a job that hard-codes localhost instead of the service label will work on the runner by accident and hide the moment the network topology changes.
act reproduces the correct topology — a per-run network with the sidecars attached by label — but three things commonly break the reproduction. First, an act build older than service-container support silently ignores the services: block, so nothing listens on postgres:5432 and every connection is refused. Second, a service with no health check starts, but the job's first step races ahead and connects before Postgres has finished its init and is accepting connections, producing an intermittent Connection refused that looks like a flaky test. Third, running act with the --bind flag or against a service addressed as localhost changes which of the two network paths is in play, so the job reaches a port that is published to the host but not to the job container, or vice versa.
The divergence is always one of those three: the service is not started at all, it is started but not yet ready, or it is reachable on a different address than the job uses. Each has a distinct signature. "Not started" fails immediately and identically on every run. "Not ready" fails only on cold starts and passes on warm ones. "Wrong address" fails deterministically but flips depending on whether the step runs in the container or on the host. Naming which signature you see tells you which alignment to fix, and the resolution below fixes all three so the local run and the runner agree.
Resolution
- Update
actand confirm it plans the service containers. A build old enough to ignoreservices:will run the job with nothing listening, so start by ruling that out. The dry run prints the resolved services alongside the steps.
#!/usr/bin/env bash
set -euo pipefail
# Upgrade via your package manager, or the official installer, then verify.
act --version
act -n -W .github/workflows/ci.yml | grep -i service || echo "No services planned — check act version and the services: block"
- Map the runner label to a full image in a committed
.actrcso the container matches CI and every engineer reproduces identically. This is the same discipline the sibling guide on reproducing CI-only test failures locally with act applies to the job image; it applies just as much to service parity, because the job container is what resolves the service DNS.
# .actrc
-P ubuntu-latest=ghcr.io/catthehacker/ubuntu:act-latest
-P ubuntu-22.04=ghcr.io/catthehacker/ubuntu:act-22.04
Address every service by its label hostname, never
localhost, from steps that run inside the job. TheDATABASE_URLandREDIS_URLin the workflow already usepostgresandredis; audit your test configuration for any127.0.0.1orlocalhostleft over from an earlier host-based setup, because that is the single most common reason a job passes on the runner by luck and fails underact.Give every service a health check in
options:and add an explicit wait step so the job never races an un-ready database. The health options let both the runner andactgate the service as healthy before steps begin; the wait loop is belt-and-suspenders for the cold-start case.
# .github/workflows/ci.yml (excerpt — add the wait step)
- name: Wait for services to accept connections
run: |
until pg_isready -h postgres -U postgres; do
echo "waiting for postgres..."; sleep 1;
done
until redis-cli -h redis ping | grep -q PONG; do
echo "waiting for redis..."; sleep 1;
done
- Run the job with the secrets and vars supplied from files, so only the values the workflow declares are present, then read the result. Feeding a
--secret-filekeeps host-only variables from masking or inventing a failure, the same clean-environment property covered in catching missing env vars before container startup.
#!/usr/bin/env bash
set -euo pipefail
act push -j test \
--secret-file secrets.env \
--var-file vars.env \
-W .github/workflows/ci.yml
Expected output
With the services planned, addressed by label, and gated on health, the local run brings the sidecars up, waits for them, and passes:
[CI/test] 🐳 docker run image=ghcr.io/catthehacker/ubuntu:act-latest ...
[CI/test] 🩺 Health check: postgres (healthy)
[CI/test] 🩺 Health check: redis (healthy)
[CI/test] ✅ Success - Wait for services to accept connections
[CI/test] ✅ Success - Run tests
[CI/test] 🏁 Job succeeded
Read the two health lines, not only the final result. They confirm act started both sidecars and that Docker reported them healthy before any step ran — which means the reproduction covered the readiness path, not just the connection path. If the health lines are absent, act never registered the services: re-check the act --version, and confirm the services: block is nested under the job and not accidentally at the top level of the workflow. A job that succeeds without ever printing a health line is connecting to something other than the sidecar you think you are testing, and it will diverge from the runner the next time timing changes.
Prevention
- Wrap the invocation in a
make ci-localtarget so nobody has to remember the flag set, and so the service-backed reproduction is a single command that new engineers run on day one.
ci-local:
act push -j test \
--secret-file secrets.env \
--var-file vars.env \
-W .github/workflows/ci.yml
Keep the health check in the workflow
options:rather than only in a local wait script, so the readiness gate travels with the workflow and protects the runner too. A wait loop that lives only on your laptop cannot help the next engineer or the runner; the--health-cmdinoptions:is the portable contract.Pin the runner label to a fixed version (
ubuntu-22.04, notubuntu-latest) and pin the service images by tag or digest, so the local and remote sidecars stay aligned as upstream images move. This is the same digest discipline the parent guide applies to base images; a floatingpostgres:15-alpinecan shift its default collation or init behavior under you and reintroduce drift the health check will not catch.
The chart below shows why moving the readiness failure onto your laptop is worth the setup: a cold act run surfaces a broken service connection in a fraction of the time a push-and-wait cycle takes, and a warm run is faster still.
Platform caveats
Apple Silicon (ARM64): add
--container-architecture linux/amd64soactpulls the amd64 job and service images GitHub actually runs; the arm64postgresorredisvariant can hide an architecture-specific init difference that only bites on the x86 runner. WSL2: pointactat the Linux Docker socket and keep the repository on the ext4 filesystem. Running against/mnt/ccauses spurious permission failures in the sidecar's data volume that never occur on the real runner, which reads as a false service failure. macOS (Docker Desktop): the sidecar images plus a runner image can exhaust the VM disk. Rundocker system pruneifactaborts while pullingpostgres:15-alpine, and raise the VM disk and memory allocation if the health check times out under load.
Rollback
act runs the job and its services in throwaway containers on a per-run network, so a run leaves nothing behind in your repository. The two things that can linger are the service containers if a run is killed mid-flight and the network they were attached to. Clean both up and reclaim the pulled images if you no longer need them:
#!/usr/bin/env bash
set -euo pipefail
# Remove any stopped act service containers and the dangling job network.
docker container prune -f
docker network prune -f
# Optional: drop the pulled sidecar images to reclaim disk.
docker image rm postgres:15-alpine redis:7-alpine || true
If a workflow edit you made while iterating turns out to be wrong, revert it with git restore .github/workflows/ci.yml before committing, so the only change that lands is the one that made the local and remote runs agree.
Frequently Asked Questions
Why does my job reach postgres in CI but not under act?
Almost always because the job addresses the service as localhost instead of by its label. On a hosted runner the ports: mapping publishes the service to the runner host, so a step running on the host can reach localhost:5432 by luck. Inside the job container — where act and modern runners execute steps — the correct address is the service label, postgres:5432. Change your DATABASE_URL to use the label and the local run matches the runner.
Does act start service containers automatically?
Yes, for any services: block, provided your act build supports them — support has been stable since 0.2.40. An older build silently ignores the block and runs the job with nothing listening, so every connection is refused. Run act --version first, and use act -n to confirm the services appear in the plan before you spend time debugging the connection.
How do I make act wait for postgres to be healthy?
Put a health check in the service's options: (--health-cmd "pg_isready -U postgres" plus interval, timeout, and retries) so both act and the runner gate the service as healthy before steps begin. Add an explicit until pg_isready -h postgres; do sleep 1; done wait step as well, which covers the cold-start race where the container is up but Postgres has not finished initializing.
Can I reach the service from my host shell while act is running?
Yes, if the workflow maps the port. A ports: - 5432:5432 entry tells act to publish the sidecar to your host, so you can connect a psql client to localhost:5432 to inspect state while the job runs. This is only for host-side debugging — the job itself still connects over the internal network by label, so do not rewrite the job to use localhost just because the host mapping works.