GitLab CI for an infra repo: lint, validate, and flux reconcile

Published: 2026-04-03

The .gitlab-ci.yml for the infra repository has one job for each thing that can go wrong: YAML syntax errors, Kustomize render failures, Terraform plan errors, and drift between git and the running cluster. Here's how the pipeline is structured.


Pipeline stages

validate:yaml        ← yamllint on fluxcd/ and ansible/
validate:kustomize   ← kubectl kustomize for every env
validate:terraform   ← terraform validate for every env
validate:security    ← Trivy SARIF + SonarQube (manual trigger)
reconcile:flux       ← flux reconcile source git (auto, default branch only)
reconcile:envs       ← per-env Kustomization reconcile (manual)
notify               ← Telegram + Mattermost webhook

YAML lint

yamlvalidate:yaml:
  stage: validate:yaml
  image: registry.example.com/pipelinecomponents/yamllint:latest
  script:
    - yamllint -c .yamllint.yaml fluxcd/ ansible/

.yamllint.yaml:

yamlextends: default
rules:
  line-length:
    max: 200      # SealedSecret base64 values need room
  truthy:
    allowed-values: ['true', 'false']   # prevent yes/no booleans
  comments:
    min-spaces-from-content: 1
  document-start: disable

Fails fast before any Kubernetes validation.


Kustomize validation

One job per environment:

yamlkustomize-validate-dev:
  stage: validate:kustomize
  image: registry.example.com/bitnami/kubectl:latest
  script:
    - kubectl kustomize fluxcd/projects/dev/kustomization/hub/
    - kubectl kustomize fluxcd/projects/dev/kustomization/spoke/
    - kubectl kustomize fluxcd/projects/dev/kustomization/routes/
    - kubectl kustomize fluxcd/projects/dev/kustomization/spoke/monitoring/

This catches:

  • Missing kustomization.yaml files
  • Bad resources: references
  • Patch selector mismatches
  • Invalid YAML in HelmRelease values

Terraform validate

yamlterraform-validate-dev:
  stage: validate:terraform
  image: registry.example.com/hashicorp/terraform:1.x
  script:
    - cd terraform/dev
    - terraform init -backend=false
    - terraform validate

-backend=false skips state backend connection. Validation only checks syntax and provider schema.


Flux reconcile (auto on main)

yamlflux-sync:
  stage: reconcile:flux
  image: registry.example.com/fluxcd/flux-cli:v2.8.3
  interruptible: false
  script:
    - flux reconcile source git flux-system -n flux-system --context=infra-k8s
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH

Runs only on pushes to main. The KUBECONFIG points to .kubeconfigs/infra.yaml committed to the repo (hub cluster endpoint only, cluster is VPN-only network).


Per-env manual reconcile

yamlreconcile-dev:
  stage: reconcile:envs
  when: manual
  image: registry.example.com/fluxcd/flux-cli:v2.8.3
  script:
    - flux reconcile ks dev-apps             -n dev --context=infra-k8s --timeout=5m
    - flux reconcile ks dev-spoke            -n dev --context=infra-k8s --timeout=5m
    - flux reconcile ks dev-monitoring-spoke -n dev --context=infra-k8s --timeout=5m
    - flux reconcile ks dev-routes           -n dev --context=infra-k8s --timeout=5m

Useful after a large batch of changes when you don't want to wait for the 1-minute Flux polling interval.


Workflow rules

yamlworkflow:
  auto_cancel:
    on_new_commit: interruptible
  rules:
    - if: $CI_MERGE_REQUEST_ID
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
    - when: never

auto_cancel: on_new_commit: interruptible — lint and kustomize-validate jobs are cancelled and restarted on new commit. flux-sync is not interruptible.


Notification jobs

yamlnotify:success:
  stage: notify
  when: on_success
  variables:
    STATUS_ICON: "✅"
    STATUS_LABEL: SUCCESS

notify:failure:
  stage: notify
  when: on_failure
  variables:
    STATUS_ICON: "❌"
    STATUS_LABEL: FAILED

Both extend .notify which sends to Telegram and Mattermost. The message includes pipeline ID, branch, commit SHA, author, and first line of the commit message.

Telegram goes through an HTTP proxy because the CI runner is on-prem and can't reach api.telegram.org directly. The script tries the proxy first, then falls back to direct.


Runner setup

All jobs use the office-dind tag — an on-prem GitLab Runner with Docker-in-Docker. Images pulled from registry.example.com (internal Artifactory), not Docker Hub.

GIT_DEPTH: "1" on most jobs. SonarQube overrides with GIT_DEPTH: "0" for git blame analysis.


Full job dependency graph

yamllint
  └─> kustomize-validate-*
        └─> flux-sync (auto)
              └─> reconcile-* (manual)
                    └─> notify

If yamllint fails, Kustomize validation is skipped. This prevents noisy failures from cascading.