---
title: "Speed Up Docker Builds in GitHub Actions | StarSling"
description: "Start only the containers a test needs and pin image tags so Docker builds in CI stay fast, reproducible, and cache-friendly."
url: https://starsling.dev/github-actions/optimizations/optimize-docker-builds
canonicalUrl: https://starsling.dev/github-actions/optimizations/optimize-docker-builds
---

# Speed up Docker builds in GitHub Actions

[GitHub Actions](https://starsling.dev/github-actions) / [Optimizations](https://starsling.dev/github-actions/optimizations) / Speed up Docker builds in GitHub Actions

- [How StarSling works](https://starsling.dev/)

Rule: ci.build.docker-builds. Detection mode: static. Last updated: 2026-08-21

A Docker-heavy CI job that starts only the service containers a test actually connects to, and pins every image to a specific tag, avoids paying for containers it never uses and keeps its cache keys stable from run to run.

## Table of contents

- [Do this](#do-this)
- [Avoid this](#avoid-this)
- [How to detect it](#how-to-detect-it)
- [Tradeoffs and safety](#tradeoffs)
- [Start only the containers a test needs](#start-only-what-the-test-needs)
- [Pin every image tag](#pin-image-tags)
- [Verify it worked](#verify)
- [Related pages](#related-pages)
- [Sources](#sources)

<a id="do-this"></a>

## Do this

docker-compose.yml usually lists every service a repository can run against - Postgres, MySQL, MongoDB, Redis - because that is convenient for local development. A CI job that runs `docker compose up` with no service argument starts all of them, even when the job under test only opens a connection to one. Each extra container still has to pull its image, initialize, and pass its healthcheck before the job can proceed, and none of that work has anything to do with the test result. Floating tags compound the problem: an image reference with no tag, or `:latest`, resolves to whatever the registry currently serves, so the exact bytes pulled can change between two runs of the same workflow. That breaks reproducibility (a green run and a red run may not have tested the same image) and defeats caching, because a cache keyed on the image reference cannot tell a new `:latest` apart from an old one.

_.github/workflows/test.yml_

```yaml
jobs:
  test-postgres-adapter:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      # Only the service this job connects to. Its image is pinned in
      # docker-compose.yml (postgres:16.2, not postgres or postgres:latest),
      # so the pulled bytes and the cache key are the same on every run.
      - run: docker compose up -d --wait postgres
      - run: npm ci
      - name: Run Postgres adapter tests
        run: npm test -- --grep postgres
        env:
          DATABASE_URL: postgres://postgres:postgres@localhost:5432/test
```

<a id="avoid-this"></a>

## Avoid this

_.github/workflows/test.yml_

```yaml
jobs:
  test-postgres-adapter:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      # No service named: every container in docker-compose.yml starts,
      # even though this job only talks to Postgres.
      - run: docker compose up -d
      - run: npm ci
      - name: Run Postgres adapter tests
        run: npm test -- --grep postgres
        env:
          DATABASE_URL: postgres://postgres:postgres@localhost:5432/test
```

<a id="how-to-detect-it"></a>

## How to detect it

1. Search for compose invocations with no service argument: `grep -rn 'docker compose up\|docker-compose up' .github/workflows/`. `docker compose up` with nothing after it (and no `--profile` restricting the set) starts every service in the file.
2. For each match, open the referenced `docker-compose*.yml` and list its services: `docker compose config --services`. Compare that list against what the job's test suite actually connects to (its `DATABASE_URL`, connection host, or client config) - a job testing one adapter has no reason to also start the other three.
3. Search for unpinned images: `grep -rnE 'image:\s*[a-zA-Z0-9_./-]+(:latest)?\s*$' docker-compose*.yml .github/workflows/*.yml` and flag any `image:` line with no tag or an explicit `:latest`.

<a id="tradeoffs"></a>

## Tradeoffs and safety

- If two services in the compose file genuinely depend on each other at runtime (an app container that reads from both Postgres and Redis in the same test), naming only one strands the other; use `depends_on` in the compose file so naming the app service still brings up what it needs, rather than hand-listing every dependency in the workflow.
- Docker Compose profiles are a cleaner fix than a hand-maintained service list when several jobs each need a different subset: tag services with `profiles` in docker-compose.yml and activate the right one per job with `--profile`, so the mapping lives in one file instead of being repeated across workflows.
- Pinning to an exact tag (`postgres:16.2`) still trusts the registry not to rewrite that tag's contents; pinning to a digest (`postgres@sha256:...`) is stricter but means a manual update step every time you want a new patch version - reserve the digest form for images where supply-chain integrity matters more than convenience.
- A tag pin needs an update path or it goes stale silently. Point it at a tool that bumps pinned versions on a schedule (Dependabot or Renovate can target compose files) rather than leaving the version to rot until something forces an upgrade.

<a id="start-only-what-the-test-needs"></a>

## Start only the containers a test needs

A docker-compose.yml built for local development typically lists every backend a repository can run against, so a developer can bring up any of them on demand. A CI job testing one adapter inherits that whole file if it calls `docker compose up` with nothing after it. Naming the service (or services) the job actually uses skips the pull, initialization, and healthcheck wait for every container the test never touches:

_docker-compose.yml_

```yaml
services:
  postgres:
    image: postgres:16.2
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
  mysql:
    image: mysql:8.4
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
  mongo:
    image: mongo:7.0
    healthcheck:
      test: ["CMD", "mongosh", "--eval", "db.adminCommand('ping')"]

# CI job for the Postgres adapter only needs:
#   docker compose up -d --wait postgres
```

<a id="pin-image-tags"></a>

## Pin every image tag

An image reference with no tag defaults to `:latest`, and `:latest` is not a version - it is whichever image the publisher currently serves under that name. Two runs of the same workflow, hours apart, can pull genuinely different bytes for the same `image:` line. That breaks reproducibility (you cannot tell whether a red run failed because of your change or because the base image moved under you) and it breaks caching, because a cache key built from the image reference cannot distinguish today's `:latest` from yesterday's. Pin to the version you tested against:

_docker-compose.yml_

```yaml
services:
  postgres:
    # image: postgres          # resolves to :latest, moves under you
    image: postgres:16.2        # pinned - stable across every run
```

<a id="verify"></a>

## Verify it worked

Hand this prompt to your coding agent (Claude Code, Cursor, and the like) to run this audit and open the fix as a reviewable PR:

```
Audit this repository's Docker Compose usage in CI and fix what you find. This is
about which containers start and which tags they pull - it does NOT cover the build
step's own layer cache, which is a separate fix (see the Docker layer caching recipe
linked on this page if the build step itself needs that).

1. grep .github/workflows/ for "docker compose up" or "docker-compose up" with no
   service argument and no --profile flag. For each job, read what the test step
   actually connects to (its DATABASE_URL, connection host, or client setup) and
   compare that against the full service list in the referenced docker-compose.yml
   (docker compose config --services).
2. Where a job only needs a subset of services, change the up command to name only
   those services (or the depends_on chain that reaches them), or add a Docker
   Compose profile in docker-compose.yml and activate it with --profile in the
   workflow if several jobs each need a different subset.
3. grep docker-compose*.yml and .github/workflows/*.yml for image: lines with no
   tag or an explicit :latest. Pin each to a specific version tag you can confirm
   exists for that image (check the image's own tag list before choosing one -
   do not guess a version number).
4. Read the Docker Compose up/profiles docs and the Docker image best-practices
   doc linked on this page before making changes.
5. Show the full diff and open a pull request; do not apply changes blindly. In
   the PR body, list which services each job now starts (before and after) and
   which images were pinned, with the tag chosen for each.
```

Confirm the change landed:

1. Run `docker compose ps` right after the `up` step and confirm only the intended service (and anything it declares in `depends_on`) is listed - not every service in the compose file.
2. Compare the job's `docker compose up` step duration before and after scoping it to one service; fewer containers to pull, initialize, and healthcheck should shorten that step.
3. Run `docker compose config` (or `grep image: docker-compose.yml`) and confirm every image reference carries an explicit version tag, with no bare image name and no `:latest`.
4. Re-run the same workflow twice and confirm `docker compose pull` (or the implicit pull inside `up`) resolves the same image digest both times for each pinned service.

<a id="related-pages"></a>

## Related pages

- [Use Docker layer caching in GitHub Actions](https://starsling.dev/github-actions/optimizations/use-docker-layer-caching)
- [Docker builds in GitHub Actions, done right](https://starsling.dev/ci/docker)
- [The right way to configure docker/build-push-action](https://starsling.dev/github-actions/docker-build-push-action)

<a id="sources"></a>

## Sources

- [Docker Compose: the up command](https://docs.docker.com/reference/cli/docker/compose/up/)
- [Docker Compose: using profiles](https://docs.docker.com/compose/how-tos/profiles/)
- [Docker: Dockerfile and image best practices](https://docs.docker.com/build/building/best-practices/)
