SonarQube in Kubernetes: deployment and GitLab CI integration

Published: 2026-02-19

SonarQube runs on the infra cluster and scans code from all GitLab projects. It's accessed via OIDC (GitLab), stores analysis history in PostgreSQL, and receives results from sonar-scanner-cli running in GitLab CI. This covers the full setup: HelmRelease, PostgreSQL via CloudNativePG, ESO for credentials, and the CI pipeline job.


HelmRelease

yamlapiVersion: helm.toolkit.fluxcd.io/v2
kind: HelmRelease
metadata:
  name: sonarqube
  namespace: infra
spec:
  chart:
    spec:
      chart: sonarqube
      version: "10.4.*"
      sourceRef:
        kind: HelmRepository
        name: sonarqube
        namespace: flux-system
  values:
    edition: "community"
    jdbcOverwrite:
      enable: true
      jdbcUrl: "jdbc:postgresql://postgres.infra.svc.cluster.local:5432/sonarqube"
      jdbcUsername: sonarqube
      jdbcPassword: "${SONAR_DB_PASS}"
    sonarProperties:
      sonar.forceAuthentication: "true"
      sonar.auth.gitlab.enabled: "true"
      sonar.auth.gitlab.url: "https://gitlab.example.com"
      sonar.auth.gitlab.applicationId: "${SONAR_GITLAB_APP_ID}"
      sonar.auth.gitlab.secret: "${SONAR_GITLAB_SECRET}"
      sonar.auth.gitlab.allowUsersToSignUp: "true"
    persistence:
      enabled: true
      storageClass: local-path
      size: 20Gi
    resources:
      requests:
        cpu: 400m
        memory: 1Gi
      limits:
        cpu: 2
        memory: 2Gi
    jvmOpts: "-Xms512m -Xmx1536m"

jdbcOverwrite.enable: true — disables the bundled H2/Postgres container and uses the external PostgreSQL instead. Without this, SonarQube uses an embedded database that's not suitable for production and gets wiped on pod restart.

jvmOpts: "-Xms512m -Xmx1536m" — sets minimum and maximum JVM heap. SonarQube starts slowly when heap is too small. The max must leave room for JVM overhead within the container memory limit: limits.memory: 2Gi - 1536m heap = ~512m headroom for OS/non-heap allocations.

GitLab OIDC setup

Before deploying, create a GitLab Application:

  1. GitLab → Admin Area → Applications → New Application
  2. Name: SonarQube
  3. Redirect URI: https://sonarqube.example.com/oauth2/callback/gitlab
  4. Scopes: read_user, api
  5. Save the Application ID and Secret to Vault:
bashvault kv put secret/infra/sonarqube \
  gitlab_app_id=YOUR_APP_ID \
  gitlab_secret=YOUR_SECRET \
  db_password=POSTGRES_PASS

PostgreSQL for SonarQube

SonarQube requires an external database. The infra cluster runs a small PostgreSQL instance managed by CloudNativePG operator:

yamlapiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
  name: postgres
  namespace: infra
spec:
  instances: 1
  storage:
    size: 10Gi
    storageClass: local-path
  bootstrap:
    initdb:
      database: sonarqube
      owner: sonarqube
      secret:
        name: postgres-sonarqube-creds

The secret.name: postgres-sonarqube-creds points to a Kubernetes Secret with username and password keys. CloudNativePG creates the database and user on first boot.

For a shared PostgreSQL instance (multiple apps), add a separate database for SonarQube:

yamlbootstrap:
  initdb:
    database: sonarqube
    owner: sonarqube
    secret:
      name: postgres-sonarqube-creds
    postInitSQL:
      - "GRANT ALL PRIVILEGES ON DATABASE sonarqube TO sonarqube;"

ESO for credentials

yamlapiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
  name: sonarqube-creds
  namespace: infra
spec:
  refreshInterval: 1h
  secretStoreRef:
    name: vault-backend
    kind: ClusterSecretStore
  target:
    name: sonarqube-creds
  data:
    - secretKey: SONAR_DB_PASS
      remoteRef:
        key: secret/infra/sonarqube
        property: db_password
    - secretKey: SONAR_GITLAB_APP_ID
      remoteRef:
        key: secret/infra/sonarqube
        property: gitlab_app_id
    - secretKey: SONAR_GITLAB_SECRET
      remoteRef:
        key: secret/infra/sonarqube
        property: gitlab_secret

The HelmRelease references sonarqube-creds via envFrom:

yamlvalues:
  extraEnvVars:
    - name: SONAR_DB_PASS
      valueFrom:
        secretKeyRef:
          name: sonarqube-creds
          key: SONAR_DB_PASS

GitLab CI scanner job

yamlsonarqube:
  stage: quality
  image: registry.example.com/sonarsource/sonar-scanner-cli:5.0.1
  variables:
    SONAR_HOST_URL: "https://sonarqube.infra.test.antonnovikov.com"
    SONAR_USER_HOME: "${CI_PROJECT_DIR}/.sonar"
    GIT_DEPTH: "0"
    GIT_STRATEGY: fetch
  script:
    - sonar-scanner
        -Dsonar.projectKey=${CI_PROJECT_PATH_SLUG}
        -Dsonar.sources=.
        -Dsonar.host.url=${SONAR_HOST_URL}
        -Dsonar.login=${SONAR_TOKEN}
        -Dsonar.gitlab.project_id=${CI_PROJECT_ID}
        -Dsonar.gitlab.commit_sha=${CI_COMMIT_SHA}
        -Dsonar.gitlab.ref_name=${CI_COMMIT_REF_NAME}
  allow_failure: true
  only:
    - merge_requests
    - master

GIT_DEPTH: "0" is required for SonarQube blame — it needs full git history to annotate issues with the author. With GIT_DEPTH: "50" (GitLab default), blame works only for recent commits.

GIT_STRATEGY: fetch ensures the runner fetches (not clones) the repository, preserving history between pipeline runs.

SONAR_TOKEN is a project-level CI variable (masked), generated in SonarQube under My Account → Security → Generate Tokens.

allow_failure: true — SonarQube analysis is informational. Pipeline shouldn't block deployment because of quality issues unless you explicitly enforce quality gates.

Storing scanner results as artifacts

yamlsonarqube:
  # ...
  after_script:
    - |
      if [ -f .scannerwork/report-task.txt ]; then
        echo "SonarQube report task: $(cat .scannerwork/report-task.txt)"
      fi
  artifacts:
    paths:
      - .scannerwork/
    expire_in: 1 week
    when: always

Quality gate

The default Sonar Way quality gate checks on new code:

  • Coverage ≥ 80%
  • Duplicated lines ≤ 3%
  • No new blocker/critical bugs
  • No new security hotspots

For infrastructure repos (YAML, shell, Python without test coverage), coverage isn't relevant. Create a custom quality gate:

  1. SonarQube → Quality Gates → Create
  2. Name: Infrastructure
  3. Add conditions: only Bugs and Security Hotspots
  4. Assign to infrastructure projects

Using sonar-project.properties

For fine-grained control per project:

ini# sonar-project.properties
sonar.projectName=My Service
sonar.sources=src/
sonar.tests=tests/
sonar.python.coverage.reportPaths=coverage.xml
sonar.exclusions=**/vendor/**,**/migrations/**
sonar.coverage.exclusions=**/test*/**,**/*test*

Commit this file to the repo root. The scanner picks it up automatically and merges with CI -D flags.


Keeping analysis data manageable

SonarQube stores analysis history indefinitely. For an infra-only setup, a 20GB PVC lasts years. Prune old project data periodically:

bash# Via SonarQube API — delete analyses older than 90 days
curl -X POST \
  -u admin:password \
  "https://sonarqube.example.com/api/projects/delete_analyses?project=my-project&from=2024-01-01&to=2024-06-01"

Or configure data retention in SonarQube → Administration → Configuration → Housekeeping.

Monitoring SonarQube health

SonarQube exposes a health check endpoint:

bashcurl https://sonarqube.example.com/api/system/status | jq .status
# Should return "UP"

Failed background tasks appear in Administration → Background Tasks with full stack traces — the first place to look when a scanner job succeeds but results don't appear.