GitHub Actions Service Containers

Many applications need a database, a cache, or a message queue to run their tests. Installing those tools by hand on every run wastes time. Service containers start them for you as small Docker containers that live beside your job and disappear when it ends.

Service Containers in Simple Words

Imagine a chef who needs a refrigerator while cooking. The kitchen delivers a fridge before the shift and removes it afterward. Your job is the chef, and the service container is the fridge. It exists only for the length of the job.

Basic Structure

A services block sits inside a job. Each service gets a label, an image, and optional settings.

jobs:
  test:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_PASSWORD: testpass
          POSTGRES_DB: shop_test
        ports:
          - 5432:5432
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
    steps:
      - uses: actions/checkout@v4
      - name: Run tests
        run: npm test
        env:
          DATABASE_URL: postgres://postgres:testpass@localhost:5432/shop_test

Understanding Each Setting

image

The image names the Docker image to download. Pin a version tag such as postgres:16 so results stay repeatable.

env

Environment variables configure the service. The Postgres image reads the password and database name from these values.

ports

The entry 5432:5432 maps a container port to a port on the runner machine. Write only the container port, such as 5432, to let GitHub pick a random free host port.

options

These flags pass straight to the Docker create command. Health checks make the job wait until the database accepts connections, which stops random startup failures.

Architecture Diagram

+---------------------- GitHub-hosted runner -----------------------+
|                                                                   |
|   +----------------+   localhost:5432   +--------------------+    |
|   | Job steps      | -----------------> | postgres container |    |
|   | (npm test)     |                    | (service)          |    |
|   +----------------+                    +--------------------+    |
|                                                                   |
+-------------------------------------------------------------------+

Two Ways to Connect

Where the Job RunsHostname to UsePort to Use
Directly on the runnerlocalhostThe mapped host port
Inside a container (container: key)The service label, such as postgresThe container port

Containers on the same job share a private Docker network. The label becomes a hostname automatically.

Example With Two Services

A web application might need both a database and a cache.

services:
  db:
    image: mysql:8
    env:
      MYSQL_ROOT_PASSWORD: rootpass
    ports:
      - 3306:3306
  cache:
    image: redis:7
    ports:
      - 6379:6379
    options: --health-cmd "redis-cli ping" --health-interval 10s
Job steps ---> db    (MySQL, port 3306)
          ---> cache (Redis, port 6379)

Rules and Limits

  • Service containers run only on Linux runners. Windows and macOS runners do not support them.
  • Each job gets its own fresh services. Services never carry data from a previous run.
  • Private images need a credentials block with a username and a secret password.

Troubleshooting

Connection refused errors usually mean the service is not ready. Add a health check in options. A wrong hostname causes the next most common failure: use localhost for runner jobs and the label for container jobs. When a test cannot find tables, run your migration step before the tests start.

Why Teams Choose This Approach

Tests run against a real database instead of a fake one, so bugs surface earlier. Setup stays inside the workflow file, which means every developer and every run uses identical versions. Cleanup happens automatically, and no leftover data pollutes the next run.