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:
- Data sovereignty: Your code, your builds, your artifacts stay on your infrastructure
- No usage limits: Unlimited CI minutes, unlimited storage, unlimited users
- Network locality: Builds run close to your clusters, faster artifact transfers
- Customization: Configure runners exactly how you need them
- 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-fromspeeds 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:
- Container scanning (Trivy): Find vulnerabilities in container images
- SAST: Static analysis of source code
- 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:
- Clones the GitOps repository
- Updates the image tag using Kustomize
- Commits and pushes
- 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
| Variable | Protected | Masked | Description |
|---|---|---|---|
GITOPS_SSH_KEY | ✓ | ✓ | SSH key for GitOps repo |
KUBECONFIG | ✓ | ✓ | Kubernetes config (if direct deploy) |
SONAR_TOKEN | ✓ | ✓ | SonarQube 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
Self-host GitLab if you care about sovereignty. The operational overhead is worth it.
Never deploy directly from CI to Kubernetes. Update GitOps repos instead.
Fail fast on security: Container scans with
--exit-code 1blocks vulnerable images.Use protected variables: Secrets should only be available on protected branches.
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.
