Testing an application that talks to PostgreSQL against a simulated database proves very little. Service containers start real dependencies next to your job, for the duration of its run. The job container, on the other hand, changes the machine your code runs on. The two are often confused, and they answer opposite needs.
What you will learn
- Start a database or a cache next to the job
- Wait until a service is genuinely ready, without a
sleep - Reach the service, depending on whether the job runs on the runner or in a container
- Tell apart service container and job container, and pick the right one
A service, not a simulation
A service container is a container GitHub starts before your job and destroys afterwards. Your code connects to it the way it would connect to a real service, because it is one.
name: Integration tests
on: pull_request: branches: [main]
permissions: {}
jobs: integration: runs-on: ubuntu-24.04 permissions: contents: read services: postgres: image: postgres:17-alpine@sha256:18cfe3ef5e6815560c98237d6216d1e5119702fb0f3894c8785dd58b8bbe5d73 env: POSTGRES_PASSWORD: test-password POSTGRES_DB: app_test ports: - 5432:5432 options: >- --health-cmd "pg_isready -U postgres" --health-interval 10s --health-timeout 5s --health-retries 5 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: python-version: "3.13" - run: pip install --require-hashes -r requirements.txt - name: Run the integration tests env: DATABASE_URL: postgresql://postgres:test-password@localhost:5432/app_test run: pytest tests/integration -qThe image carries a digest, as everywhere else on this site: a test service pinned to a mutable tag makes your tests irreproducible, and introduces the same unmanaged dependency as in a production image.
Waiting until the service is ready
This is trap number one. A started container is not a ready service: PostgreSQL accepts connections several seconds after the container launches. Without precaution, the first test fails one time in three, seemingly at random.
The answer is not a sleep, which is either too short or too long, but the
health check declared in options. GitHub waits until the container is
healthy before starting the first step.
| Service | Health command |
|---|---|
| PostgreSQL | pg_isready -U postgres |
| MySQL | mysqladmin ping |
| Redis | redis-cli ping |
| MongoDB | mongosh --eval "db.adminCommand('ping')" |
The four parameters that go with them read together: --health-interval sets
how often attempts are made, --health-timeout how long before an attempt is
declared lost, --health-retries how many failures are tolerated, and
--health-start-period an initial grace period useful for services that are
slow to start.
Reaching the service: the part that traps people
The address to use depends on where your job runs, and that is the most frequent source of confusion on this subject.
| The job runs | Service address | Port |
|---|---|---|
| Directly on the runner | localhost | The port published through ports: |
| In a job container | The service name | The container's internal port |
When the job runs on the runner, services are reachable on localhost thanks to
port publishing. When the job itself runs in a container, it shares a Docker
network with the services: you reach them by their name, and publishing
ports becomes unnecessary.
integration-in-container: runs-on: ubuntu-24.04 permissions: contents: read container: image: python:3.13-slim@sha256:9534e5a8e315485d4061ed659af0fd78a284c015f9b73661b41d6bab25604534 services: redis: image: redis:8-alpine@sha256:becdda6c7f4b3fb42e42fd7f120bbf5c54c4caaaf16f26da24e4563d2c1f0576 options: >- --health-cmd "redis-cli ping" --health-interval 10s --health-timeout 5s --health-retries 5 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - name: Run the tests env: REDIS_URL: redis://redis:6379 run: pytest tests/integration -qNote the absence of ports: on the service, and the address redis://redis:6379
which reuses the key name declared under services:.
The job container, a different need
A job's container: does not start a dependency: it changes the execution
machine of your steps. Instead of running on the GitHub runner image, your
commands run in the image you choose.
| Service container | Job container | |
|---|---|---|
| Role | A dependency next to the job | The environment of the job itself |
| Key | services: | container: |
| Use case | Database, cache, message queue | A frozen toolchain, a precise distribution |
| Count | Several | Only one |
The job container answers a need for reproducibility: the same image in CI
and locally, therefore the same tool versions, therefore the end of "it works on
my machine". It has a downside worth knowing: some actions assume tools present
on the runner image and fail in a minimal image. A slim or alpine image
without git, curl or node can break actions/checkout or other JavaScript
actions.
When not to use a service container
Three situations where the reflex costs more than it returns.
Unit tests. If the test does not need the database, starting PostgreSQL adds twenty seconds to every run for nothing. Reserve services for the tests that genuinely cross the dependency.
External dependencies that cannot be containerised. A third-party service reachable only through an API cannot be simulated with an official container: what you need there is an application-level test double, not a service container.
Wide matrices. A service starts per combination of the matrix. Nine combinations means nine PostgreSQL instances. When startup time dominates, grouping the integration tests into a dedicated job outside the matrix costs less.
Key points
- A service container starts before the job and dies after it: the code talks to a real dependency, not a simulation.
- A service image is pinned by digest, like any other image: a mutable tag makes the tests irreproducible.
- The password of a test service is not a secret: the container is ephemeral and reachable from that job only.
- A started container is not a ready service: use a
--health-cmd, never asleep. - The address depends on the context:
localhostand the published port from the runner, the service name and the internal port from a job container. - The job container changes the execution environment, not the dependencies; there is only one, and it may lack tools some actions expect.
- A job container is not a security boundary: it runs on the runner and shares its fate.
- Services start per matrix combination: isolate integration tests when startup dominates the run time.
Next steps
- Composite actions: factoring out the repetitive preparation of a test environment across several workflows.
- Securing GitHub Actions: what adding third-party images to your workflows changes for the supply chain.
- Pinning actions by SHA: the same immutability requirement, applied to actions as it is here to service images.