GitHub Actions can run your jobs inside Docker containers, which is great for ensuring consistent build environments and isolating dependencies.

Let’s see how this works with a simple example. Imagine you have a workflow that needs to build a Go application. Instead of installing Go directly on the runner, you can use a Docker image that already has Go installed.

name: Dockerized Go Build

on: [push]

jobs:
  build:
    runs-on: ubuntu-latest
    container:
      image: golang:1.19
      options: --user root
    steps:
      - name: Checkout code
        uses: actions/checkout@v3

      - name: Build Go app
        run: go build -o myapp .

In this workflow, runs-on: ubuntu-latest specifies the operating system of the runner machine. The crucial part is container: image: golang:1.19. This tells GitHub Actions to pull the golang:1.19 Docker image and run the job’s steps inside a container created from that image. The options: --user root is an example of passing Docker runtime options, useful if your containerized process needs elevated privileges (though it’s generally better to avoid running as root if possible).

When this workflow runs, GitHub Actions will:

  1. Provision an ubuntu-latest runner.
  2. Pull the golang:1.19 Docker image.
  3. Start a container from that image.
  4. Mount your repository’s code into the container at /github/workspace.
  5. Execute each steps command within that container.

This setup means that the go build command will be executed by the Go toolchain installed inside the golang:1.19 image, guaranteeing that the build environment is exactly as defined by that image, regardless of any pre-existing software on the runner.

The real power comes from the isolation and reproducibility. If your project depends on specific versions of tools or libraries, you can define them once in a Dockerfile and use that image across all your CI/CD pipelines, both locally and on GitHub Actions.

Consider a more complex scenario where you need multiple services, like a web application and a database, to run for your tests. You can define a services block within your container configuration.

name: Dockerized Test Suite

on: [push]

jobs:
  test:
    runs-on: ubuntu-latest
    container:
      image: node:18
      services:
        postgres:
          image: postgres:14
          env:
            POSTGRES_USER: testuser
            POSTGRES_PASSWORD: testpassword
            POSTGRES_DB: testdb
          ports:
            - 5432:5432
          options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
    steps:
      - name: Checkout code
        uses: actions/checkout@v3

      - name: Install dependencies
        run: npm install

      - name: Run tests
        run: npm test
        env:
          POSTGRES_HOST: localhost
          POSTGRES_PORT: 5432
          POSTGRES_USER: testuser
          POSTGRES_PASSWORD: testpassword
          POSTGRES_DB: testdb

Here, the primary job runs inside a node:18 container. Simultaneously, a PostgreSQL database is started in a separate container (postgres:14). The services block configures this sidecar container. We’re setting environment variables for the PostgreSQL container to initialize the database with specific credentials and a database name. ports makes the database accessible from the main job’s container, and options include health checks to ensure the database is ready before tests begin.

Your main job’s steps can then interact with the postgres service as if it were running locally. The env block in the Run tests step shows how you’d pass connection details to your application or test runner. The POSTGRES_HOST is localhost because the services are networked together within the GitHub Actions runner’s Docker environment.

The options for services are particularly powerful. Using --health-cmd with a command that checks the service’s health (like pg_isready for PostgreSQL) allows GitHub Actions to wait for the service to be fully operational before proceeding. This prevents flaky tests that start before their dependencies are ready.

One subtle but important point is how networking works between the main job container and its services. While you might expect service IPs, the primary container can usually reach services using localhost if the ports are mapped, or via service-specific hostnames if you’re using Docker Compose-like configurations within the services block. The ports mapping is key here; it exposes the service container’s port to the main job container’s network.

When your job runs with container, the runs-on operating system is still relevant for the underlying runner hardware, but the execution environment for your steps is entirely dictated by the specified Docker image and any services. This is the core mechanism for achieving reproducible, isolated build and test environments.

The next step is often to build your own custom Docker images for more complex or specific build environments, which involves writing a Dockerfile and pushing it to a registry.

Want structured learning?

Take the full Github-actions course →