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.
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 hostregistry: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 - Executor —
dockeris 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.