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.

How act reproduces service containers Three stages: act creates a job network, starts the postgres and redis sidecars on it, then runs the job steps that connect by service label. Same Network, Same Hostnames create job network shared bridge per workflow run start sidecars postgres, redis labels as hostnames run job steps connect to postgres reproduce failure act mirrors the runner's job-scoped service network.
act stands the sidecars up on a job-scoped network so the label hostnames resolve exactly as they do on a hosted runner.

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.

Runner service networking versus a broken local run Comparison of a faithful hosted-runner setup against the common local misconfigurations across four rows. Faithful vs Broken Reproduction Hosted runner (works) shared job network label host: postgres:5432 health check gates start job waits until ready connects cleanly Broken local run old act ignores services hard-coded localhost:5432 no health check, race step runs before ready connection refused
Every local failure maps to one of three causes: the sidecar is not started, not ready, or addressed on the wrong network path.

Resolution

  1. Update act and confirm it plans the service containers. A build old enough to ignore services: 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"
  1. Map the runner label to a full image in a committed .actrc so 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
  1. Address every service by its label hostname, never localhost, from steps that run inside the job. The DATABASE_URL and REDIS_URL in the workflow already use postgres and redis; audit your test configuration for any 127.0.0.1 or localhost left 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 under act.

  2. 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 and act gate 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
  1. 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-file keeps 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
Resolution sequence for local service parity Four ordered steps from confirming act plans the services to running the job with files and reading the result. Four Steps To Service Parity 1 — confirm act plans the services 2 — address services by label, not localhost 3 — add health checks + a wait step 4 — run with secret + var files
Each step removes one of the three divergences: not started, not ready, or addressed on the wrong network path.

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

  1. Wrap the invocation in a make ci-local target 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
  1. 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-cmd in options: is the portable contract.

  2. Pin the runner label to a fixed version (ubuntu-22.04, not ubuntu-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 floating postgres:15-alpine can 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.

Time to surface a service failure by method Bar chart comparing seconds to see a service-connection failure across push-and-wait, a cold act run, and a warm act run. Time To Surface The Failure (seconds) push + wait 210s act (cold) 85s act (warm) 18s
A warm act run with the sidecar images already pulled turns a multi-minute push cycle into a sub-twenty-second check.

Platform caveats

Apple Silicon (ARM64): add --container-architecture linux/amd64 so act pulls the amd64 job and service images GitHub actually runs; the arm64 postgres or redis variant can hide an architecture-specific init difference that only bites on the x86 runner. WSL2: point act at the Linux Docker socket and keep the repository on the ext4 filesystem. Running against /mnt/c causes 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. Run docker system prune if act aborts while pulling postgres: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.