How to Run Integration Tests With Service Containers in CI
Run integration tests against real databases in CI with service containers: GitHub and GitLab syntax, health checks, ports and host names.

Tests that mock the database pass until the day the query has a syntax error the mock never saw. A real Postgres or Redis in CI costs little and catches those. CI systems start such dependencies for you as service containers, and the only tricky parts are readiness and addressing. This post covers both for the two common platforms, as the CI side of throwaway databases. Examples use PostgreSQL 17 and Redis 7.
GitHub Actions
jobs:
integration:
runs-on: ubuntu-24.04
services:
postgres:
image: postgres:17
env:
POSTGRES_PASSWORD: postgres
POSTGRES_DB: app_test
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U postgres"
--health-interval 5s
--health-timeout 3s
--health-retries 10
redis:
image: redis:7
ports:
- 6379:6379
options: --health-cmd "redis-cli ping" --health-interval 5s --health-retries 10
env:
DATABASE_URL: postgres://postgres:postgres@localhost:5432/app_test
REDIS_URL: redis://localhost:6379
steps:
- uses: actions/checkout@v4
- run: ./scripts/migrate.sh
- run: ./scripts/integration-tests.shThe runner starts each service and, because of the health options, waits until the check passes before the first step. Without a health command, the job begins as soon as the container starts, and the first test fails with "connection refused" while the database is still initializing. Because the job runs directly on the runner, services are reached on localhost at the mapped port. Writing 5432:5432 fixes the host port; writing just 5432 picks a random one, readable from ${{ job.services.postgres.ports['5432'] }}.
A job that runs in a container
container: node:22
env:
DATABASE_URL: postgres://postgres:postgres@postgres:5432/app_testWhen the job itself runs in a container, it joins the same Docker network as the services, and the host name is the service label (postgres), not localhost, and no port mapping is needed. Mixing the two modes is the most frequent source of "works locally, not in CI". Service containers are supported on Linux runners only, not macOS or Windows.
GitLab CI
integration:
image: node:22
services:
- name: postgres:17
alias: db
variables:
POSTGRES_PASSWORD: postgres
variables:
DATABASE_URL: postgres://postgres:postgres@db:5432/postgres
script:
- ./scripts/wait-for-db.sh db 5432
- ./scripts/integration-tests.shGitLab services share a network with the job and are reached by the alias. They have no built-in health wait, so the job's script must wait: poll with pg_isready or a TCP check, as in waiting for a service, before it runs the tests.
Make the tests reliable
- Create a fresh schema per run, or reset between tests with transactions, so state does not leak.
- Pin the image version to production's major version;
latestbreaks tests when upstream releases. - Use tmpfs for the data directory if speed matters; it is throwaway anyway.
- Print the service logs on failure, for example
docker logsin anif: failure()step.
Where this does not apply
Services that need cloud features (a managed queue, object storage with IAM) are better replaced by local emulators or tested in a staging environment. Very heavy services slow every run; split integration tests into their own job, so the quick unit tests report first. For the shell side of testing against disposable services, see testing scripts with network and files, and the related fail-fast CI scripts.
How the job reaches the service
The hostname you use depends on where the test process runs. When the job runs directly on the runner machine and the service runs as a container beside it, the service's port is published to the runner, and the tests connect to localhost and the mapped port. When the job itself runs inside a container, both containers join a shared network, and the tests connect by the service's label as the hostname, with the container's own port. Mixing the two is a classic cause of connection refused errors, such as using localhost from inside a job container, where it points at the job container and finds nothing. GitHub Actions makes the first case explicit with a ports mapping, and the second case needs no mapping. GitLab CI always uses the second style, and derives the hostname from the image name unless you set an alias, replacing slashes and colons in a way that is easy to guess wrong, so setting an alias removes the doubt.
Print the connection settings at the start of the test job, as environment variables the tests read, so the same tests run against a local container, a CI service and a remote database by changing the variables.
Readiness and the race at startup
A service container is started before the job's steps run, and it is not necessarily ready to accept connections when the first step begins. A database can take several seconds to initialize, and the first test then fails with a connection error that passes on a rerun. Give the service a health check with the options key in GitHub Actions, using flags such as --health-cmd, --health-interval and --health-retries, and the runner waits for it to pass before it starts the job. Where no health check is available, add a short wait step that polls the port or runs a query with a time limit and fails clearly when the limit passes. Avoid a fixed sleep, which is too short on a slow runner and wasteful on a fast one. The same applies to migrations and seed data: run them as an explicit step after the service is ready, so a failure is visible and not mixed with the tests.
Pin the image version to the one production uses, including the major version of the database, because a test that passes against the latest release can hide a bug that appears on the version you run.
Keeping the tests fast and independent
Each run gets a fresh service, which is the main benefit: no leftover rows, no shared state with other jobs, and nothing to clean up. Make use of that by not building tests that depend on order. Create the data each test needs, or wrap tests in transactions that roll back, and avoid sleeping to wait for asynchronous results. Tests that share one database and run in parallel need separate schemas or databases per worker, which a small setup step can create. Mount a tmpfs for the data directory of a database in CI to make it faster, accepting that the data is lost when it stops, which is fine for a test. Set modest resource limits, because a runner has little memory and a database that wants a large cache will either fail or slow the whole job. Collect the service logs on failure, since the cause of a failed test is often in the database's own output, and a step that prints them with if: failure() saves a rerun.
Debugging a flaky service in CI
When the tests pass locally and fail in CI on a connection, check the basics in order. Print the service's status and logs in a step that always runs. Confirm the hostname and port the tests used by echoing them. Check whether the service had finished starting by looking at the first timestamps in its log. Look for resource limits: a database killed by the out-of-memory killer on a small runner produces a sudden connection reset in the middle of a test run. Pin versions and compare them with local. Reproduce the failure locally with the same compose file the CI uses, which is a good reason to define the services once in a file both can read. Retrying a failed test hides the problem, so use retries sparingly and always report that a retry happened.


