For a long time my CI lived on someone else’s servers. Push code, wait for a hosted runner, watch the minutes tick down against a monthly quota, hope the provider doesn’t change the rules next quarter. It worked, right up until I started asking where my source code actually sat while it was being built, and who else could see it.

So I moved GitLab in-house. I run it self-hosted now, on my own hardware, next to the clusters it deploys to. That’s sovereignty applied to CI/CD: my code, my builds, my artifacts, no vendor that can change pricing, deprecate a feature I depend on, or read my repositories without me knowing.

The reasons stack up once you start counting:

  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, so artifact transfers are fast
  4. Customisation: configure runners exactly how you need them
  5. Air-gap capable: works in offline environments

None of this is free. You maintain GitLab now. You patch it, back it up, and you own the 2am page when a runner pod gets OOM-killed. For me that overhead buys something I can’t get any other way, so I pay it gladly.

That settles where the pipeline runs. The harder question is what it should actually do, and the part most people get wrong is the last step.

The complication: how does code get from a commit into a running pod, safely?

Building an image is easy. Anyone can write a docker build. The mess starts after the image exists. How do you stop a vulnerable image reaching production? Where do the cluster credentials live, and how many places get to hold them? When a deploy goes sideways, can you tell what changed and roll it back without spelunking through job logs?

The naive answer is to give the CI pipeline a kubeconfig and let it run kubectl apply at the end. It works on day one. By day ninety you’ve got cluster admin credentials sitting in a dozen project pipelines, no audit trail of what’s actually running, and a deploy process that only exists as imperative shell commands inside a YAML file.

I’d rather draw a hard line between what CI is good at and what it has no business touching.

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, and the last one stops at the GitOps repo rather than the cluster. More on why that matters below. 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

That’s a lot of YAML at once. Let me walk through the stages that earn their keep.

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. The tag tells you exactly which commit produced the image, which you’ll want the first time a deploy misbehaves
  • Also tag latest: for the cache source in the 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 the coverage percentage so GitLab can show it on the merge request. Small thing, but it puts the number in front of you at review time instead of buried in a log nobody opens.

Scan Stage: Security Gates

This is where the pipeline starts saying no on your behalf. Three scans, each catching a different class of mistake:

  1. Container scanning (Trivy): finds vulnerabilities in container images
  2. SAST: static analysis of source code
  3. Secret detection: catches credentials someone accidentally committed
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

Here’s the answer to the question I posed up top. The pipeline never touches the cluster. It updates a GitOps repository, and ArgoCD does the actual deployment by reconciling the cluster against what’s now in Git.

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 win is in what’s missing. The application repo never holds cluster credentials. It only needs write access to one GitOps repo. Compromise a project pipeline and the blast radius is a Git commit someone will notice on review, not a kubeconfig with the keys to the cluster. Every deploy is now a commit with a diff, an author, and a timestamp, so the question “what’s running right now and who put it there” has a one-line answer. And when production catches fire, rollback is git revert, which is muscle memory rather than a fresh round of imperative kubectl under pressure.

That separation is the whole point. CI builds and proves the artifact; Git records intent; ArgoCD makes reality match. Each part does the thing it’s actually good at.

GitLab Runners for Kubernetes

None of this runs without GitLab Runners to execute the jobs. I run them in Kubernetes too, so the same cluster that hosts my workloads also builds them:

# 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

Mark the GitOps SSH key both protected and masked. A feature branch should never be able to push a deploy, and the key should never show up in a log a teammate can scroll back through.

Merge Request Pipelines

You don’t need the full pipeline on every push. Merge requests run a subset so feedback comes back fast:

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

Fast feedback on the MR, the heavier jobs reserved for the default branch. A pipeline you wait ten minutes for on every push is a pipeline people start skipping.

Caching for Speed

The other thing that keeps a pipeline from getting ignored is speed, and caching does most of the work:

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

A pipeline is infrastructure, and infrastructure you don’t watch is infrastructure you don’t understand. Scrape GitLab’s own metrics:

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

Key metrics to watch:

  • Pipeline duration (creeping up means caching is failing somewhere)
  • Success/failure rate
  • Queue time (climbing queue time means runners are saturated)
  • 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

Each box knows nothing about the others’ internals. CI doesn’t know how the cluster applies manifests. The cluster doesn’t know how the image got built. They meet at a Git commit, which is the most boring and most inspectable interface I could ask for.

My Recommendations

  1. Self-host GitLab if you care about sovereignty. The operational overhead is real, and it’s worth it.

  2. Never deploy directly from CI to Kubernetes. Update GitOps repos and let a reconciler do the applying.

  3. Fail fast on security: container scans with --exit-code 1 block vulnerable images before they ship.

  4. Use protected variables: secrets should only be reachable from protected branches.

  5. Manual production deploys: keep a human in the loop for production.


The pipeline I trust most is the one I’d be happy to explain to a stranger line by line. Build, prove, record, reconcile. Nothing magic, nothing hidden, and a Git history that tells you exactly what happened when something breaks.