I run GitLab self-hosted. Not because it’s trendy, but because I want to own my CI/CD pipeline. No vendor can change pricing, deprecate features, or access my code without my knowledge.

This is sovereignty applied to CI/CD. And GitLab makes it practical.

Let me show you how to build a complete pipeline: from code commit to running in Kubernetes.

Why Self-Hosted GitLab?

Before we dive into pipelines, the “why” matters:

  1. Data sovereignty: Your code, your builds, your artifacts stay on your infrastructure
  2. No usage limits: Unlimited CI minutes, unlimited storage, unlimited users
  3. Network locality: Builds run close to your clusters, faster artifact transfers
  4. Customization: Configure runners exactly how you need them
  5. Air-gap capable: Works in offline environments

The trade-off is operational overhead. You maintain GitLab. For me, that’s worth it.

The Pipeline Architecture

A Kubernetes deployment pipeline has distinct stages:

flowchart LR
    subgraph pipeline["GitLab CI Pipeline"]
        Build["Build<br/>Compile, Lint,<br/>Build image"] --> Test["Test<br/>Unit, Integr.,<br/>E2E"]
        Test --> Scan["Scan<br/>Trivy, SAST,<br/>Secrets"]
        Scan --> Publish["Publish<br/>Push to Registry,<br/>Sign"]
        Publish --> Deploy["Deploy<br/>Update GitOps<br/>Repo"]
    end

Each stage has a clear purpose. Let’s build it.

The Complete .gitlab-ci.yml

Here’s a production-ready pipeline:

stages:
  - build
  - test
  - scan
  - publish
  - deploy

variables:
  # Container settings
  DOCKER_HOST: tcp://docker:2376
  DOCKER_TLS_CERTDIR: "/certs"
  DOCKER_TLS_VERIFY: 1
  DOCKER_CERT_PATH: "$DOCKER_TLS_CERTDIR/client"

  # Image naming
  IMAGE_NAME: $CI_REGISTRY_IMAGE
  IMAGE_TAG: $CI_COMMIT_SHORT_SHA

  # Kubernetes
  KUBE_NAMESPACE: $CI_PROJECT_NAME

# Reusable configurations
.docker-base:
  image: docker:24
  services:
    - docker:24-dind
  before_script:
    - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY

# ============================================
# BUILD STAGE
# ============================================

build:
  extends: .docker-base
  stage: build
  script:
    - docker build
        --cache-from $IMAGE_NAME:latest
        --tag $IMAGE_NAME:$IMAGE_TAG
        --tag $IMAGE_NAME:latest
        --build-arg BUILDKIT_INLINE_CACHE=1
        .
    - docker push $IMAGE_NAME:$IMAGE_TAG
    - docker push $IMAGE_NAME:latest
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
    - if: $CI_MERGE_REQUEST_ID

lint:
  stage: build
  image: golangci/golangci-lint:latest  # Adjust for your language
  script:
    - golangci-lint run ./...
  rules:
    - if: $CI_MERGE_REQUEST_ID

# ============================================
# TEST STAGE
# ============================================

unit-tests:
  stage: test
  image: golang:1.22  # Adjust for your language
  script:
    - go test -v -race -coverprofile=coverage.out ./...
    - go tool cover -func=coverage.out
  coverage: '/total:\s+\(statements\)\s+(\d+\.\d+)%/'
  artifacts:
    reports:
      coverage_report:
        coverage_format: cobertura
        path: coverage.xml
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
    - if: $CI_MERGE_REQUEST_ID

integration-tests:
  stage: test
  extends: .docker-base
  script:
    - docker compose -f docker-compose.test.yml up -d
    - docker compose -f docker-compose.test.yml run tests
    - docker compose -f docker-compose.test.yml down
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH

# ============================================
# SCAN STAGE
# ============================================

container-scan:
  stage: scan
  image:
    name: aquasec/trivy:latest
    entrypoint: [""]
  script:
    - trivy image
        --exit-code 1
        --severity HIGH,CRITICAL
        --ignore-unfixed
        $IMAGE_NAME:$IMAGE_TAG
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
    - if: $CI_MERGE_REQUEST_ID
  allow_failure: false

sast:
  stage: scan
  image: returntocorp/semgrep
  script:
    - semgrep --config auto --error .
  rules:
    - if: $CI_MERGE_REQUEST_ID

secret-detection:
  stage: scan
  image: trufflesecurity/trufflehog:latest
  script:
    - trufflehog git file://. --only-verified --fail
  rules:
    - if: $CI_MERGE_REQUEST_ID

# ============================================
# PUBLISH STAGE
# ============================================

publish:
  extends: .docker-base
  stage: publish
  script:
    # Tag with semantic version if tagged
    - |
      if [ -n "$CI_COMMIT_TAG" ]; then
        docker pull $IMAGE_NAME:$IMAGE_TAG
        docker tag $IMAGE_NAME:$IMAGE_TAG $IMAGE_NAME:$CI_COMMIT_TAG
        docker push $IMAGE_NAME:$CI_COMMIT_TAG
      fi
  rules:
    - if: $CI_COMMIT_TAG

# ============================================
# DEPLOY STAGE
# ============================================

deploy-staging:
  stage: deploy
  image: alpine:latest
  before_script:
    - apk add --no-cache git openssh-client
    - eval $(ssh-agent -s)
    - echo "$GITOPS_SSH_KEY" | tr -d '\r' | ssh-add -
    - mkdir -p ~/.ssh && chmod 700 ~/.ssh
    - ssh-keyscan gitlab.com >> ~/.ssh/known_hosts
  script:
    - git clone git@gitlab.com:$GITOPS_REPO.git gitops
    - cd gitops
    - |
      # Update image tag in Kustomize
      cd apps/$CI_PROJECT_NAME/overlays/staging
      kustomize edit set image $IMAGE_NAME:$IMAGE_TAG
    - git config user.email "ci@gitlab.local"
    - git config user.name "GitLab CI"
    - git add -A
    - git commit -m "Deploy $CI_PROJECT_NAME:$IMAGE_TAG to staging" || exit 0
    - git push
  environment:
    name: staging
    url: https://staging.example.com
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH

deploy-production:
  stage: deploy
  image: alpine:latest
  before_script:
    - apk add --no-cache git openssh-client
    - eval $(ssh-agent -s)
    - echo "$GITOPS_SSH_KEY" | tr -d '\r' | ssh-add -
    - mkdir -p ~/.ssh && chmod 700 ~/.ssh
    - ssh-keyscan gitlab.com >> ~/.ssh/known_hosts
  script:
    - git clone git@gitlab.com:$GITOPS_REPO.git gitops
    - cd gitops
    - |
      cd apps/$CI_PROJECT_NAME/overlays/production
      kustomize edit set image $IMAGE_NAME:$IMAGE_TAG
    - git config user.email "ci@gitlab.local"
    - git config user.name "GitLab CI"
    - git add -A
    - git commit -m "Deploy $CI_PROJECT_NAME:$IMAGE_TAG to production" || exit 0
    - git push
  environment:
    name: production
    url: https://example.com
  rules:
    - if: $CI_COMMIT_TAG
  when: manual  # Require manual approval for production

Breaking Down the Pipeline

Build Stage: Creating the Container

build:
  extends: .docker-base
  stage: build
  script:
    - docker build
        --cache-from $IMAGE_NAME:latest
        --tag $IMAGE_NAME:$IMAGE_TAG
        --tag $IMAGE_NAME:latest
        .

Key points:

  • Cache from previous builds: --cache-from speeds up builds dramatically
  • Commit SHA as tag: Immutable, traceable image versions
  • Also tag latest: For cache source in next build

Test Stage: Verify Before Deploy

Tests run in parallel where possible:

unit-tests:
  # ...
  coverage: '/total:\s+\(statements\)\s+(\d+\.\d+)%/'

The coverage regex extracts coverage percentage for GitLab’s merge request display.

Scan Stage: Security Gates

Three security scans:

  1. Container scanning (Trivy): Find vulnerabilities in container images
  2. SAST: Static analysis of source code
  3. Secret detection: Catch accidentally committed credentials
container-scan:
  script:
    - trivy image
        --exit-code 1          # Fail pipeline on findings
        --severity HIGH,CRITICAL  # Only block on serious issues
        --ignore-unfixed       # Skip vulnerabilities without fixes

Deploy Stage: GitOps Integration

This is where it gets interesting. We don’t deploy directly to Kubernetes. We update the GitOps repository, and ArgoCD does the actual deployment.

deploy-staging:
  script:
    - git clone git@gitlab.com:$GITOPS_REPO.git gitops
    - cd gitops/apps/$CI_PROJECT_NAME/overlays/staging
    - kustomize edit set image $IMAGE_NAME:$IMAGE_TAG
    - git commit -m "Deploy $CI_PROJECT_NAME:$IMAGE_TAG to staging"
    - git push

This pattern:

  1. Clones the GitOps repository
  2. Updates the image tag using Kustomize
  3. Commits and pushes
  4. ArgoCD detects the change and deploys

The application code repo doesn’t need cluster credentials. It only needs write access to the GitOps repo.

GitLab Runners for Kubernetes

For this to work, you need GitLab Runners. I run them in Kubernetes:

# values.yaml for gitlab-runner Helm chart
gitlabUrl: https://gitlab.example.com
runnerRegistrationToken: "your-token"

runners:
  config: |
    [[runners]]
      [runners.kubernetes]
        namespace = "gitlab-runners"
        image = "alpine:latest"
        privileged = true  # Required for Docker-in-Docker

        [[runners.kubernetes.volumes.empty_dir]]
          name = "docker-certs"
          mount_path = "/certs/client"
          medium = "Memory"

rbac:
  create: true

resources:
  limits:
    memory: 256Mi
    cpu: 250m

Install with Helm:

helm repo add gitlab https://charts.gitlab.io
helm install gitlab-runner gitlab/gitlab-runner -f values.yaml -n gitlab-runners

Environment Variables and Secrets

Store sensitive values in GitLab CI/CD variables:

Project Settings → CI/CD → Variables

VariableProtectedMaskedDescription
GITOPS_SSH_KEYSSH key for GitOps repo
KUBECONFIGKubernetes config (if direct deploy)
SONAR_TOKENSonarQube token

Protected: Only available on protected branches Masked: Hidden in job logs

Merge Request Pipelines

For merge requests, run a subset of the pipeline:

lint:
  rules:
    - if: $CI_MERGE_REQUEST_ID  # Only on MRs

unit-tests:
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
    - if: $CI_MERGE_REQUEST_ID  # Also on MRs

This gives fast feedback without wasting resources on every push.

Caching for Speed

Speed up pipelines with caching:

variables:
  GOPATH: $CI_PROJECT_DIR/.go

cache:
  key: ${CI_COMMIT_REF_SLUG}
  paths:
    - .go/pkg/mod/
    - node_modules/
    - .npm/

For Docker layer caching, use BuildKit:

build:
  variables:
    DOCKER_BUILDKIT: 1
  script:
    - docker build --build-arg BUILDKIT_INLINE_CACHE=1 ...

Monitoring Pipeline Health

Track pipeline metrics:

# In your monitoring stack
- job_name: 'gitlab'
  static_configs:
    - targets: ['gitlab.example.com']
  metrics_path: '/-/metrics'

Key metrics to watch:

  • Pipeline duration
  • Success/failure rate
  • Queue time (runners busy?)
  • Cache hit rate

The Complete Flow

flowchart TD
    Dev["Developer<br/>pushes code"] --> GitLab["GitLab<br/>triggers CI"]
    GitLab --> Pipeline["Pipeline<br/>Build → Test → Scan → Publish → Update GitOps"]
    Pipeline --> GitOpsRepo["GitOps Repo<br/>(updated)"]
    GitOpsRepo --> ArgoCD["ArgoCD<br/>detects & deploys"]
    ArgoCD --> K8s["Kubernetes<br/>(running)"]

Clean separation of concerns:

  • GitLab CI: Build, test, scan, publish
  • GitOps repo: Desired state
  • ArgoCD: Reconciliation
  • Kubernetes: Runtime

My Recommendations

  1. Self-host GitLab if you care about sovereignty. The operational overhead is worth it.

  2. Never deploy directly from CI to Kubernetes. Update GitOps repos instead.

  3. Fail fast on security: Container scans with --exit-code 1 blocks vulnerable images.

  4. Use protected variables: Secrets should only be available on protected branches.

  5. Manual production deploys: Require human approval for production.


A good CI/CD pipeline is invisible when it works and obvious when it fails. Build it once, trust it always — but verify with every commit.