civil-and-structural-engineering
How to Use Docker in a Continuous Integration Environment
Table of Contents
Understanding Docker and CI
Docker packages applications and their dependencies into lightweight, portable containers that run consistently across development, staging, and production environments. Continuous Integration (CI) is the practice of automatically building, testing, and integrating code changes multiple times per day. When combined, Docker provides a reproducible runtime for each step in the CI pipeline, eliminating the classic "it works on my machine" problem.
Containers share the host OS kernel but isolate processes through namespaces and control groups. This makes them far lighter than virtual machines—starting in milliseconds rather than minutes—and allows developers to define the exact environment (OS, language runtime, system libraries) in a Dockerfile. In a CI context, every build can spin up a clean container, run tests, and then discard the container, guaranteeing that no previous state leaks across runs.
Modern CI platforms like GitLab CI, GitHub Actions, CircleCI, and Jenkins have first-class support for Docker. They can run build steps inside containers, use Docker images as execution environments, and even build and push new images as part of a pipeline. Mastering Docker in CI means faster feedback loops, fewer environment-related flaky tests, and smoother deployment pipelines.
Key Benefits of Using Docker in CI
Consistency Across Environments
Without containerization, developers often work with slightly different operating systems, library versions, or patch levels. When the CI server runs the same tests, subtle differences can cause failures that are hard to reproduce locally. Docker images capture the entire runtime—down to the apt-get packages and environment variables—so the same image that passes CI can be deployed to staging and production with complete confidence.
Isolation and Clean State
Each CI build in a Dockerized environment starts with a fresh container spawned from a known image. No leftover files, no zombie processes from previous jobs, and no polluted dependency caches. For teams running parallel test suites, this isolation prevents tests from interfering with one another – and it greatly simplifies debugging because any failure can be reproduced by rerunning the same container image on any machine.
Speed Through Caching and Parallelism
Docker images are built in layers. CI systems can cache these layers across builds so that only changed layers are rebuilt. For example, if you install dependencies in one layer and then copy source code in a later layer, a new commit that changes only the source code will reuse the cached dependency layer. Combined with multi-core machines, CI tools can also spin up multiple containers in parallel to run test suites concurrently, dramatically reducing total pipeline duration.
Portability Across Infrastructure
A Docker image built on your local laptop runs identically on a cloud CI runner, a bare-metal Jenkins node, or a Kubernetes cluster. This portability means you can switch CI providers or move to a different cloud without rewriting your build scripts. Teams that adopt Docker in CI also find it easier to implement deployment strategies like blue/green or canary releases because the artifact itself is a versioned, immutable container image.
Setting Up Docker in CI Pipelines
Integrating Docker into a CI environment typically involves three main tasks: (1) defining a Docker image for the build/test environment, (2) configuring the CI job to run inside a container, and (3) optionally building and pushing a new Docker image as an artifact. Below are platform-specific steps for four popular CI tools.
Using Docker in GitLab CI
GitLab CI has native Docker support via the image keyword. You simply specify which Docker image to use for each job. GitLab Runner can be configured with the docker executor to run jobs inside containers.
# .gitlab-ci.yml
default:
image: node:18-alpine
stages:
- test
- build
unit-tests:
stage: test
script:
- npm ci
- npm test
build-image:
stage: build
image: docker:20.10.16
services:
- docker:20.10.16-dind
variables:
DOCKER_HOST: tcp://docker:2375
DOCKER_TLS_CERTDIR: ""
script:
- docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA .
- docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA
only:
- main
The docker:20.10.16-dind service (Docker-in-Docker) is required if you need to run docker build inside a CI container. For better security, consider using docker:20.10.16 with socket binding instead of DinD when possible.
Using Docker in GitHub Actions
GitHub Actions lets you run jobs directly on a Docker container by specifying the container option. You can also use Docker actions from the marketplace to build and push images.
# .github/workflows/ci.yml
name: CI
on: [push]
jobs:
test:
runs-on: ubuntu-latest
container:
image: golang:1.21-bullseye
steps:
- uses: actions/checkout@v4
- name: Run tests
run: go test ./...
build-and-push:
runs-on: ubuntu-latest
needs: test
steps:
- uses: actions/checkout@v4
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build and push
uses: docker/build-push-action@v5
with:
push: true
tags: myorg/myapp:${{ github.sha }}
GitHub Actions also supports matrix builds across multiple container images, which is useful for testing against different language versions or operating systems.
Using Docker in CircleCI
CircleCI uses the docker executor as one of its primary execution environments. You define the image in the .circleci/config.yml file.
# .circleci/config.yml
version: 2.1
jobs:
test:
docker:
- image: python:3.11-slim
steps:
- checkout
- run: pip install -r requirements.txt
- run: pytest
build:
docker:
- image: docker:20.10.16
steps:
- checkout
- setup_remote_docker
- run: docker build -t myapp:latest .
- run: docker login -u $DOCKER_USER -p $DOCKER_PASS
- run: docker push myapp:latest
workflows:
version: 2
ci:
jobs:
- test
- build:
requires:
- test
The setup_remote_docker step provides a dedicated Docker daemon for building images, avoiding DinD complexities.
Using Docker in Jenkins
Jenkins can be configured to run build agents as Docker containers, and pipeline jobs can use the Docker plugin to build and register images.
// Jenkinsfile
pipeline {
agent {
docker { image 'maven:3.8-openjdk-11' }
}
stages {
stage('Test') {
steps {
sh 'mvn test'
}
}
stage('Build Image') {
agent {
docker { image 'docker:20.10.16' }
}
steps {
script {
docker.withRegistry('https://registry.hub.docker.com', 'docker-hub-credentials') {
def customImage = docker.build("myorg/myapp:${env.BUILD_ID}")
customImage.push()
}
}
}
}
}
}
Jenkins also offers the docker and dockerPipeline steps for fine-grained control over build contexts.
Advanced Docker Techniques for CI
Layer Caching for Faster Builds
The Docker build cache is one of the most powerful levers for reducing CI time. To maximize cache hits, always order Dockerfile commands from least to most frequently changing. For example, copy package.json and run npm install before copying the rest of the source code. CI providers like GitHub Actions offer docker/build-push-action with built-in cache exporters, while GitLab CI can use docker build --cache-from with the registry.
Running Multi-Container Tests with Docker Compose
Many applications depend on external services like databases, message queues, or caches. Docker Compose allows you to define the entire stack in a single YAML file and spin it up in CI. Most CI environments support running docker-compose up inside a job. For privacy, use environment variables for passwords and connection strings.
test-integration:
stage: test
script:
- docker-compose -f docker-compose.test.yml up --abort-on-container-exit --exit-code-from app
This approach ensures that integration tests run against the same service versions that will be used in production.
Security Scanning in the Pipeline
Pushing untrusted Docker images to production is a security risk. Integrate vulnerability scanning tools like Trivy or Docker Scout into the CI pipeline. For example, in a GitHub Actions workflow you can run:
- name: Scan image for vulnerabilities
uses: aquasecurity/trivy-action@master
with:
image-ref: 'myapp:latest'
format: 'sarif'
output: 'trivy-results.sarif'
Fail the build if critical vulnerabilities are found, and enforce a policy that all base images must be rebuilt weekly to include latest security patches.
Multi-Architecture Builds
When deploying to a mixed environment of AMD64 and ARM64 machines (e.g., AWS Graviton instances alongside Intel), you can use Docker's buildx to create multi-architecture manifests in CI. This requires setting up QEMU emulation and using docker buildx build --platform linux/amd64,linux/arm64. CI runners that support hardware emulation (like GitHub Actions' ubuntu-24.04-arm) can build native ARM images without emulation.
Best Practices
- Use slim base images: Prefer
alpinevariants or distroless images to minimize attack surface and transfer time. For example, switch fromnode:18tonode:18-alpinewhere possible. - Leverage multi-stage builds: Separate build dependencies from runtime dependencies. The final image contains only what is needed to run the application, reducing size by up to 80%.
- Tag images meaningfully: Use a combination of the Git commit SHA, branch name, and build timestamp. Avoid using
latestas the primary tag in production pipelines because it is ambiguous. - Lock base image versions: Always specify a digest (
alpine@sha256:abc123) or a specific patch version (node:18.20.2) rather than a floating tag likenode:18to prevent unexpected breakage from upstream updates. - Store CI secrets securely: Use the CI provider's built-in secret management for Docker registry credentials, API keys, and environment-specific values. Never hardcode them in
Dockerfileor pipeline configuration. - Separate build stages for tests and production: Use one image for running unit tests (with dev dependencies) and a different, smaller image for production deployment. Multi-stage builds make this distinction clean.
- Regularly update base images: Schedule periodic pipeline runs that rebuild and push fresh images with the latest security patches. Tools like Renovate or Dependabot can automatically open pull requests when a base image digest changes.
Common Pitfalls and How to Avoid Them
Docker-in-Docker Overhead
Running Docker inside a Docker container (DinD) requires privileged mode and can cause security concerns. It also introduces a small performance penalty. Prefer using the host's Docker socket when possible (-v /var/run/docker.sock:/var/run/docker.sock). If your CI runner runs in a container itself (common in Kubernetes-based runners), consider using kaniko or buildah as rootless alternatives for building images.
Cache Invalidation
Because Docker caches layers by the exact command hash, a simple change in a RUN apt-get update line may invalidate the entire subsequent cache. To avoid this, combine RUN commands that install dependencies into a single layer, and use --no-cache sparingly. For package managers like npm or pip, copy the lock file first and install before copying source code.
Permission Issues
When the CI container runs as root but the host workspace is owned by a non-root user, file permissions inside the container can become inconsistent. Either run the CI job with user: root (if acceptable) or ensure the container user matches the host UID. GitHub Actions handles this automatically; other tools may require explicit chown steps.
Registry Rate Limits
Docker Hub imposes pull rate limits for anonymous and free-tier users. To avoid failed builds due to rate limiting, use a paid Docker subscription, or switch to a different registry like GitHub Container Registry or Amazon ECR. Also consider mirroring frequently used base images to your own registry.
Conclusion
Docker transforms a CI pipeline from a fragile, environment-dependent process into a robust, repeatable system. By defining the exact runtime in a Dockerfile and orchestrating builds, tests, and deployments inside containers, teams gain consistency, speed, and portability. Implementing layer caching, multi-stage builds, and security scanning further hardens the pipeline without adding operational overhead. Whether you use GitLab CI, GitHub Actions, CircleCI, or Jenkins, the principles remain the same: treat images as immutable artifacts, version them rigorously, and always test in an environment identical to production. Start small—containerize your CI runner—and gradually expand to build, push, and deploy entirely through Docker workflows.