DevOps Lesson 41 of 56

Set Up SonarQube on Kubernetes with PostgreSQL and Quality Gate Enforcement in CI

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

What you’ll be able to do after this

Target topology

Set Up SonarQube on Kubernetes with PostgreSQL and Quality Gate Enforcement in CI — 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

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.

  1. (Beginner) Read the gate over the API. Return just the gate status string for main of project kloudvin_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 ERROR
    

    Why: project_status is the very endpoint qualitygate.wait polls — reading it by hand demystifies “the gate.” </details>

  2. (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: false and jdbcOverwrite set, that log line must never appear; if it does, your history is ephemeral. </details>

  3. (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/checkout must use fetch-depth: 0. Why: reference-branch new code needs full Git history to compute the diff, or new_coverage is empty and the gate passes vacuously. </details>

  4. (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>

  5. (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=true so the scanner blocks and exits non-zero on ERROR; (2) a new_coverage condition in the gate; (3) a correct sonar.coverage.jacoco.xmlReportPaths so coverage is not a false 0%; (4) branch protection requiring the SonarQube status check on main. 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>

  6. (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_request to main only (not push to 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

Glossary

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.

SonarQubeKubernetesPostgreSQLGitHub ActionsQuality GatesCI/CD
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments