DevOps Platform

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.

Prerequisites

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.

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, 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.

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

Keep Reading