GitLab Runner: Building Docker images

Build and push Docker images efficiently from your GitLab CI/CD pipelines using Stackhero runners and Docker-in-Docker

👋 Welcome to the Stackhero documentation!

Stackhero offers a simple GitLab Runner cloud solution that makes running your GitLab CI/CD jobs efficient and straightforward. Here’s what you can expect:

  • Unlimited CI/CD minutes: run your pipelines as often as you need, with no per-minute billing or unexpected fees.
  • Multiple concurrent jobs: speed up your development by running several jobs in parallel.
  • The Docker executor with Docker-in-Docker support: easily build and push container images as part of your CI/CD workflows.
  • Works seamlessly with both GitLab.com and self-managed GitLab instances.
  • A private, dedicated infrastructure with fast NVMe/SSD storage ensures stable and predictable build performance.
  • Available in 🇪🇺 Europe and 🇺🇸 USA regions to meet your team's needs.

Save time: you can connect your first GitLab Runner and start running pipelines in just a few minutes!

With a Stackhero GitLab Runner, each job runs inside an isolated container using the Docker executor. You can build your own Docker images directly in your pipeline by enabling Docker-in-Docker (DinD). This setup starts a Docker daemon alongside your job, allowing you to run docker build and docker push commands as part of your CI/CD process.

Every run benefits from unlimited CI/CD minutes: you can build as often as needed, without worrying about quotas. Your build cache is stored on the runner's dedicated disk, which means repeated builds can reuse previous layers. This significantly reduces build times and speeds up your pipelines.

You can add the following sample .gitlab-ci.yml to your repository. This configuration builds the Dockerfile located at the root of your project:

build-image:
  stage: build
  image: docker:29
  services:
    - name: docker:29-dind
      alias: docker
  variables:
    DOCKER_HOST: "tcp://docker:2375"
    DOCKER_TLS_CERTDIR: ""
  before_script:
    - docker info
  script:
    # Replace "my-image" with your desired name:
    - docker build -t my-image .
    # Optionally, run a quick test on the built image:
    # - docker run --rm my-image /path/to/tests

We are using Docker image version 29 in this example. You may want to use a newer version as they become available. You can find the latest tags on the official Docker image page.

In this setup, the docker:29-dind service starts a Docker daemon next to your job, and DOCKER_HOST: "tcp://docker:2375" tells the docker CLI to use it. Always set DOCKER_HOST when you declare a docker:dind service: without it, the CLI silently connects to a different daemon, so your build can pass even when the service is misconfigured, which hides real problems (and breaks tools like Testcontainers that rely on the service). DOCKER_TLS_CERTDIR: "" connects over the plain, non-TLS port 2375 on the internal job network.

As a simpler alternative, you can omit the services block and both variables: Stackhero's runner also exposes a ready-to-use Docker daemon through a mounted socket, so a bare docker build works without any extra configuration.

Testcontainers is a testing library, available for Java, Go, Node.js, Python, .NET and other languages, that starts real services as disposable Docker containers while your tests run. Instead of mocking a database or a message broker, your integration tests talk to a genuine PostgreSQL, MySQL, Redis or Kafka instance, created before the tests and removed right after. It is especially popular in Java and Spring projects.

Testcontainers needs a Docker daemon, so it runs with exactly the same Docker-in-Docker configuration as above. No extra variable is needed:

test:
  stage: test
  # Use the image your tests need (a JDK here), not necessarily the docker image:
  image: gradle:jdk21
  services:
    - name: docker:29-dind
      alias: docker
  variables:
    DOCKER_HOST: "tcp://docker:2375"
    DOCKER_TLS_CERTDIR: ""
  script:
    - gradle test

Testcontainers reads DOCKER_HOST to find the daemon, and it reuses that same docker hostname to reach the ports published by the containers it starts. Both work on their own, because the docker alias resolves on your job's network.

Keep DOCKER_HOST in your Testcontainers jobs. Without it, Testcontainers falls back to the Docker socket mounted by the runner, then tries to reach your test containers on the host IP address, which your job cannot connect to. The usual symptom is the Ryuk helper container failing with Wait strategy failed. Container is removed, or Timed out waiting for log output matching '.*Started.*'.

GitLab provides predefined variables (CI_REGISTRY, CI_REGISTRY_USER, CI_REGISTRY_PASSWORD, CI_REGISTRY_IMAGE) so your pipeline can authenticate and push images to your project's container registry securely. No extra secrets are required.

Here is an example job that builds and pushes your image:

build-and-push:
  stage: build
  image: docker:29
  services:
    - name: docker:29-dind
      alias: docker
  variables:
    DOCKER_HOST: "tcp://docker:2375"
    DOCKER_TLS_CERTDIR: ""
  before_script:
    - docker login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" "$CI_REGISTRY"
  script:
    - docker build -t "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA" .
    - docker push "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA"
    # If you are on the default branch, you can also tag and push "latest":
    - |
      if [ "$CI_COMMIT_BRANCH" = "$CI_DEFAULT_BRANCH" ]; then
        docker tag "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA" "$CI_REGISTRY_IMAGE:latest"
        docker push "$CI_REGISTRY_IMAGE:latest"
      fi

To push your images to a different registry (such as Docker Hub or a private registry), you can store credentials as CI/CD variables and use them with docker login in the same way.

Your runner's disk persists between pipelines, allowing you to reuse image layers as a build cache. This can make repeated builds much faster. Here is an example configuration:

build-cached:
  stage: build
  image: docker:29
  services:
    - name: docker:29-dind
      alias: docker
  variables:
    DOCKER_HOST: "tcp://docker:2375"
    DOCKER_TLS_CERTDIR: ""
  before_script:
    - docker login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" "$CI_REGISTRY"
  script:
    # Pull the latest image to seed the cache (if it exists):
    - docker pull "$CI_REGISTRY_IMAGE:latest" || true
    - docker build --cache-from "$CI_REGISTRY_IMAGE:latest" -t "$CI_REGISTRY_IMAGE:latest" .
    - docker push "$CI_REGISTRY_IMAGE:latest"

This approach allows your builds to leverage Docker's layer caching, so only new or changed layers are rebuilt.

Your plan determines how many jobs can run concurrently. Jobs in the same stage start together, up to your concurrency limit. This means several independent jobs can run in parallel and finish as soon as the slowest job completes, rather than waiting for each to finish in sequence.

Example:

stages:
  - test

unit:
  stage: test
  image: node:22
  script: npm run test:unit

integration:
  stage: test
  image: node:22
  script: npm run test:integration

e2e:
  stage: test
  image: node:22
  script: npm run test:e2e

If you set your concurrency to 1 or higher, the unit, integration, and e2e jobs will run at the same time.

For more information on building Docker images in GitLab CI/CD pipelines, you can refer to the official GitLab documentation on using Docker builds.