Skip to content
Français
CI/CD & Automatisation medium

Service containers and job containers

30 min de lecture

Read this page in French

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 -q

The 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.

ServiceHealth command
PostgreSQLpg_isready -U postgres
MySQLmysqladmin ping
Redisredis-cli ping
MongoDBmongosh --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 runsService addressPort
Directly on the runnerlocalhostThe port published through ports:
In a job containerThe service nameThe 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 -q

Note 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 containerJob container
RoleA dependency next to the jobThe environment of the job itself
Keyservices:container:
Use caseDatabase, cache, message queueA frozen toolchain, a precise distribution
CountSeveralOnly 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 a sleep.
  • The address depends on the context: localhost and 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.

Is this site useful to you?

Fewer than 1% of readers support this site.

I maintain more than 700 free guides, with no ads and no tracking. Any support, even a symbolic one, helps cover hosting and keeps these resources free. Thank you for the help.

The form does not show? Open Ko-fi in a new tab.

Subscribe and follow my DevSecOps work on LinkedIn