← Back to DevBytes

Docker with GitLab CI: Production Guide

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:

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:

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:

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:

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 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:

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:

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.

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles