A 60-engineer platform team had “code quality” as a wiki page nobody read. Three production incidents in a quarter traced back to untested error paths, and a security review flagged the same SQL-injection pattern copy-pasted across four services. The engineering lead’s mandate was blunt: “I don’t want a dashboard people ignore — I want the build to go red when coverage drops or a bug is introduced, and I want it to happen before the PR can merge.” That is exactly what a SonarQube server plus an enforced quality gate buys you. This guide stands up SonarQube on Kubernetes with a durable external PostgreSQL database, defines a quality gate that fails on new uncovered code and new code smells, and wires it into GitHub Actions so a breach blocks the merge — with a Jenkins variant for the teams not yet migrated.
In a nutshell
SonarQube is an automated code reviewer that reads your code on every pull request and reports three kinds of problem — bugs (things that will break at runtime), vulnerabilities (things an attacker can abuse), and code smells (things that work but are messy and slow the next person down) — plus how much of your code is covered by tests. A quality gate boils that report down to a single pass/fail verdict. Wire the verdict into your pipeline and branch protection and it does the one thing a dashboard never could: it turns the merge button off.
The idea that makes a mandatory gate humane is “Clean as You Code.” SonarQube judges only the code you changed in this pull request — the “new code” — not your entire legacy codebase. You are never asked to retrofit tests onto a ten-year-old monolith before you can ship a one-line fix; you are only asked to leave your addition clean. Think of it as an airport security checkpoint: the luggage already inside the terminal is grandfathered in, but every new bag going through the scanner has to pass. Month after month the “already inside” share shrinks and the whole codebase drifts clean — with no big-bang cleanup sprint.
This lesson builds that checkpoint end to end: SonarQube running on Kubernetes with its history in a durable PostgreSQL database, a strict quality gate, and a CI job whose red X blocks the merge.
Level: Intermediate · Time: ~40 min
Prerequisites
- A Kubernetes cluster (v1.27+) with at least one node having 4 vCPU / 8 GiB free for SonarQube;
kubectlcontext pointing at it. - Helm 3.12+ installed locally.
- A storage class that supports
ReadWriteOncePVCs (for Elasticsearch data) — confirm withkubectl get sc. - A PostgreSQL 13–16 instance reachable from the cluster. This guide uses a managed instance (AWS RDS / Azure Database for PostgreSQL / Cloud SQL) rather than an in-cluster database, so an Elasticsearch corruption or pod eviction never takes your code-history with it.
- An Ingress controller (NGINX assumed) and a DNS name you control, fronted by Akamai for TLS termination and WAF so the SonarQube login page is never raw-exposed to the internet.
- A GitHub repository with Actions enabled (and, for the variant, a Jenkins controller with the Pipeline plugins).
- The host kernel tuned for Elasticsearch:
vm.max_map_count >= 262144on the nodes (set via a DaemonSet or node config, shown below).
What you’ll be able to do after this
- Deploy SonarQube on Kubernetes with Helm, backed by an external PostgreSQL you control, so pod restarts never lose history.
- Explain the difference between a quality profile (which rules fire) and a quality gate (the pass/fail conditions), and between a vulnerability and a security hotspot.
- Author a “new code” quality gate that fails on low coverage, new bugs/vulnerabilities/smells, and duplications.
- Wire the SonarScanner into GitHub Actions (and Jenkins) so a gate breach exits non-zero and, via branch protection, blocks the merge.
- Reason about when SonarQube Community Build is enough and when you actually need Developer Edition or SonarQube Cloud.
Target topology
The shape is deliberately simple and matches how this runs in production. Developers push to a feature branch; GitHub Actions (or Jenkins) builds the code, runs unit tests to produce a coverage report, and runs the SonarScanner, which uploads analysis to the SonarQube server living in its own sonarqube namespace on Kubernetes. SonarQube persists every project’s history, issues, and measures in an external PostgreSQL database — the single source of truth that survives any pod restart. Elasticsearch (bundled inside the SonarQube pod) holds only a rebuildable search index on a local PVC. After analysis, the scanner polls SonarQube’s quality-gate API; if the gate is ERROR, the CI job exits non-zero, the required status check stays red, and the branch-protection rule prevents the merge. Akamai sits at the edge for TLS/WAF; Okta or Entra ID brokers single sign-on into the SonarQube UI so engineers never manage a separate local password; HashiCorp Vault holds the database password and the CI analysis token so neither is ever written to a Kubernetes Secret in plaintext or a CI variable; Wiz / Wiz Code scans the running namespace and the IaC for misconfiguration and exposure; Datadog scrapes SonarQube’s JMX/Prometheus metrics for availability; and ServiceNow receives an auto-raised change record whenever the production quality gate definition itself is modified.
How SonarQube thinks: profiles, gates, and Clean as You Code
Before wiring anything, it pays to know exactly what SonarQube measures and where each setting lives. The two most common sources of day-one confusion are “profile vs gate” and “why did my coverage read 0%” — both dissolve once the model is clear.
The scanner finds issues; the profile decides which issues count. When the SonarScanner analyses your code it runs a large set of language rules. A quality profile is the named, per-language set of active rules — “Sonar way” is the built-in default for each language (Java, Python, TypeScript, …). Deactivate a noisy rule, raise a rule’s severity, or activate an extra security rule, and you are editing a profile. Profiles decide what shows up as an issue in the first place.
Every finding is tagged with the quality it damages. SonarQube sorts what it reports by the software quality it hurts:
| Finding | Plain-English meaning | Software quality | New-code metric key |
|---|---|---|---|
| Bug | Code that is or will be wrong at runtime | Reliability | new_reliability_rating |
| Vulnerability | A concrete, exploitable security hole | Security | new_security_rating |
| Code smell | Works, but is hard to maintain | Maintainability | new_maintainability_rating |
| Security hotspot | Security-sensitive code a human must review | (its own category) | new_security_hotspots_reviewed |
| Coverage | % of new lines/conditions exercised by tests | — | new_coverage |
| Duplications | % of new lines copy-pasted | — | new_duplicated_lines_density |
Ratings run A (best) to E (worst), so a gate condition of new_reliability_rating > 1 means “anything worse than A fails.” A security hotspot is not a vulnerability: it is code sitting near something dangerous (a call to Runtime.exec, a permissive CORS setting, a string that looks like a secret) that SonarQube cannot itself prove is safe or unsafe. A human reviews the hotspot and marks it safe or needs fixing; the gate can insist that 100% of new hotspots are at least reviewed.
The quality gate is the pass/fail line over those measures. Where a profile is per-language and about rules, a quality gate is per-project and about thresholds — coverage ≥ 80%, duplications ≤ 3%, ratings = A. A project has exactly one gate, and the gate reads the measures the profiles produced. Tune noise in the profile; tune strictness in the gate.
“Clean as You Code” = judge the new code, not the codebase. Every measure above has an overall version and a new code version. Gating on overall coverage would fail every PR because of legacy debt nobody has time to erase. Gating on new code — the lines added or changed relative to your new code definition (previous version, a number of days, or a reference branch) — asks only that today’s change is clean. That single decision is what makes a mandatory gate politically survivable.
SonarQube is a SAST-style gate: it reads your source. It sits alongside the coverage gate you build in Testing in CI: the test pyramid, coverage, and quality gates and the DAST/SCA/policy gates of a full DevSecOps pipeline; it complements, but does not replace, a dependency scanner like Snyk.
1. Provision the PostgreSQL database
SonarQube needs its own database, an owning role, and the public schema owned by that role. Run these against your managed PostgreSQL as an admin. Do not point SonarQube at a database it shares with anything else — it takes exclusive ownership of the schema.
-- psql -h sonar-pg.prod.internal -U pgadmin -d postgres
CREATE ROLE sonarqube WITH LOGIN PASSWORD 'replace-via-vault';
CREATE DATABASE sonarqube OWNER sonarqube ENCODING 'UTF8'
LC_COLLATE 'en_US.UTF-8' LC_CTYPE 'en_US.UTF-8' TEMPLATE template0;
\connect sonarqube
ALTER SCHEMA public OWNER TO sonarqube;
GRANT ALL ON SCHEMA public TO sonarqube;
In production the password is not typed here. Issue it from HashiCorp Vault’s database secrets engine so it is short-lived and rotatable:
# One-time: configure Vault to manage this Postgres role
vault secrets enable -path=sonar-db database
vault write sonar-db/config/sonarqube \
plugin_name=postgresql-database-plugin \
allowed_roles="sonar-app" \
connection_url="postgresql://{{username}}:{{password}}@sonar-pg.prod.internal:5432/sonarqube?sslmode=require" \
username="vault_admin" password="$VAULT_PG_ADMIN_PW"
Confirm connectivity from inside the cluster before going further — a wrong security group or pg_hba.conf line is the single most common day-one failure:
kubectl run pg-check --rm -it --restart=Never --image=postgres:16-alpine -- \
psql "postgresql://sonarqube:replace-via-vault@sonar-pg.prod.internal:5432/sonarqube?sslmode=require" -c '\conninfo'
2. Prepare the namespace and node kernel setting
SonarQube’s embedded Elasticsearch refuses to start unless vm.max_map_count is high enough. Create the namespace and apply the sysctl via a small privileged DaemonSet (idempotent, runs once per node and exits to a sleep).
kubectl create namespace sonarqube
# sysctl-daemonset.yaml — raises vm.max_map_count on every node
apiVersion: apps/v1
kind: DaemonSet
metadata:
name: sonar-sysctl
namespace: sonarqube
spec:
selector: { matchLabels: { app: sonar-sysctl } }
template:
metadata: { labels: { app: sonar-sysctl } }
spec:
initContainers:
- name: set-max-map-count
image: busybox:1.36
securityContext: { privileged: true }
command: ["sysctl", "-w", "vm.max_map_count=262144"]
containers:
- name: pause
image: registry.k8s.io/pause:3.9
kubectl apply -f sysctl-daemonset.yaml
kubectl -n sonarqube rollout status ds/sonar-sysctl
3. Store the database and admin secrets
The Helm chart can read the JDBC password from an existing Secret. In production that Secret is synced from Vault by the Vault Secrets Operator or External Secrets Operator, so the cleartext never lives in your Git repo. The literal form below is shown only so the wiring is clear:
kubectl -n sonarqube create secret generic sonarqube-jdbc \
--from-literal=password='replace-via-vault'
The equivalent External Secrets manifest, which is what you actually commit, pulls the same key from the Vault path created in step 1 and is what Wiz Code will pass in its IaC scan because no secret is in the file:
# external-secret-jdbc.yaml
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata: { name: sonarqube-jdbc, namespace: sonarqube }
spec:
refreshInterval: 1h
secretStoreRef: { name: vault-backend, kind: ClusterSecretStore }
target: { name: sonarqube-jdbc }
data:
- secretKey: password
remoteRef: { key: sonar-db/static/sonarqube, property: password }
4. Install SonarQube with Helm, pointed at external PostgreSQL
Add the official chart repo and write a values file that disables the bundled (ephemeral) PostgreSQL and points at your managed instance. The key lines are postgresql.enabled: false and the jdbcOverwrite block.
helm repo add sonarqube https://SonarSource.github.io/helm-chart-sonarqube
helm repo update
# sonarqube-values.yaml
edition: community # use 'developer'/'enterprise' if licensed (branch analysis)
postgresql:
enabled: false # do NOT run the in-chart Postgres
jdbcOverwrite:
enable: true
jdbcUrl: "jdbc:postgresql://sonar-pg.prod.internal:5432/sonarqube?sslmode=require"
jdbcUsername: "sonarqube"
jdbcSecretName: "sonarqube-jdbc" # the Secret from step 3
jdbcSecretPasswordKey: "password"
monitoringPasscode: "set-me" # required by recent charts for the web monitoring endpoint
resources:
requests: { cpu: "1", memory: "3Gi" }
limits: { cpu: "2", memory: "6Gi" }
persistence: # Elasticsearch index only — rebuildable, not your data
enabled: true
storageClass: "gp3"
size: 20Gi
# Initialise sysctl per-pod as a belt-and-braces alongside the DaemonSet
initSysctl:
enabled: true
ingress:
enabled: true
ingressClassName: nginx
hosts:
- name: sonarqube.kloudvin.internal
path: /
annotations:
nginx.ingress.kubernetes.io/proxy-body-size: "64m" # large scanner uploads
Install and wait for it to become ready (first boot runs the DB migration and can take 3–5 minutes):
helm upgrade --install sonarqube sonarqube/sonarqube \
-n sonarqube -f sonarqube-values.yaml
kubectl -n sonarqube rollout status sts/sonarqube-sonarqube --timeout=600s
kubectl -n sonarqube logs sts/sonarqube-sonarqube -c sonarqube | grep -i "SonarQube is operational"
5. Bootstrap admin, SSO, and a CI analysis token
Log in once at https://sonarqube.kloudvin.internal with the default admin/admin and immediately change the password — leaving it default is the finding every auditor opens with.
Wire Okta or Entra ID SSO so engineers authenticate with corporate identity (SAML), and so leavers lose access the moment HR deprovisions them. Set this under Administration -> Configuration -> SAML, or script it through the API:
SONAR=https://sonarqube.kloudvin.internal
ADMIN_TOKEN=$(curl -su admin:'NEW_PASSWORD' -X POST \
"$SONAR/api/user_tokens/generate?name=bootstrap" | jq -r .token)
# Enable SAML (Okta/Entra ID as IdP) — values come from your IdP app registration
curl -su "$ADMIN_TOKEN:" -X POST "$SONAR/api/settings/set" \
--data-urlencode 'key=sonar.auth.saml.enabled' --data-urlencode 'value=true'
curl -su "$ADMIN_TOKEN:" -X POST "$SONAR/api/settings/set" \
--data-urlencode 'key=sonar.auth.saml.providerName' --data-urlencode 'value=Okta'
curl -su "$ADMIN_TOKEN:" -X POST "$SONAR/api/settings/set" \
--data-urlencode 'key=sonar.auth.saml.applicationId' --data-urlencode 'value=sonarqube'
curl -su "$ADMIN_TOKEN:" -X POST "$SONAR/api/settings/set" \
--data-urlencode 'key=sonar.auth.saml.signature.enabled' --data-urlencode 'value=true'
Now mint a dedicated, project-scoped analysis token for CI. Use a global Analysis token (or a project token if you prefer least privilege per repo). Store the result in Vault, not in a GitHub variable typed by hand:
CI_TOKEN=$(curl -su "$ADMIN_TOKEN:" -X POST \
"$SONAR/api/user_tokens/generate?name=gh-actions-ci&type=GLOBAL_ANALYSIS_TOKEN" | jq -r .token)
vault kv put secret/ci/sonarqube token="$CI_TOKEN" host="$SONAR"
6. Define the quality gate that fails on coverage and code smells
The whole point is enforcement on new code — you cannot retroactively cover a legacy monolith, but you can demand that everything added from now on is clean. Create a gate called KloudVin-Strict and attach conditions on the new-code period. SonarQube’s “Sonar way” default is a good base; this makes it stricter and explicit.
QG="KloudVin-Strict"
curl -su "$ADMIN_TOKEN:" -X POST "$SONAR/api/qualitygates/create" \
--data-urlencode "name=$QG"
# Fail if coverage on NEW code drops below 80%
curl -su "$ADMIN_TOKEN:" -X POST "$SONAR/api/qualitygates/create_condition" \
--data-urlencode "gateName=$QG" --data-urlencode "metric=new_coverage" \
--data-urlencode "op=LT" --data-urlencode "error=80"
# Fail on ANY new bug, vulnerability, or code smell rated worse than A
curl -su "$ADMIN_TOKEN:" -X POST "$SONAR/api/qualitygates/create_condition" \
--data-urlencode "gateName=$QG" --data-urlencode "metric=new_reliability_rating" \
--data-urlencode "op=GT" --data-urlencode "error=1"
curl -su "$ADMIN_TOKEN:" -X POST "$SONAR/api/qualitygates/create_condition" \
--data-urlencode "gateName=$QG" --data-urlencode "metric=new_security_rating" \
--data-urlencode "op=GT" --data-urlencode "error=1"
curl -su "$ADMIN_TOKEN:" -X POST "$SONAR/api/qualitygates/create_condition" \
--data-urlencode "gateName=$QG" --data-urlencode "metric=new_maintainability_rating" \
--data-urlencode "op=GT" --data-urlencode "error=1"
# Fail if duplicated lines on new code exceed 3%
curl -su "$ADMIN_TOKEN:" -X POST "$SONAR/api/qualitygates/create_condition" \
--data-urlencode "gateName=$QG" --data-urlencode "metric=new_duplicated_lines_density" \
--data-urlencode "op=GT" --data-urlencode "error=3"
# Make it the default for new projects
curl -su "$ADMIN_TOKEN:" -X POST "$SONAR/api/qualitygates/set_as_default" \
--data-urlencode "name=$QG"
Any later change to this gate definition should fire an automation rule that opens a ServiceNow change record — the gate is a control, and silently weakening it (say, dropping new_coverage to 50% the week before a release) is exactly the kind of change you want an auditable trail for.
7. Wire enforcement into GitHub Actions
Add the analysis to your pipeline. The critical flag is sonar.qualitygate.wait=true, which makes the scanner block until SonarQube computes the gate and return a non-zero exit code on ERROR — that exit code is what turns the check red. Put the token and host in GitHub Actions secrets synced from Vault.
# .github/workflows/sonar.yml
name: SonarQube Quality Gate
on:
pull_request:
branches: [ main ]
jobs:
analyze:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 } # full history so new-code detection works
- uses: actions/setup-java@v4
with: { distribution: temurin, java-version: '21' }
- name: Test with coverage
run: ./gradlew test jacocoTestReport # produces build/reports/jacoco/.../jacoco.xml
- name: SonarQube scan + gate wait
uses: SonarSource/sonarqube-scan-action@v3
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }}
with:
args: >
-Dsonar.projectKey=kloudvin_payments-api
-Dsonar.coverage.jacoco.xmlReportPaths=build/reports/jacoco/test/jacocoTestReport.xml
-Dsonar.qualitygate.wait=true
-Dsonar.qualitygate.timeout=300
A matching sonar-project.properties at the repo root keeps language and path settings in version control:
sonar.projectKey=kloudvin_payments-api
sonar.projectName=Payments API
sonar.sources=src/main
sonar.tests=src/test
sonar.sourceEncoding=UTF-8
Finally, make it a merge blocker. In Settings -> Branches -> Branch protection rules for main, enable Require status checks to pass before merging and select the SonarQube Code Analysis check. Without this last step the job can fail and the PR will still be mergeable — the most common reason teams believe they have enforcement when they do not.
8. (Variant) Wire enforcement into Jenkins
For teams still on Jenkins, the equivalent uses the SonarQube Scanner and Quality Gate plugins. waitForQualityGate abortPipeline: true is the Jenkins analogue of qualitygate.wait and aborts the build on ERROR.
// Jenkinsfile
pipeline {
agent any
stages {
stage('Build & Test') { steps { sh './gradlew test jacocoTestReport' } }
stage('SonarQube Analysis') {
steps {
withSonarQubeEnv('sonarqube-prod') { // server configured in Manage Jenkins
sh '''sonar-scanner \
-Dsonar.projectKey=kloudvin_payments-api \
-Dsonar.coverage.jacoco.xmlReportPaths=build/reports/jacoco/test/jacocoTestReport.xml'''
}
}
}
stage('Quality Gate') {
steps {
timeout(time: 10, unit: 'MINUTES') {
waitForQualityGate abortPipeline: true // fails the build on gate ERROR
}
}
}
}
}
The webhook back from SonarQube (Administration -> Configuration -> Webhooks) must point at https://<jenkins>/sonarqube-webhook/ so waitForQualityGate is notified rather than timing out.
Validation
Prove enforcement works both ways — a passing build and a deliberately failing one. A gate that has never gone red has never been tested.
# 1. Server health and DB connection
curl -s "$SONAR/api/system/health" | jq . # expect "GREEN"; "RED" => check DB connectivity
kubectl -n sonarqube logs sts/sonarqube-sonarqube -c sonarqube | grep -i "embedded postgres" \
&& echo "WRONG: still using embedded DB" # should print nothing
# 2. Confirm the project is bound to the strict gate
curl -su "$ADMIN_TOKEN:" "$SONAR/api/qualitygates/get_by_project?project=kloudvin_payments-api" | jq .
# 3. Read the latest gate result for a branch
curl -su "$ADMIN_TOKEN:" \
"$SONAR/api/qualitygates/project_status?projectKey=kloudvin_payments-api&branch=main" \
| jq '.projectStatus.status' # "OK" or "ERROR"
Then open two PRs: one well-tested (gate green, check passes, merge allowed) and one that adds an untested method or an obvious bug (gate ERROR, Actions job exits non-zero, merge button disabled). Watching the second PR get blocked is the acceptance test for this entire guide.
Rollback / teardown
Because the data lives in PostgreSQL, you can destroy and recreate the SonarQube pod freely — your history is safe. Full teardown:
# Remove the application but keep the database (history preserved)
helm uninstall sonarqube -n sonarqube
kubectl -n sonarqube delete pvc -l app=sonarqube # drops only the rebuildable ES index
kubectl delete -f sysctl-daemonset.yaml
# Full removal including namespace
kubectl delete namespace sonarqube
-- Only if you truly want to discard all code-quality history
DROP DATABASE sonarqube;
DROP ROLE sonarqube;
To roll back the enforcement without removing SonarQube — e.g. during an incident where a false-positive gate is blocking a hotfix — temporarily disable the required status check in branch protection rather than weakening the gate definition, and re-enable it the same day. Disabling the gate condition globally affects every project; deselecting one status check affects only the repo that needs the escape hatch.
Common pitfalls
max_map_counttoo low — Elasticsearch crash-loops withmax virtual memory areas vm.max_map_count [65530] is too low. Fix with the step-2 DaemonSet;initSysctlalone is unreliable on locked-down nodes.- In-cluster PostgreSQL left enabled — forgetting
postgresql.enabled: falsesilently runs an ephemeral DB; the day a pod reschedules, all history vanishes. The validation grep above catches this. fetch-depth: 0omitted — without full Git history SonarQube cannot determine “new code,” sonew_coveragereads as empty and the gate passes vacuously. Always checkout full depth in CI.- Forgetting
qualitygate.wait=true— the scan uploads and the job goes green regardless of the gate result. Enforcement silently does nothing. - No branch-protection requirement — the check fails but the PR still merges. Enforcement lives in branch protection, not in the workflow file.
- Missing webhook (Jenkins) —
waitForQualityGatehangs until timeout because SonarQube never calls back. Configure the/sonarqube-webhook/URL. - Coverage report path wrong — if
sonar.coverage.jacoco.xmlReportPathsdoes not match where JaCoCo actually wrote the XML, coverage shows 0% and the gate fails for the wrong reason.
Security notes
Run SonarQube behind Akamai for TLS and WAF so the login surface is never directly exposed; terminate TLS at the edge and at Ingress. Replace the default admin password on first boot and federate the UI to Okta or Entra ID via SAML so access follows corporate joiner/leaver workflows. Keep the JDBC password and the CI analysis token in HashiCorp Vault (the short-lived, dynamic-credential pattern from Vault dynamic secrets for CI/CD), synced into Kubernetes via External Secrets, so neither is committed or pasted into CI settings. Scope the CI token to analysis only — it does not need admin rights. Point Wiz / Wiz Code at both the running sonarqube namespace (runtime exposure, public-IP drift, container CVEs) and the Helm/IaC in your repo (misconfiguration before deploy). SonarQube itself is part of your security posture: the new_security_rating gate condition blocks newly introduced vulnerabilities and hotspots at the PR, which is where they are cheapest to fix — and runtime workload protection from CrowdStrike Falcon on the node pool covers the pod at execution time. Note that SonarQube complements but does not replace dedicated SAST/SCA tooling for deep dependency-vulnerability scanning.
Cost notes
This footprint is modest. SonarQube fits in 2 vCPU / 6 GiB, and the external PostgreSQL is a small managed instance (a db.t3.medium or equivalent comfortably serves dozens of projects) — the database stays small because SonarQube stores measures and issues, not artifacts. The largest hidden cost is CI minutes: a full Sonar scan adds 1–4 minutes per PR, so scope analysis to changed code, cache the scanner and JaCoCo output, and run the heavy scan on PRs to main rather than on every push to every branch. The Community edition is free and covers single-branch analysis and the quality-gate enforcement this guide relies on; you only pay for Developer edition if you need built-in pull-request decoration and multi-branch analysis. Watch SonarQube availability and JVM/DB latency in Datadog (scrape the Prometheus/JMX endpoint) so a slow gate is caught before it becomes the bottleneck every PR waits on. Right-sizing here is mostly about not over-provisioning a server that idles between commits.
Going deeper
The Compute Engine: analysis is asynchronous
The scanner does not compute your issues. It parses code locally, produces a report, and uploads it. On the server a background worker called the Compute Engine picks the report off a queue, applies the project’s quality profiles, computes measures, evaluates the gate, and writes everything to PostgreSQL. That queued job is the background task, visible under Administration -> Background Tasks. Two consequences follow: a green scanner step does not yet mean a computed gate — the verdict exists only after the task finishes; and a backed-up CE queue (one worker by default; more require Enterprise/Data Center) is the usual reason “the gate is slow.” That asynchrony is exactly why the scanner needs a wait mode at all.
How the build actually learns the verdict
sonar.qualitygate.wait=true makes the scanner write the CE task id to .scannerwork/report-task.txt, then poll api/ce/task until the task is done and read api/qualitygates/project_status. On ERROR it exits non-zero. No webhook is involved — it is pure client-side polling, which is why the GitHub Actions path needs no callback URL.
Jenkins’ waitForQualityGate works the opposite way: it releases the build executor and waits for SonarQube to call back over the webhook you point at /sonarqube-webhook/. No webhook, no callback, and the step blocks until timeout fires. The webhook is therefore mandatory for the Jenkins variant and irrelevant for the GitHub Actions one. SonarQube can sign its webhooks — set a secret and verify the X-Sonar-Webhook-HMAC-SHA256 header so a forged callback cannot fake a green gate.
Pull-request decoration and branches need an edition
The caveat the feature grid buries: Community Build analyses one branch only. It has no branch analysis and no pull-request analysis — point it at a PR and it overwrites the main project’s measures. The clean per-PR experience (where “new code” is literally this PR’s diff), plus the inline PR comments and the status check SonarQube posts on the pull request — collectively “PR decoration” — require Developer Edition or higher, or SonarQube Cloud. You bind the project to the GitHub/GitLab/Bitbucket/Azure DevOps app under Administration -> DevOps Platform Integrations, and the scanner auto-detects the PR from CI environment variables.
You can still gate on Community Build — the qualitygate.wait exit code plus branch protection works — but you are gating on the single project’s measures, not an isolated PR diff. Know which behaviour your edition gives you before you promise per-PR gating.
Pick the scanner that matches the build
The generic sonarqube-scan-action runs the SonarScanner CLI, which is right for JS/TS, Python, and Go. For JVM languages the analysis needs compiled classes — CLI-only analysis of Java without sonar.java.binaries is incomplete. For Gradle/Maven the idiomatic path is the build plugin, which already knows where the classes and the JaCoCo XML are:
- name: Build, test, and analyze
run: ./gradlew build jacocoTestReport sonar
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }}
./gradlew sonar (the org.sonarqube plugin) is generally more reliable for Java than the standalone action; the action shines for non-JVM stacks. Either way, -Dsonar.qualitygate.wait=true is still the flag that fails the build.
New code definition — get this right or the gate is theatre
“New code” is configured per project (or globally) under Project Settings -> New Code, or via api/new_code_periods/set. The options are Previous version (everything since the last sonar.projectVersion), Number of days, Specific analysis, or Reference branch (diff against main — the best fit for trunk-based PR flows). Misconfigure it and new_coverage can be empty and the gate passes vacuously — the same failure mode as omitting fetch-depth: 0. For PR workflows, set the reference branch to your integration branch:
curl -su "$ADMIN_TOKEN:" -X POST "$SONAR/api/new_code_periods/set" \
--data-urlencode "project=kloudvin_payments-api" \
--data-urlencode "type=REFERENCE_BRANCH" --data-urlencode "value=main"
Add a security-hotspot condition
The gate in step 6 covers ratings, coverage, and duplications. Modern “Sonar way” also demands that new security hotspots are 100% reviewed — add it so security-sensitive new code cannot merge unlooked-at:
curl -su "$ADMIN_TOKEN:" -X POST "$SONAR/api/qualitygates/create_condition" \
--data-urlencode "gateName=$QG" \
--data-urlencode "metric=new_security_hotspots_reviewed" \
--data-urlencode "op=LT" --data-urlencode "error=100"
MQR mode and the Clean Code taxonomy (version caveat)
Since the 10.8 line, new SonarQube instances default to Multi-Quality Rule (MQR) mode: one issue can carry impacts on several software qualities (Security, Reliability, Maintainability), each with its own severity, and the old Bug / Vulnerability / Code Smell trichotomy is de-emphasised in favour of Clean Code attributes. The legacy view is Standard Experience mode, switchable under Administration -> General. Why you care: the classic metric keys (new_reliability_rating and friends) still work in both modes, but MQR adds parallel new_software_quality_* metrics, and a gate authored against one mode may need the sibling metric under the other. Pin your instance to a known LTA (Long-Term Active) release rather than latest, and choose the mode deliberately — flipping it changes how ratings are computed.
Scale, availability, and where Postgres fits
One SonarQube pod runs three JVM processes — web server, Compute Engine, and Elasticsearch — which is why it wants 4 vCPU / 8 GiB and a ReadWriteOnce PVC for the ES index. Vertical scale (more CE workers, more heap) covers most teams. True high availability — surviving a node loss with no downtime — needs the Data Center Edition, deployed from a separate Helm chart (sonarqube-dce) with clustered application nodes and a dedicated Elasticsearch tier. PostgreSQL remains the durable source of truth in every topology, so when hundreds of projects analyse concurrently, scale the database (connections, IOPS) before you scale SonarQube.
SonarQube Server or SonarQube Cloud?
SonarQube Cloud (formerly SonarCloud) is Sonar’s SaaS: no cluster, no Postgres, no upgrades to run, per-line-of-code pricing, and PR decoration included. SonarQube Server (what this guide deploys) keeps analysis and source code inside your network — the deciding factor for regulated code that must not leave the perimeter, for air-gapped environments, and for teams that want to own the upgrade cadence. The scanner, the gate model, and the Clean-as-You-Code philosophy are identical; the choice is operational ownership versus data residency and control.
| SonarQube Server (self-host) | SonarQube Cloud (SaaS) | |
|---|---|---|
| Runs where | Your Kubernetes + Postgres | Sonar-hosted |
| Ops burden | You patch, back up, scale | None |
| Source leaves your network | No | Yes (sent to Sonar) |
| PR decoration | Developer Edition+ | Included |
| Best for | Regulated / air-gapped / full control | Speed-to-value, no infra team |
Practice challenges
Work these against a throwaway project; each solution is one command or a short answer.
-
(Beginner) Read the gate over the API. Return just the gate status string for
mainof projectkloudvin_payments-api. <details><summary>Show solution</summary>curl -su "$ADMIN_TOKEN:" \ "$SONAR/api/qualitygates/project_status?projectKey=kloudvin_payments-api&branch=main" \ | jq -r '.projectStatus.status' # OK or ERRORWhy:
project_statusis the very endpointqualitygate.waitpolls — reading it by hand demystifies “the gate.” </details> -
(Beginner) Prove the database is external. Give one command that fails loudly if SonarQube ever fell back to the embedded database. <details><summary>Show solution</summary>
kubectl -n sonarqube logs sts/sonarqube-sonarqube -c sonarqube \ | grep -i "embedded postgres" && echo "WRONG: embedded DB in use"Why: with
postgresql.enabled: falseandjdbcOverwriteset, that log line must never appear; if it does, your history is ephemeral. </details> -
(Intermediate) Make new-code detection reliable. Set the project’s new code definition to diff against
main, then name the CI setting that must also be present. <details><summary>Show solution</summary>curl -su "$ADMIN_TOKEN:" -X POST "$SONAR/api/new_code_periods/set" \ --data-urlencode "project=kloudvin_payments-api" \ --data-urlencode "type=REFERENCE_BRANCH" --data-urlencode "value=main"And
actions/checkoutmust usefetch-depth: 0. Why: reference-branch new code needs full Git history to compute the diff, ornew_coverageis empty and the gate passes vacuously. </details> -
(Intermediate) Tighten the gate. Add a condition that fails any PR leaving a new security hotspot unreviewed. <details><summary>Show solution</summary>
curl -su "$ADMIN_TOKEN:" -X POST "$SONAR/api/qualitygates/create_condition" \ --data-urlencode "gateName=KloudVin-Strict" \ --data-urlencode "metric=new_security_hotspots_reviewed" \ --data-urlencode "op=LT" --data-urlencode "error=100"Why: hotspots are security-sensitive code needing human sign-off;
< 100%reviewed on new code fails the gate. </details> -
(Advanced) Fail the build on purpose, then explain the chain. Add an untested public method in a PR and name the four links that must all hold for the merge to be blocked. <details><summary>Show solution</summary>
The chain: (1)
sonar.qualitygate.wait=trueso the scanner blocks and exits non-zero on ERROR; (2) anew_coveragecondition in the gate; (3) a correctsonar.coverage.jacoco.xmlReportPathsso coverage is not a false 0%; (4) branch protection requiring the SonarQube status check onmain. Drop any one link and the PR merges anyway. Why: enforcement is a pipeline of independent guarantees, and the weakest link — usually the missing branch-protection requirement — decides the outcome. </details> -
(Advanced) Right-size the enforcement. A team complains that every push to every branch runs a 4-minute scan. Change the trigger and analysis scope to cut cost without losing the merge gate. <details><summary>Show solution</summary>
Trigger on
pull_requesttomainonly (notpushto all branches), let new-code scoping skip unchanged files, and cache the scanner and JaCoCo output.on: pull_request: branches: [ main ]Why: the gate only has to be authoritative at the merge point — scanning every feature-branch push burns CI minutes for a verdict nobody reads. </details>
Common beginner mistakes
- “A green scan means the gate passed.” No — the scan only uploads. The Compute Engine computes the gate afterwards, and without
qualitygate.wait=truethe job goes green regardless of the verdict. Right model: the scanner submits evidence; the server renders the verdict, and you must wait for it. - “SonarQube will clean up our legacy code.” It won’t, and it shouldn’t try. Clean as You Code deliberately ignores old code and gates only new code. Expecting a mandate to fix a decade of debt sets the tool up to be hated and switched off. Right model: hold the line on new code; legacy improves opportunistically as files are touched.
- “A security hotspot is a vulnerability.” A hotspot is security-sensitive code that a human must judge; many turn out fine. Treating every hotspot as a confirmed bug drowns the team in false positives. Right model: hotspots are review prompts, vulnerabilities are confirmed holes.
- “The quality profile and the quality gate are the same thing.” The profile (per language) decides which rules fire; the gate (per project) decides which thresholds fail the build. You tune noise in the profile and strictness in the gate. Confusing them means editing the wrong screen and wondering why nothing changed.
- “The failing check blocks the merge.” Only if branch protection requires that check. A red check with no branch-protection rule is decorative — the PR still merges. Right model: enforcement lives in branch protection, not in the workflow file.
- “Community Build gives us per-PR analysis.” It analyses one branch. Isolated per-PR “new code” and inline PR decoration need Developer Edition or SonarQube Cloud. Right model: know your edition’s limits before promising PR-scoped gating.
- “We’ll just lower the threshold when it’s annoying.” Silently weakening the gate (dropping
new_coverageto 50% before a release) defeats the control and leaves no trail. Right model: the gate is an auditable control — change it through review (and, as wired here, a ServiceNow record), or use the branch-protection escape hatch for one repo during an incident.
Glossary
- SonarQube Server — the self-hosted analysis server this guide deploys; stores results in PostgreSQL, serves the UI, and computes quality gates.
- SonarQube Cloud — Sonar’s SaaS edition (formerly SonarCloud); same model, no infrastructure to run.
- SonarScanner — the client that parses your code in CI and uploads an analysis report to the server; ships as a CLI and as Gradle/Maven/.NET/npm plugins.
- Quality profile — the per-language set of active rules that decide which issues are raised. “Sonar way” is the built-in default.
- Quality gate — the per-project set of pass/fail conditions (coverage, ratings, duplications) evaluated after each analysis.
- Clean as You Code / new code — Sonar’s methodology of judging only the code changed in this PR, not the whole codebase.
- New code definition — how “changed” is decided: previous version, number of days, a specific analysis, or a reference branch.
- Bug / Reliability issue — code that is or will be wrong at runtime; drives the reliability rating.
- Vulnerability / Security issue — a concrete, exploitable security hole; drives the security rating.
- Code smell / Maintainability issue — working but hard-to-maintain code; drives the maintainability rating.
- Security hotspot — security-sensitive code flagged for human review; not itself a proven vulnerability.
- Rating (A–E) — a letter grade per software quality; a gate condition like
rating > 1fails anything worse than A. - Coverage — percentage of (new) lines and conditions exercised by tests, computed from a report such as JaCoCo XML.
- Duplications — percentage of (new) lines that are copy-pasted.
- Compute Engine / background task — the server-side worker that processes an uploaded report and computes measures and the gate, asynchronously.
sonar.qualitygate.wait— scanner flag that blocks CI until the gate is computed and exits non-zero on ERROR.- Webhook — a server-to-client callback SonarQube fires when a task finishes; required for Jenkins
waitForQualityGate, optional for the polling scanner. - PR decoration — inline comments and a status check SonarQube posts on a pull request; needs Developer Edition or SonarQube Cloud.
- Branch-protection required check — the GitHub rule that makes a named check mandatory before merge; the actual merge-blocking mechanism.
- Analysis token — a scoped credential the scanner uses to authenticate to SonarQube; keep it in Vault and scope it to analysis only.
jdbcOverwrite— the Helm setting that points SonarQube at your external PostgreSQL instead of the bundled one.vm.max_map_count— a Linux kernel setting Elasticsearch requires (≥ 262144); too low and the SonarQube pod crash-loops.- LTA (Long-Term Active) — Sonar’s long-support release line (successor to the old LTS); pin to it rather than
latest. - MQR mode — Multi-Quality Rule mode, where one issue can impact several software qualities; the modern default, versus legacy Standard Experience.
- Data Center Edition — the clustered, highly-available SonarQube edition for zero-downtime operation at scale.
The shape of the win
The payoff is cultural as much as technical: “code quality” stops being a wiki page and becomes a wall the build runs into. A developer who drops coverage or introduces a bug on new code finds out in their own PR, in minutes, with the exact lines flagged — not in a quarterly review and not in a production incident. Because the gate runs on new code, the team is never blocked by legacy debt yet can never add to it. Everything around it — PostgreSQL for durable history, Vault for the secrets, Okta/Entra for access, Akamai and Wiz for the posture, Datadog for availability — exists so that the one moment that matters, the red check on a risky PR, happens reliably every single time.