Back to blog
March 2026 6 min read

From Commit to Cluster: A GitLab CI + Helm Pipeline, End to End

Standing up a full CI/CD loop — a local Docker registry, Vault-backed secrets, a self-hosted GitLab Runner with SonarQube scanning, and a two-pipeline Helm deployment flow from build to cluster.

GitLab CI Helm Kubernetes Vault SonarQube DevOps Docker

A complete deployment pipeline touches more than just .gitlab-ci.yml — it needs somewhere to push images, a runner that can actually reach your cluster, secrets that don't live in plaintext, and code quality gates before anything ships. Here's the full chain, from a local registry up to a two-pipeline Helm release flow.


Step 1: Local Docker Registry

For testing without pushing to a hosted registry, run one locally with persistent storage:

docker run -d \
  --name local-registry \
  -p 5000:5000 \
  -v /workdir/data/registry:/var/lib/registry \
  registry:2
  • -p 5000:5000 — expose the registry on port 5000
  • -v /workdir/data/registry:/var/lib/registry — persist image data on the host
  • registry:2 — the official Docker Registry image

Allow Insecure (HTTP) Registry Access

Since this registry serves plain HTTP, Docker needs to be told to trust it explicitly:

// /etc/docker/daemon.json
{
  "insecure-registries": ["localhost:5000"]
}

If the registry lives on another LAN host rather than the local machine, use that host's address instead of localhost (e.g. 192.168.1.100:5000).

Run an Image from the Local Registry

docker run -d -p 7099:7099 --name demo_app localhost:5000/demojavaapp:v1.0.2

Step 2: Store App Secrets in Vault

Unseal and authenticate to Vault, then write the demo app's credentials into KV storage:

kubectl exec -n vault vault-0 -- vault operator unseal <unseal-key>
kubectl exec -n vault vault-0 -- vault login <vault-root-token>
kubectl exec -n vault vault-0 -- vault kv put secret/demoapp/secrets \
  username="mubin" \
  password="<demo-app-password>" \
  host="demojavaapp.dev.svc.cluster.local" \
  port="7099"

From here, an ExternalSecret synced via ESO (see the Vault + ESO guide) is what actually delivers these into the app's namespace as a Kubernetes Secret — no need to duplicate credentials in CI variables.


Step 3: Install a Self-Hosted GitLab Runner

RHEL 10 — Manual Binary Install

sudo curl -L --output /usr/local/bin/gitlab-runner \
  "https://s3.dualstack.us-east-1.amazonaws.com/gitlab-runner-downloads/latest/binaries/gitlab-runner-linux-amd64"
chmod +x /usr/local/bin/gitlab-runner

useradd --comment 'GitLab Runner' --create-home gitlab-runner --shell /bin/bash
gitlab-runner install --user=gitlab-runner --working-directory=/home/gitlab-runner
gitlab-runner start

Alternative — Package Manager Install

Ubuntu:

curl -L "https://packages.gitlab.com/install/repositories/runner/gitlab-runner/script.deb.sh" | sudo bash
sudo apt install gitlab-runner

RHEL/CentOS:

curl -L "https://packages.gitlab.com/install/repositories/runner/gitlab-runner/script.rpm.sh" | sudo bash
sudo yum install gitlab-runner

Either way, add the runner user to the docker group so it can drive Docker-executor jobs:

sudo usermod -aG docker gitlab-runner

Register the Runner

gitlab-runner register

You'll be walked through an interactive prompt:

  • GitLab instance URL — your self-hosted or gitlab.com URL
  • Registration token — from your GitLab instance's runner settings (this is a one-time secret; rotate/revoke it in GitLab if it's ever exposed)
  • Description — a human-readable label for the runner
  • Tags — comma-separated, e.g. build,deploy,helm,scan,notify — these tags are how you target specific jobs at this runner in your CI YAML
  • Executordocker is the common choice for reproducible, isolated jobs
  • Default Docker image — a lightweight base like alpine:latest

GitLab Runner 15.6+ deprecated registration tokens in favor of authentication tokens — check your GitLab version and prefer the newer flow if available.

The resulting config (including the runner's auth token) is saved to /etc/gitlab-runner/config.toml.


Step 4: Set Up SonarQube Scanning on the Build Server

cd /home/gitlab-runner
wget https://binaries.sonarsource.com/Distribution/sonar-scanner-cli/sonar-scanner-cli-7.2.0.5079-linux-x64.zip
unzip sonar-scanner-cli-7.2.0.5079-linux-x64.zip

Configure Java and PATH

echo 'export JAVA_HOME=/usr/lib/jvm/java-21-openjdk-amd64' >> ~/.bashrc
echo 'export PATH=$JAVA_HOME/bin:$PATH' >> ~/.bashrc
echo 'export PATH=$PATH:/home/gitlab-runner/sonar-scanner/bin' >> ~/.bashrc
source ~/.bashrc

Fixing Persistent JAVA_HOME/PATH Errors

If the scanner keeps failing to find Java — usually because the JDK installed doesn't ship a JRE the bundled scanner expects — disable the scanner's embedded JRE:

# Back up first
sudo cp $(which sonar-scanner) $(which sonar-scanner).backup

# Disable embedded JRE so it uses your system JAVA_HOME instead
sudo sed -i 's/use_embedded_jre=true/use_embedded_jre=false/' $(which sonar-scanner)

And make sure the scanner binary is on PATH system-wide, not just in the runner user's shell profile — CI jobs don't always source .bashrc:

echo 'export PATH=$PATH:/home/gitlab-runner/sonar-scanner/bin' | sudo tee -a /etc/profile

Step 5: Give the Runner Cluster Access

Copy the cluster kubeconfig into the runner's home directory so CI jobs can run kubectl/helm against it directly:

sudo cp /etc/rancher/k3s/k3s.yaml /home/gitlab-runner/.kube/config
sudo chown gitlab-runner:gitlab-runner /home/gitlab-runner/.kube/config
sudo chmod 600 /home/gitlab-runner/.kube/config

chmod 600 matters here — this file has full cluster-admin credentials by default; keep it readable only by the runner user.


The Pipeline Design: Two Repos, Two Pipelines

This setup splits application code and Helm chart into separate repositories, each with its own pipeline, connected by a shared image tag:

demojavaapp.git          → Pipeline 1: build image, push, update chart repo
demojavaapp-dev-chart.git → Pipeline 2: package chart, deploy to cluster

Pipeline 1 — Build & Update Chart

flowchart TD
    A1([Start: Commit to dev/uat/prod]) --> B1[Build Docker Image]
    B1 --> C1{Commit Branch?}
    C1 -->|dev| D1[Build dev image]
    C1 -->|uat| E1[Build uat image]
    C1 -->|prod| F1[Build prod image]
    D1 & E1 & F1 --> G1[Push image to registry]
    G1 --> H1[Export IMAGE_TAG artifact]
    H1 --> I1{Manual approval?}
    I1 -->|Yes| J1[Update Helm values via GitLab API]
    I1 -->|No| K1[Skip update]
    J1 --> L1[Commit to chart repo]
    L1 --> M1[Notify success - Discord]
    J1 -->|Fail| N1[Notify failure - Discord]

Branch (dev/uat/prod) determines which image variant gets built and pushed. The image tag is exported as a pipeline artifact, and — gated behind manual approval — a job updates the Helm chart repo's values file via the GitLab API, committing the new tag so the chart repo's pipeline picks it up.

Pipeline 2 — Package & Deploy

flowchart TD
    A2([Start: Commit to dev/uat/prod]) --> B2[Package Helm chart]
    B2 --> C2[Push chart to local OCI registry]
    C2 --> D2[Export VERSION + CHART_TAG]
    D2 --> E2{Commit Branch?}
    E2 -->|dev| F2[Deploy with values-dev.yaml]
    E2 -->|uat| G2[Deploy with values-uat.yaml]
    E2 -->|prod| H2[Deploy with values-prod.yaml]
    F2 & G2 & H2 --> I2[helm upgrade/install to target namespace]
    I2 --> J2[Notify success - Discord]
    I2 -->|Fail| K2[Notify failure - Discord]

This pipeline packages the chart, pushes it to a local OCI registry for versioning, then deploys via helm upgrade --install using the branch-specific values file — landing in the namespace that matches the environment.

The full loop: commit → build image → (approve) → update chart values → chart pipeline packages and deploys → Discord notification either way.


Summary

| Component | Role | |---|---|

  • Local Docker registry | Fast, private image storage for testing |
  • Vault | Source of truth for app secrets, synced via ESO |
  • GitLab Runner | Self-hosted executor with Docker + cluster access |
  • SonarQube scanner | Code quality/security gate before deploy |
  • Pipeline 1 | Build image → push → update chart repo |
  • Pipeline 2 | Package chart → deploy via Helm → notify |

Golden rule: keep the registration token, Vault root token, and kubeconfig out of anything that gets committed — they're one-time or long-lived credentials that hand over serious access if leaked, and none of them belong in a .gitlab-ci.yml or a public repo history.