GitLab + Helm

Learn how to package, publish, and deploy Helm charts with GitLab. From chart structure to OCI registry publishing and environment-specific deployments, build a complete Helm workflow in CI/CD.

Helm OCI Registry GitLab CI/CD
Why Helm with GitLab?

Helm is the de facto package manager for Kubernetes. It turns a collection of Kubernetes manifests into a reusable, versioned, configurable chart. Instead of maintaining separate YAML files for staging and production, you maintain one chart with environment-specific values files. Instead of copying manifests between projects, you package the chart and publish it to a registry. Helm brings structure and reusability to Kubernetes deployments, and GitLab provides the CI/CD platform to build, publish, and deploy those charts.

GitLab and Helm fit together naturally. GitLab Container Registry supports OCI artifacts, which means you can store Helm charts in the same registry as your Docker images. GitLab CI/CD can package the chart, push it to the registry, and deploy it to Kubernetes using the GitLab agent. The whole workflow — from chart change to production deployment — lives in one platform, with one set of credentials, one audit trail, and one place to look when something goes wrong.

The alternative — a separate chart repository like ChartMuseum or Artifactory — adds another service to maintain, another set of credentials to manage, and another place where things can break. For teams already using GitLab, the OCI registry is the path of least resistance. It's not the only option, but it's the one that requires the least additional infrastructure.

Core Principle: One chart, many environments. Use values files to customize per environment, and publish the chart to the OCI registry so it can be consumed by any pipeline or cluster.

Chart Structure

Chart.yaml, values.yaml, templates/. The standard Helm layout. Every chart has these files.

Values Files

values.yaml (defaults), values-staging.yaml, values-production.yaml. Environment-specific overrides.

helm package

Creates a .tgz archive from the chart. This is the artifact that gets published.

OCI Registry

GitLab Container Registry stores charts as OCI artifacts. Push with helm push, pull with helm pull.

helm upgrade --install

Idempotent deploy: installs if missing, upgrades if present. The standard deployment command.

helm rollback

Reverts to a previous release. Works alongside GitLab environment rollbacks.
Helm Chart Structure

A Helm chart is a directory with a specific structure. At minimum, it contains a Chart.yaml (metadata: name, version, description), a values.yaml (default configuration), and a templates/ directory (the Kubernetes manifests with template placeholders). Helm renders the templates with the values to produce the final Kubernetes YAML. This separation — templates for structure, values for configuration — is what makes charts reusable across environments.

The Chart.yaml is the chart's identity. The name and version fields are required. The version field follows semantic versioning and must be bumped every time the chart changes — this is how Helm tracks releases. The appVersion field is separate and refers to the application version (e.g., the Docker image tag), which is often set dynamically during CI/CD.

The templates/ directory contains standard Kubernetes manifests with Go template syntax. Helpers like {{ .Values.image.tag }} and {{ .Release.Name }} inject values from values.yaml and Helm's built-in release metadata. The _helpers.tpl file is a convention for reusable template snippets — name generation, labels, annotations — that many templates share. Understanding these conventions is essential for writing maintainable charts.

# Helm chart directory structure my-app/ ├── Chart.yaml # Chart metadata ├── values.yaml # Default values ├── values-staging.yaml # Staging overrides ├── values-production.yaml # Production overrides ├── templates/ │ ├── _helpers.tpl # Reusable template snippets │ ├── deployment.yaml # Deployment manifest │ ├── service.yaml # Service manifest │ ├── ingress.yaml # Ingress manifest │ ├── configmap.yaml # ConfigMap manifest │ └── NOTES.txt # Post-install notes ├── charts/ # Subcharts (dependencies) └── .helmignore # Files to exclude from packaging # Chart.yaml apiVersion: v2 name: my-app description: A Helm chart for my application type: application version: 0.1.0 # Chart version (semver) appVersion: "1.0.0" # App version (image tag) # values.yaml (defaults) image: repository: registry.gitlab.com/my-group/my-app tag: latest pullPolicy: IfNotPresent replicaCount: 2 service: type: ClusterIP port: 80 resources: limits: cpu: 500m memory: 512Mi requests: cpu: 100m memory: 128Mi ingress: enabled: false host: "" # values-production.yaml (overrides) replicaCount: 5 image: pullPolicy: Always resources: limits: cpu: 2 memory: 2Gi requests: cpu: 500m memory: 512Mi ingress: enabled: true host: app.example.com # templates/deployment.yaml apiVersion: apps/v1 kind: Deployment metadata: name: {{ include "my-app.fullname" . }} labels: {{- include "my-app.labels" . | nindent 4 }} spec: replicas: {{ .Values.replicaCount }} selector: matchLabels: {{- include "my-app.selectorLabels" . | nindent 6 }} template: metadata: labels: {{- include "my-app.selectorLabels" . | nindent 8 }} spec: containers: - name: {{ .Chart.Name }} image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" imagePullPolicy: {{ .Values.image.pullPolicy }} ports: - containerPort: 8080 resources: {{- toYaml .Values.resources | nindent 12 }}
Chart Structure Best Practices:
  • Use semantic versioning for version; bump it on every chart change
  • Keep appVersion separate from version; set it dynamically in CI/CD
  • Use _helpers.tpl for name generation and labels
  • Provide sensible defaults in values.yaml; override per environment
  • Use .helmignore to exclude test files, docs, and CI configs from packaging
Packaging and Publishing to OCI Registry

Packaging a chart is a single command: helm package my-app produces my-app-0.1.0.tgz, a versioned archive of the chart. This is the artifact that gets published and consumed. In a GitLab pipeline, packaging is typically a build-stage job that runs after chart validation and linting. The output is an artifact that later jobs can use.

Publishing to the GitLab Container Registry uses Helm's OCI support. You authenticate with helm registry login using the standard CI registry credentials ($CI_REGISTRY_USER, $CI_REGISTRY_PASSWORD), then push the packaged chart with helm push my-app-0.1.0.tgz oci://$CI_REGISTRY_IMAGE/charts. The chart is stored alongside your Docker images, tagged by version, and can be pulled from any cluster that has registry access.

The chart version and the app version should be set dynamically during CI/CD. The chart version is typically set to the git commit SHA or a semantic version derived from git tags. The appVersion is set to the image tag. This ensures that every chart release is traceable to a specific commit and a specific image, which is essential for rollbacks and audits. A chart published without version information is useless for reproducibility.

# Lint and package chart lint_chart: stage: build image: alpine/helm:latest script: - helm lint ./charts/my-app - helm template ./charts/my-app # Render to catch errors - helm package ./charts/my-app --version $CI_COMMIT_SHORT_SHA artifacts: paths: - my-app-*.tgz expire_in: 1 week # Publish to OCI registry publish_chart: stage: build image: alpine/helm:latest needs: [lint_chart] before_script: - echo $CI_REGISTRY_PASSWORD | helm registry login $CI_REGISTRY -u $CI_REGISTRY_USER --password-stdin script: - helm push my-app-$CI_COMMIT_SHORT_SHA.tgz oci://$CI_REGISTRY_IMAGE/charts rules: - if: $CI_COMMIT_BRANCH == "main" # Set chart version dynamically from git tag package_with_tag: stage: build image: alpine/helm:latest script: - | if [ -n "$CI_COMMIT_TAG" ]; then CHART_VERSION=${CI_COMMIT_TAG#v} else CHART_VERSION=0.0.0-$CI_COMMIT_SHORT_SHA fi - helm package ./charts/my-app --version $CHART_VERSION - ls -la *.tgz artifacts: paths: - "*.tgz" # Publish multiple charts (monorepo pattern) publish_all_charts: stage: build image: alpine/helm:latest before_script: - echo $CI_REGISTRY_PASSWORD | helm registry login $CI_REGISTRY -u $CI_REGISTRY_USER --password-stdin script: - | for chart in charts/*/; do name=$(basename $chart) helm package $chart --version $CI_COMMIT_SHORT_SHA helm push $name-$CI_COMMIT_SHORT_SHA.tgz oci://$CI_REGISTRY_IMAGE/charts done # Pull chart from OCI registry pull_chart: stage: deploy image: alpine/helm:latest before_script: - echo $CI_REGISTRY_PASSWORD | helm registry login $CI_REGISTRY -u $CI_REGISTRY_USER --password-stdin script: - helm pull oci://$CI_REGISTRY_IMAGE/charts/my-app --version $CI_COMMIT_SHORT_SHA - helm upgrade --install my-app ./my-app-$CI_COMMIT_SHORT_SHA.tgz # OCI commands: # helm registry login -u --password-stdin # helm push .tgz oci:/// # helm pull oci://// --version # helm show chart oci://// --version # helm template oci://// --version # Note: HELM_EXPERIMENTAL_OCI=1 is no longer needed # OCI support is stable since Helm 3.8
OCI Publishing Gotchas: