What is Docker with GitLab CI
Combining Docker with GitLab CI means using containerization throughout your continuous integration pipeline. Instead of running jobs on bare-metal runners or virtual machines, every stage of your pipeline executes inside isolated Docker containers. GitLab CI natively supports Docker through its runner architecture, allowing you to define custom build environments, produce container images as artifacts, and deploy those images to production registries or orchestration platforms—all from a single .gitlab-ci.yml file.
At its core, the integration works through three mechanisms:
- Docker executor – The GitLab Runner pulls a specified image and runs your job commands inside a fresh container, giving you a clean, reproducible environment every time.
- Docker-in-Docker (dind) – A technique where a Docker daemon runs inside a container, allowing you to build, tag, and push images from within a CI job without touching the host system.
- Service containers – Sidecar containers that accompany your job container, providing databases, message brokers, or other dependencies during testing.
Why Docker with GitLab CI Matters in Production
🚀 Deploy your AI agent in 10 minutes
Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.
Try it free →Running Docker in CI pipelines isn't just about convenience—it fundamentally changes how teams ship software. Here's why it matters for production environments:
- Reproducibility – Every build runs in an identical environment defined by a Docker image. The PHP 8.2 image you test against is the exact same PHP 8.2 that runs in staging and production.
- Immutable artifacts – Docker images become your deployable artifacts. Once built and tested, the same image hash propagates through staging, canary, and production—eliminating "works on my machine" scenarios.
- Pipeline portability – A
.gitlab-ci.ymlthat builds Docker images can run on any GitLab instance with Docker-capable runners—on-premise, cloud, or air-gapped environments. - Faster recovery – Rolling back a bad deployment means pointing your orchestrator to the previous image tag. There's no rebuild, no dependency resolution, just a tag change.
- Security isolation – Each job gets its own container. Secrets, environment variables, and file systems don't leak between pipeline stages.
Prerequisites and Runner Setup
Before writing your pipeline configuration, you need GitLab Runners configured to support Docker. There are two common approaches:
Approach 1: Docker Executor with Privileged Mode (DinD)
Register a runner with the Docker executor and enable privileged mode. This allows the runner to spawn a nested Docker daemon inside the job container:
# Register the runner
gitlab-runner register \
--url https://gitlab.example.com \
--registration-token YOUR_TOKEN \
--executor docker \
--docker-image docker:24-dind \
--docker-privileged \
--description "docker-builder"
Then verify the runner's /etc/gitlab-runner/config.toml contains:
[[runners]]
executor = "docker"
[runners.docker]
image = "docker:24-dind"
privileged = true
volumes = ["/cache", "/var/run/docker.sock:/var/run/docker.sock"]
Approach 2: Socket Binding (Less Isolation, Faster Startup)
If you trust the jobs running on the runner and need faster builds, bind the host's Docker socket directly. This avoids the overhead of starting a nested daemon but reduces isolation:
[[runners]]
executor = "docker"
[runners.docker]
image = "docker:24"
volumes = ["/var/run/docker.sock:/var/run/docker.sock", "/cache"]
Security note: Socket binding means any CI job can issue arbitrary Docker commands on the host. For production pipelines, prefer DinD with privileged mode limited to trusted projects, or use Kaniko for rootless builds.
Building Your First Docker Image in GitLab CI
Here is a minimal pipeline that builds a Docker image, tags it with the commit SHA, and pushes it to GitLab's built-in Container Registry:
# .gitlab-ci.yml
stages:
- build
variables:
DOCKER_TLS_CERTDIR: ""
DOCKER_HOST: "tcp://docker:2375"
build-image:
stage: build
image: docker:24-dind
services:
- docker:24-dind
script:
- docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
- docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA .
- docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA
tags:
- docker-builder
Let's break down what's happening:
DOCKER_TLS_CERTDIR: ""disables TLS for the inner Docker daemon (acceptable inside the isolated CI network).DOCKER_HOSTtells the Docker client to connect to thedockerservice container over TCP.CI_REGISTRY_IMAGEis automatically derived from your project path (e.g.,registry.gitlab.com/group/project).CI_REGISTRY_USERandCI_REGISTRY_PASSWORDare predefined variables that authenticate to GitLab's registry.
Multi-Stage Production Pipeline
A production-grade pipeline typically includes build, test, security scan, and deploy stages. Below is a complete example:
# .gitlab-ci.yml
stages:
- build
- test
- security
- deploy-staging
- deploy-production
variables:
DOCKER_TLS_CERTDIR: ""
DOCKER_HOST: "tcp://docker:2375"
PROD_IMAGE_TAG: "${CI_REGISTRY_IMAGE}:${CI_COMMIT_SHORT_SHA}"
STAGING_TAG: "${CI_REGISTRY_IMAGE}:staging-${CI_COMMIT_SHORT_SHA}"
# ---- BUILD STAGE ----
docker-build:
stage: build
image: docker:24-dind
services:
- docker:24-dind
script:
- docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
- docker build -t $PROD_IMAGE_TAG -f Dockerfile.prod .
- docker push $PROD_IMAGE_TAG
tags:
- docker-builder
only:
- main
- merge_requests
# ---- TEST STAGE ----
unit-tests:
stage: test
image: $PROD_IMAGE_TAG
script:
- npm ci
- npm run test:ci
- npm run lint
needs:
- docker-build
tags:
- docker-executor
integration-tests:
stage: test
image: docker:24-dind
services:
- docker:24-dind
- postgres:15-alpine
variables:
DATABASE_URL: "postgresql://user:password@postgres:5432/testdb"
script:
- docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
- docker pull $PROD_IMAGE_TAG
- docker run --network host -e DATABASE_URL=$DATABASE_URL $PROD_IMAGE_TAG npm run test:integration
needs:
- docker-build
tags:
- docker-builder
# ---- SECURITY STAGE ----
container-scan:
stage: security
image: docker:24-dind
services:
- docker:24-dind
script:
- docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
- docker pull $PROD_IMAGE_TAG
- docker run --rm -v $(pwd)/trivy-reports:/tmp aquasec/trivy image --severity HIGH,CRITICAL --exit-code 1 $PROD_IMAGE_TAG || echo "Vulnerabilities found, check report"
needs:
- docker-build
tags:
- docker-builder
allow_failure: true
# ---- DEPLOY STAGING ----
deploy-staging:
stage: deploy-staging
image: alpine:3.19
before_script:
- apk add --no-cache openssh-client curl
script:
- |
curl -X POST https://staging-api.example.com/deploy \
-H "Authorization: Bearer $DEPLOY_TOKEN" \
-H "Content-Type: application/json" \
-d '{"image": "'"${PROD_IMAGE_TAG}"'", "tag": "staging"}'
needs:
- container-scan
- integration-tests
environment:
name: staging
url: https://staging.example.com
only:
- main
# ---- DEPLOY PRODUCTION ----
deploy-production:
stage: deploy-production
image: alpine:3.19
before_script:
- apk add --no-cache openssh-client curl
script:
- |
curl -X POST https://prod-api.example.com/deploy \
-H "Authorization: Bearer $DEPLOY_TOKEN" \
-H "Content-Type: application/json" \
-d '{"image": "'"${PROD_IMAGE_TAG}"'", "rollback": false}'
needs:
- deploy-staging
environment:
name: production
url: https://example.com
when: manual
only:
- main
Key design decisions in this pipeline:
- Stage gating – Each stage depends on the previous one succeeding. Production deployment requires staging deployment to pass first.
needskeyword – Jobs start immediately when their dependencies complete, without waiting for the entire stage to finish (parallelism where possible).- Manual production gate –
when: manualon the production deploy job ensures a human approves the final push. - Environment tracking – GitLab tracks deployments per environment, showing which commit is live in staging vs. production.
Dockerfile Optimization for CI Speed
A slow Docker build kills pipeline velocity. Here's a production-optimized Dockerfile that leverages layer caching, multi-stage builds, and minimal final images:
# Dockerfile.prod
# ---- Stage 1: Build dependencies ----
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --production=false
COPY . .
RUN npm run build
# ---- Stage 2: Production image ----
FROM node:20-alpine AS production
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./
USER appuser
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1
CMD ["node", "dist/index.js"]
This Dockerfile delivers several production benefits:
- Layer caching – Copying
package.jsonbefore source code means dependency layers rebuild only when packages change. - Multi-stage build – The final image contains only the compiled
dist/folder and production dependencies, not dev tools or intermediate build artifacts. - Non-root user – The application runs as
appuser, limiting the blast radius if the container is compromised. - Health check – Orchestrators (Kubernetes, Docker Swarm) can detect and restart unhealthy containers automatically.
Layer Caching in GitLab CI
By default, each CI job starts with a clean file system. Docker builds download base images and re-download dependencies every run. To accelerate builds, implement Docker layer caching using GitLab's distributed cache or a dedicated cache volume:
Using Docker BuildKit with Remote Cache
# .gitlab-ci.yml (excerpt)
docker-build-cached:
stage: build
image: docker:24-dind
services:
- docker:24-dind
script:
- |
docker buildx build \
--cache-from type=registry,ref=${CI_REGISTRY_IMAGE}:build-cache \
--cache-to type=registry,ref=${CI_REGISTRY_IMAGE}:build-cache,mode=max \
--tag $PROD_IMAGE_TAG \
--push .
tags:
- docker-builder
The --cache-from and --cache-to flags store intermediate layers in your container registry. Subsequent builds pull the cache, dramatically reducing build times for projects with infrequent dependency changes.
Using GitLab Cache for Dependencies
# Cache node_modules between pipeline runs
docker-build:
stage: build
image: docker:24-dind
services:
- docker:24-dind
cache:
key: ${CI_COMMIT_REF_SLUG}-node-modules
paths:
- node_modules/
script:
- docker build --cache-from local-cache -t $PROD_IMAGE_TAG .
tags:
- docker-builder
Handling Secrets and Environment Variables
Never hardcode credentials in Dockerfiles or CI configuration. GitLab provides several secure mechanisms:
Protected CI/CD Variables
In GitLab project settings under Settings → CI/CD → Variables, define variables like AWS_ACCESS_KEY_ID, DOCKER_HUB_TOKEN, or PRIVATE_NPM_TOKEN. Mark them as protected and optionally masked to prevent exposure in logs. Then reference them in your pipeline:
docker-build:
stage: build
image: docker:24-dind
services:
- docker:24-dind
script:
- echo "$DOCKER_HUB_TOKEN" | docker login -u myuser --password-stdin
- docker build \
--build-arg NPM_TOKEN=$PRIVATE_NPM_TOKEN \
--secret id=npmrc,src=.npmrc \
-t $PROD_IMAGE_TAG .
tags:
- docker-builder
BuildKit Secrets (No Leakage)
Docker BuildKit supports --secret mounts that are available only at build time and never appear in image layers:
# Dockerfile snippet using BuildKit secret
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \
npm ci --production
This prevents private tokens from being baked into image layers where they could be extracted.
Deploying to Kubernetes from GitLab CI
A common production pattern uses GitLab CI to build the image, push it to a registry, and then update a Kubernetes deployment:
# Deploy stage using kubectl
deploy-k8s-production:
stage: deploy-production
image: alpine/k8s:1.28.3
script:
- kubectl config use-context production-cluster
- |
kubectl set image deployment/app-backend \
backend=${PROD_IMAGE_TAG} \
--namespace=production \
--record
- kubectl rollout status deployment/app-backend --namespace=production --timeout=5m
- kubectl rollout restart deployment/app-backend --namespace=production
needs:
- deploy-staging
environment:
name: production
when: manual
only:
- main
For a more controlled rollout, use a Helm chart or a GitOps tool like ArgoCD. In the GitOps model, GitLab CI pushes the image but does not directly modify cluster state—instead, it updates a manifest repository that ArgoCD watches:
gitops-update:
stage: deploy-production
image: alpine:3.19
before_script:
- apk add --no-cache git curl
script:
- git clone https://${GITOPS_TOKEN}@gitlab.example.com/infra/manifests.git
- cd manifests
- |
sed -i "s|image:.*|image: ${PROD_IMAGE_TAG}|" apps/backend/overlays/production/deployment.yaml
- git config user.name "ci-bot"
- git config user.email "ci-bot@example.com"
- git commit -am "Deploy ${CI_COMMIT_SHORT_SHA} to production"
- git push origin main
environment:
name: production
when: manual
Best Practices for Production
1. Pin Exact Base Image Versions
Never use floating tags like node:latest or alpine:3. Pin to a specific digest or version:
FROM node:20.11.0-alpine@sha256:abc123...def456
This ensures builds are fully reproducible and immune to upstream image changes that could break your pipeline.
2. Implement Image Signing
Use Cosign or Notary to sign images before pushing, and enforce signature verification on the deployment side:
sign-image:
stage: security
image: bitnami/cosign:latest
script:
- cosign sign --key cosign.key ${PROD_IMAGE_TAG}
needs:
- docker-build
tags:
- docker-builder
3. Run Container Structure Tests
Validate that your built image actually behaves as expected before deploying:
structure-tests:
stage: test
image: gcr.io/gcp-runtimes/container-structure-test:latest
script:
- container-structure-test test --image ${PROD_IMAGE_TAG} --config structure-tests.yaml
needs:
- docker-build
tags:
- docker-builder
# structure-tests.yaml
schemaVersion: "2.0.0"
metadataTest:
env:
- key: NODE_ENV
value: production
exposedPorts: ["3000"]
cmd: ["node", "dist/index.js"]
workdir: "/app"
fileExistenceTests:
- name: "dist directory"
path: "/app/dist"
shouldExist: true
permissions: "-rwxr-xr-x"
4. Limit Docker Image Size
Set a threshold in CI and fail builds that produce bloated images:
check-image-size:
stage: test
image: docker:24-dind
services:
- docker:24-dind
script:
- IMAGE_SIZE=$(docker inspect -f '{{ .Size }}' ${PROD_IMAGE_TAG})
- MAX_SIZE=500000000
- |
if [ $IMAGE_SIZE -gt $MAX_SIZE ]; then
echo "Image size ${IMAGE_SIZE} exceeds limit of ${MAX_SIZE} bytes"
exit 1
fi
needs:
- docker-build
tags:
- docker-builder
5. Separate Build and Runtime Dependencies
Use multi-stage builds aggressively. The final image should contain only the compiled artifacts and the minimum runtime libraries. Tools like compilers, package managers, and dev dependencies stay in the builder stage and never reach production.
6. Use GitLab Container Registry Wisely
Implement a tagging strategy that distinguishes between purposes:
registry.gitlab.com/group/project:sha-abc123– immutable, per-commit tag for traceability.registry.gitlab.com/group/project:staging– mutable tag pointing to the current staging candidate.registry.gitlab.com/group/project:latest– only update after full production verification, never directly from CI.
Schedule regular registry cleanup to delete old, untagged images and keep storage manageable.
7. Monitor Pipeline Duration
Add a job that logs pipeline duration and alerts if it exceeds a threshold:
pipeline-metrics:
stage: deploy-production
image: alpine:3.19
script:
- |
DURATION=$(( $(date +%s) - $(date -d "$CI_PIPELINE_CREATED_AT" +%s) ))
echo "Pipeline duration: ${DURATION}s"
if [ $DURATION -gt 1800 ]; then
echo "WARNING: Pipeline exceeded 30 minutes threshold"
fi
when: always
8. Implement Rollback Readiness
Always tag the previous production image before deploying a new one, so you can instantly revert:
# Before deploying, save rollback tag
tag-rollback:
stage: deploy-production
image: docker:24-dind
services:
- docker:24-dind
script:
- docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
- docker pull ${CI_REGISTRY_IMAGE}:production-current || echo "No current production image"
- |
if docker manifest inspect ${CI_REGISTRY_IMAGE}:production-current 2>/dev/null; then
docker tag ${CI_REGISTRY_IMAGE}:production-current ${CI_REGISTRY_IMAGE}:rollback-$(date +%Y%m%d%H%M%S)
docker push ${CI_REGISTRY_IMAGE}:rollback-$(date +%Y%m%d%H%M%S)
fi
- docker tag $PROD_IMAGE_TAG ${CI_REGISTRY_IMAGE}:production-current
- docker push ${CI_REGISTRY_IMAGE}:production-current
needs:
- docker-build
Troubleshooting Common Issues
"Cannot connect to Docker daemon"
This is the most frequent error when setting up DinD. Verify:
- The
DOCKER_HOSTvariable is set totcp://docker:2375(the hostnamedockermatches the service container alias). - The
DOCKER_TLS_CERTDIRvariable is either empty or correctly configured. - The runner is registered with
--docker-privilegedflag. - If using socket binding, the host socket path is mounted correctly.
Out of Disk Space
Docker images accumulate on runners. Implement a cleanup step or use ephemeral runners that are destroyed after each job:
after_script:
- docker system prune -af --volumes || true
Slow Builds Despite Caching
If builds remain slow, profile your Dockerfile with docker build --progress=plain to identify which layer takes the longest. Common culprits are npm install, pip install, or large file copies. Consider using .dockerignore to exclude unnecessary files:
# .dockerignore
.git
.gitlab-ci.yml
*.md
node_modules
test/
coverage/
dist/
*.log
.env
.env.*
docker-compose.yml
README.md
Conclusion
Docker with GitLab CI forms a powerful foundation for production delivery pipelines. By containerizing every build step, you gain reproducibility, immutability, and portability across environments. The combination of DinD services, multi-stage Dockerfiles, and GitLab's integrated container registry lets teams go from commit to deployed container with minimal friction.
The practices outlined here—pinning base images, implementing image signing, enforcing size limits, maintaining rollback tags, and gating production deployments behind manual approval—turn a basic CI pipeline into a production-grade delivery system. As your deployment topology grows, these patterns scale naturally from single-container deployments to Kubernetes clusters managed through GitOps.
Start with a simple build-and-push pipeline, then incrementally add testing, scanning, and deployment stages. Each addition hardens your delivery process without requiring a fundamental rearchitecture. The result is a pipeline that not only builds containers but instills confidence that every image reaching production is tested, scanned, signed, and ready for the real world.