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:
- GitLab → Admin Area → Applications → New Application
- Name:
SonarQube - Redirect URI:
https://sonarqube.example.com/oauth2/callback/gitlab - Scopes:
read_user,api - 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:
- SonarQube → Quality Gates → Create
- Name:
Infrastructure - Add conditions: only Bugs and Security Hotspots
- 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.