Skip to main content

Managed GitLab CI Runners

This guide explains how to configure your GitLab CI/CD pipelines to work on Bunker's managed GitLab-CI runners, which do not allow privileged mode for security reasons.

Objective

Build and deploy container images without Docker-in-Docker (dind), using tools compatible with a non-privileged Kubernetes environment.

Prerequisites

  • A GitLab project configured to use Bunker runners
  • A container registry (GitLab Container Registry, etc.)
  • Registry credentials stored in CI/CD variables

Tools to Use

BuildKit Rootless - Image Building

BuildKit is Docker's next-generation build engine. Rootless mode allows building images without a Docker daemon and without root privileges.

When to use: For any image building (equivalent to docker build)

Official image: moby/buildkit:rootless

Advantages over Kaniko:

  • Better performance and memory usage
  • More efficient caching
  • Native multi-platform build support
  • Actively maintained

Crane - Registry Operations

Crane manipulates images in registries without Docker.

When to use:

  • Copy an image between registries (equivalent to docker tag + docker push)
  • Extract files from an image (docker cp)
  • Registry authentication

Installation:

curl -sL "https://github.com/google/go-containerregistry/releases/download/v0.20.2/go-containerregistry_Linux_x86_64.tar.gz" | tar -xzf - -C /usr/local/bin crane

What NOT to Do

ForbiddenWhyRecommended Alternative
services: [docker:dind]Requires privileged modeBuildKit rootless
docker buildNo Docker daemonBuildKit rootless
docker push/pullNo Docker daemonCrane or BuildKit
docker runNo Docker daemonDirect image in image:
docker cpNo Docker daemoncrane export
image: docker:*Useless without daemonJob-specific image
Variable DOCKER_HOSTNo daemonRemove
Variable DOCKER_TLS_CERTDIRNo daemonRemove
pull_policy: [Always]Not allowed by runnerDon't specify (uses default)

Why No Docker-in-Docker (DinD)?

Bunker Kubernetes runners do not allow privileged: true mode and therefore DinD for 3 reasons:

  1. Security: Privileged mode gives full root access to the host node, forget it :)
  2. Isolation: Privileged containers can escape their sandbox
  3. Multi-tenancy: On a shared cluster, this would compromise isolation between clients

What You SHOULD Do

1. Image Building with BuildKit Rootless

build:
stage: build
image:
name: moby/buildkit:rootless
entrypoint: ['sh', '-c']
variables:
BUILDKITD_FLAGS: --oci-worker-no-process-sandbox
script:
- buildctl-daemonless.sh build
--frontend dockerfile.v0
--local context=.
--local dockerfile=.
--output type=image,name=$CI_REGISTRY_IMAGE:$CI_COMMIT_SHA,push=true

Key points:

  • entrypoint: ['sh', '-c'] is mandatory to execute shell commands
  • BUILDKITD_FLAGS: --oci-worker-no-process-sandbox disables sandboxing (required without privileges)
  • buildctl-daemonless.sh launches BuildKit without a persistent daemon
  • Use immutable tags (SHA) for traceability

2. Registry Authentication

GitLab Container Registry:

before_script:
- mkdir -p ~/.docker
- echo "{\"auths\":{\"$CI_REGISTRY\":{\"auth\":\"$(echo -n $CI_REGISTRY_USER:$CI_REGISTRY_PASSWORD | base64)\"}}}" > ~/.docker/config.json

3. Build Cache

BuildKit supports remote caching to speed up builds:

script:
- buildctl-daemonless.sh build
--frontend dockerfile.v0
--local context=.
--local dockerfile=.
--import-cache type=registry,ref=$CI_REGISTRY_IMAGE:cache
--export-cache type=inline
--output type=image,name=$CI_REGISTRY_IMAGE:$CI_COMMIT_SHA,push=true

4. Extracting Files from an Image

To run tests with dependencies from the built image while respecting the immutable build principle:

test:
image: node:20 # If you have a NodeJS project
before_script:
- curl -sL "https://github.com/google/go-containerregistry/releases/download/v0.20.2/go-containerregistry_Linux_x86_64.tar.gz" | tar -xzf - -C /usr/local/bin crane
- crane auth login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
- crane export $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA - | tar -xf - -C / usr/src/app
script:
- cd /usr/src/app
- npm test

Note: The job's base image (image: node:20) must have the same Node.js version as the Dockerfile to avoid native module errors (NODE_MODULE_VERSION mismatch).

5. Copying Between Registries

To deploy to multiple regions:

deploy:
script:
- curl -sL "https://github.com/google/go-containerregistry/releases/download/v0.20.2/go-containerregistry_Linux_x86_64.tar.gz" | tar -xzf - -C /usr/local/bin crane
- crane auth login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
- crane copy $SOURCE_IMAGE $DEST_IMAGE

6. Multi-Platform Build

BuildKit excels at multi-architecture builds:

script:
- buildctl-daemonless.sh build
--frontend dockerfile.v0
--local context=.
--local dockerfile=.
--opt platform=linux/amd64,linux/arm64
--output type=image,name=$CI_REGISTRY_IMAGE:$CI_COMMIT_SHA,push=true

Immutable Build Principle

We recommend following the "build once, deploy everywhere" principle:

  1. Single build: Build the image once in the build stage
  2. Immutable tag: Use ${CI_COMMIT_SHA} in the tag
  3. Reuse: Subsequent jobs extract or use this image
  4. No rebuild: Never rebuild for tests or deployment
build ──► tests (extract app) ──► security scan ──► deploy (copy image)
│ │ │ │
└──────────────┴─────────────────────────┴────────────────────┘
Same image everywhere

Common Errors

"Cannot connect to Docker daemon"

Cause: You're using a docker command without a daemon.

Solution: Replace with BuildKit rootless (build) or Crane (registry operations).

"could not connect to buildkitd.sock"

Cause: BuildKit is trying to use a Unix socket instead of daemonless mode.

Solution: Use buildctl-daemonless.sh (not buildctl) and verify BUILDKITD_FLAGS is defined.

"Unauthorized" on crane export

Cause: Crane is not authenticated to the registry.

Solution: Add crane auth login before crane export.

"NODE_MODULE_VERSION mismatch"

Cause: Job image uses a different Node.js version than the Dockerfile.

Solution: Align image: with the Dockerfile version.

"exec format error"

Cause: Image built for a different architecture.

Solution: Specify --opt platform=linux/amd64 in BuildKit.

"invalid pull policy for container"

Cause: Your GitLab CI configuration (or an included template) specifies a pull_policy (e.g., [Always]) not allowed by the Kubernetes runner.

ERROR: Job failed: invalid pull policy for container "build":
pull_policy ([Always]) defined in GitLab pipeline config is not one of the allowed_pull_policies ([])

Solution: Don't specify pull_policy in your configuration. The runner will use its default policy. If you include an external template that defines pull_policy, replace it with inline configuration:

# ❌ Avoid templates that define pull_policy
include:
- project: 'some-org/some-template'
file: '/template.gitlab-ci.yml'

# ✅ Prefer inline configuration without pull_policy
my-job:
stage: build
image: my-image:latest
# Don't specify pull_policy - runner will use its default policy
script:
- echo "Build"

Complete Example (Node.js app)

Here's a complete pipeline illustrating all best practices:

# .gitlab-ci.yml - Pipeline without privileged mode for Bunker
# Uses BuildKit rootless for image building

variables:
# Immutable tag based on commit SHA
BUILD_IMAGE: '${CI_REGISTRY_IMAGE}:${CI_COMMIT_REF_SLUG}-${CI_COMMIT_SHA}'
# BuildKit rootless configuration
BUILDKITD_FLAGS: --oci-worker-no-process-sandbox

stages:
- build
- test
- security
- deploy

# ============================================
# STAGE: BUILD - Building with BuildKit
# ============================================
build:
stage: build
image:
name: moby/buildkit:rootless
entrypoint: ['sh', '-c']
before_script:
- mkdir -p ~/.docker
- echo "{\"auths\":{\"$CI_REGISTRY\":{\"auth\":\"$(echo -n $CI_REGISTRY_USER:$CI_REGISTRY_PASSWORD | base64)\"}}}" > ~/.docker/config.json
script:
- buildctl-daemonless.sh build
--frontend dockerfile.v0
--local context=.
--local dockerfile=.
--import-cache type=registry,ref=$CI_REGISTRY_IMAGE:cache
--export-cache type=inline
--output type=image,name=$BUILD_IMAGE,push=true
rules:
- when: on_success

# ============================================
# TEMPLATE: Extract app from image
# ============================================
.extract_app: &extract_app
# IMPORTANT: Must match Node.js version from Dockerfile
image: node:20
before_script:
- curl -sL "https://github.com/google/go-containerregistry/releases/download/v0.20.2/go-containerregistry_Linux_x86_64.tar.gz" | tar -xzf - -C /usr/local/bin crane
- crane auth login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
- crane export $BUILD_IMAGE - | tar -xf - -C / usr/src/app

# ============================================
# STAGE: TEST - Tests on built image
# ============================================
test:
<<: *extract_app
stage: test
coverage: '/Lines[^:]+\:\s+(\d+\.\d+)\%/'
script:
- cd /usr/src/app
- npm test
artifacts:
reports:
junit: /usr/src/app/coverage/junit.xml
paths:
- /usr/src/app/coverage/
rules:
- when: on_success

lint:
<<: *extract_app
stage: test
script:
- cd /usr/src/app
- npm run lint
rules:
- when: on_success

# ============================================
# STAGE: SECURITY - Vulnerability scans
# ============================================
image-security:
stage: security
image: aquasec/trivy:0.56.2
allow_failure: true
script:
- trivy image
--exit-code 0
--format template
--template "@/contrib/gitlab.tpl"
-o gl-container-scanning-report.json
$BUILD_IMAGE
- trivy image --exit-code 1 --severity CRITICAL $BUILD_IMAGE
artifacts:
reports:
container_scanning: gl-container-scanning-report.json
rules:
- when: on_success

dependency-scan:
stage: security
image: ghcr.io/google/osv-scanner:v1.9.1
allow_failure: true
before_script:
- apk add --no-cache curl
- curl -sL "https://github.com/google/go-containerregistry/releases/download/v0.20.2/go-containerregistry_Linux_x86_64.tar.gz" | tar -xzf - -C /usr/local/bin crane
- crane auth login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
- crane export $BUILD_IMAGE - | tar -xf - usr/src/app/package-lock.json --strip-components=3
script:
- /osv-scanner --lockfile package-lock.json
rules:
- when: on_success

# ============================================
# STAGE: DEPLOY - Production deployment
# ============================================
deploy-production:
stage: deploy
image: bitnami/kubectl:latest
before_script:
- curl -sL "https://github.com/google/go-containerregistry/releases/download/v0.20.2/go-containerregistry_Linux_x86_64.tar.gz" | tar -xzf - -C /usr/local/bin crane
- crane auth login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
script:
# Optional: copy to dedicated registry
- crane copy $BUILD_IMAGE $PRODUCTION_REGISTRY/$PROJECT:$CI_COMMIT_SHA
# Deploy to Kubernetes
- envsubst < deploy/deployment.yaml | kubectl apply -f -
- kubectl rollout status deployment/my-app
environment:
name: production
rules:
- if: '$CI_COMMIT_BRANCH == "main"'

Resources

Next Steps

Check out the Concepts to understand Bunker's architecture.