Self-Hosting SonarQube for a 13-Branch Microservices Pipeline: Infrastructure, Network Isolation…
How I built a private, network-isolated SonarQube environment with multi-branch analysis for a 13-service Node.js platform — the Vagrant…
Self-Hosting SonarQube for a 13-Branch Microservices Pipeline: Infrastructure, Network Isolation, and Where Deep SAST Belongs in CI
How I built a private, network-isolated SonarQube environment with multi-branch analysis for a 13-service Node.js platform — the Vagrant infrastructure, the two-VM network design, the self-hosted GitHub Actions runner, and the exact reasoning behind where this scan sits in a 6-stage security pipeline.

“The deepest scan should run last not because it matters least, but because it costs the most.”
When I added Static Application Security Testing to the PDF-Labs pipeline, I ran into a problem that doesn’t show up in most SonarQube tutorials: my platform has 13 independent service branches, and SonarQube needs to analyze each one separately, track issues per branch, and decorate results without losing history every time a new branch appears.
That requirement, genuine multi-branch analysis, sits behind a licensing wall in SonarQube’s hosted and default self-hosted offerings. So this article isn’t just about adding a SonarQube step to a YAML file. It’s about the full infrastructure I built to make deep, per-branch static analysis work for a real multi-service platform: a provisioned VM running SonarQube inside Docker, a second VM running a self-hosted GitHub Actions runner, a private network connecting the two, and the exact position this scan occupies in a six-stage CI pipeline.
Why This Needed Real Infrastructure, Not Just a YAML Step
Most CI security tutorials treat SonarQube as a single action block: add the step, pass a token, and you're done. That works fine for a single-repo, single-branch project using SonarCloud or a vanilla SonarQube container.
It does not work the same way once you have 13 service branches that each need independent analysis. SonarQube’s branch analysis, separate issue tracking per branch, pull request decoration, and branch comparison are features that are gated behind SonarQube’s paid Developer and Enterprise editions when using the official **sonarqube:lts-community image. The free Community Edition image only analyses a single branch, conventionally `main`**.
That’s a hard blocker for a platform structured the way PDF-Labs is, where each of the 13 services lives and is developed on its own branch in the central repository.
The fix is a community-maintained image that re-enables multi-branch analysis in the open-source edition:
mc1arke/sonarqube-with-community-branch-plugin:lts
This image bundles the official SonarQube Community Edition with the SonarQube Community Branch Plugin, an open-source plugin that restores multi-branch analysis and pull request decoration without requiring a commercial license. It is not an official Sonar product, but it is widely used precisely for this scenario: teams that need genuine multi-branch CI integration without paying for Developer or Enterprise tiers.
Running this requires a SonarQube server that is persistent, not ephemeral. SonarQube needs to retain its analysis database, issue history, quality gate trends, and branch comparisons across every CI run. Spinning up a fresh SonarQube container with a fresh, empty database on every pipeline execution would mean losing all of that history each time, which defeats the purpose of the tool. So the server itself has to live somewhere long-running, not inside a GitHub-hosted runner’s ephemeral, single-job VM.
In this case, that persistent home is a privately networked VM rather than a public cloud instance, a deliberate choice to keep infrastructure costs down. That single decision is what shaped the rest of the setup: because the SonarQube server lives on a private network with no public route, a GitHub-hosted runner has no way to reach it. The only thing that can reach it is something sitting on that same private network, which is exactly why a self-hosted runner became necessary as well.
The Infrastructure: Two VMs, One Private Network
The setup is two Vagrant-provisioned virtual machines on a shared VirtualBox host-only network:
VirtualBox Host-Only Network (192.168.56.0/24)
│
├── SonarQube VM 192.168.56.10
│ └── Docker container: mc1arke/sonarqube-with-community-branch-plugin:lts
│ (port 9000)
│
└── GitHub Runner VM 192.168.56.x
└── Self-hosted GitHub Actions runner
(registered against the central repo)
Both VMs sit on the same private network, which is what allows the runner to reach the SonarQube server at [**http://192.168.56.10:9000](http://192.168.56.10:9000) **an address that is not reachable from the public internet and, therefore, not reachable from a GitHub-hosted runner at all. This is precisely why a GitHub-hosted runner could never execute the SonarQube step in this pipeline: there is no route from GitHub's cloud infrastructure into a private VirtualBox network sitting behind a developer's machine.
The SonarQube VM
Provisioned with this Vagrantfile:
Vagrant.configure("2") do |config|
config.vm.define "sonarqube" do |sonarqube|
sonarqube.vm.box = "ubuntu/jammy64"
sonarqube.vm.hostname = "sonarqube"
# Add host-only so host & self-hoster-runner can reach VM
sonarqube.vm.network "private_network", ip: "192.168.56.10"
sonarqube.vm.provider "virtualbox" do |vb|
vb.memory = "6144"
vb.cpus = 2
end
sonarqube.vm.provision "shell", inline: <<-SHELL
sudo apt-get update -y
sudo apt-get install -y docker.io
sudo systemctl start docker
sudo systemctl enable docker
sudo usermod -aG docker $USER
sudo chmod 666 /var/run/docker.sock
echo "Waiting for 30 seconds before running SonarQube Docker container..."
sleep 30
## Running SonarQube in a docker container
docker run -d -p 9000:9000 --name sonarqube \
mc1arke/sonarqube-with-community-branch-plugin:lts
SHELL
end
end
A few details worth explaining:
6GB RAM and 2 CPUs. SonarQube’s Elasticsearch-backed indexing is memory-hungry. Under-provisioning this VM is the most common cause of SonarQube failing to start cleanly. It will frequently fail Elasticsearch’s bootstrap checks or get OOM-killed during indexing on anything smaller.
**private_network, ip: "192.168.56.10" assigns a static host-only IP. This is what makes the address predictable and stable across reboots. It is important because the runner's CI job references this exact IP as `SONAR_HOST_URL`**.
The sleep 30 before starting the container gives Docker's daemon time to fully initialize after the provisioning script enables and starts the service. Without this, the **docker run** command can occasionally race the daemon startup on a freshly provisioned VM.
**chmod 666 /var/run/docker.sock is a provisioning convenience for a single-purpose lab VM. It allows the vagrant user to run Docker commands without re-logging in to pick up the new `docker`** group membership. This is a reasonable trade-off for an internal, network-isolated lab server; it would not be the right call on a multi-tenant or internet-facing host.

Running SonarQube Container
Why the Community Branch Plugin Image Specifically
The default official image:
# The Community LTS Image
docker run -d -p 9000:9000 --name sonarqube sonarqube:lts-community
# The Community Edition core, with the branch plugin pre-installed
docker run -d -p 9000:9000 --name sonarqube mc1arke/sonarqube-with-community-branch-plugin:lts
works perfectly well for single-branch analysis. The moment a second branch is pushed and analyzed, the Community Edition UI will only retain results for the branch it considers the “main” branch. Multi-branch comparison, per-branch dashboards, and PR decoration are disabled and surfaced as upsell prompts toward Developer Edition.
**mc1arke/sonarqube-with-community-branch-plugin:lts ships the same SonarQube Community Edition core, with the branch plugin pre-installed, so `-Dsonar.branch.name`** (passed automatically by the scan action based on the checked-out ref) is respected and each of the 13 service branches gets its own persistent, comparable analysis history in the dashboard.

SonarQube dashboard showing multiple branches listed with separate issue counts/quality gate status.
Hardening the Instance
This is not left as the bare default Docker setup. Token-based authentication is used end-to-end:
- The default admin credentials are rotated immediately after the first login.
- A dedicated analysis token is generated under My Account → Security → Users → Generate Tokens specifically for CI use, scoped to this purpose rather than reusing an admin session.
- That token is not a username/password pair but is what’s stored as
**SONAR_TOKEN** in GitHub Actions secrets and passed to the scan action. - Network exposure is limited to the VirtualBox host-only adapter; the SonarQube web UI and API are not reachable outside that private network, which is a stronger boundary than authentication alone.

SonarQube token generation screen under My Account → Security → Users → Generate Tokens

SonarQube token generation screen under My Account → Security → Users → Generate Tokens
The GitHub Actions Runner VM
The second VM is a separate Vagrant box dedicated solely to running the self-hosted GitHub Actions runner. It is registered against the central **MICROSERVICE-PDF-LABS repository following GitHub's standard self-hosted runner registration flow, and it sits on the same `192.168.56.0/24** host-only network as the SonarQube VM, which is the entire reason it can reachhttp://192.168.56.10:9000` when **sonar-scanner** runs.
Separating the runner from the SonarQube server onto its own VM, rather than running both on one box, keeps the SonarQube server’s resources dedicated to indexing and analysis and means the runner’s lifecycle (registration, token refresh, runner software updates) is managed independently from the analysis server’s lifecycle.

GitHub repo settings → Actions → Runners, showing the self-hosted runner registered and online

GitHub repo settings → Actions → Runners, showing the self-hosted runner registered and online
The Pipeline Side: Where This Scan Sits and Why
With the infrastructure in place, the CI-side configuration is comparatively simple, but every choice in it is deliberate. Here is the relevant job from [**ci-security-scans.yml](https://github.com/Godfrey22152/MICROSERVICE-PDF-LABS/blob/b6aca1a40b05defd49307e73846f186548764407/.github/workflows/ci-security-scans.yml)**:
# ── SonarQube Static Analysis ───────────────────────────────
# SonarQube is the deepest SAST stage in the pipeline. It
# runs AFTER owasp-dependency-check because:
# 1. It is the slowest scan and benefits from all faster
# gates (compile, audit, gitleaks, trivy, owasp) having
# already acted as a first filter.
# 2. Running it last means a quick early failure (e.g. a
# hardcoded secret caught by gitleaks) avoids burning
# self-hosted runner minutes on a deep SAST scan.
#
# fetch-depth: 0 is required by SonarQube — shallow clones
# hide blame/history data that SonarQube uses to compute
# new-code metrics and assign issue ownership accurately.
#
# Exclusions keep the scan focused on application source:
# node_modules — third-party code (covered by npm audit)
# dist/build — generated output, not source
# .github — CI config, not application logic
# .gitignore / .dockerignore — config files, not code
sonarqube-scan:
name: SonarQube Static Analysis
runs-on: self-hosted-runner
needs: owasp-dependency-check
steps:
- name: Check out Git repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
with:
fetch-depth: 0 # full history required for accurate analysis
- name: SonarQube Scan
uses: SonarSource/sonarqube-scan-action@master
with:
args: >
-Dsonar.projectKey=PDF-Labs-Project-key
-Dsonar.projectName=PDF-Labs-Project
-Dsonar.sources=.
-Dsonar.exclusions=**/node_modules/**,**/dist/**,**/build/**,**/.github/**,**/.gitignore,**/.dockerignore
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
#NOTE:
# SONAR_TOKEN: personal access token
# created in your SonarQube instance under
# My Account → Security → Generate Tokens.
# SONAR_HOST_URL: base URL of your SonarQube server.
Why SonarQube Runs Last in the Scan Sequence
The full pre-build scan sequence is:
compile (syntax check)
│
├── dependency-audit (npm audit)
├── gitleaks-scan (secret detection)
├── dockerfile-lint (Hadolint)
├── trivy-fs-scan (filesystem CVEs)
└── owasp-dependency-check (NVD cross-reference)
│
▼
sonarqube-scan (deep SAST — runs last)

Pre-build scan sequence
**needs: owasp-dependency-check and not `needs: compile`** like the five parallel scanners above it, is the structural decision that puts SonarQube at the very end of the source-scanning sequence, after every other check has already had a chance to fail fast.
This ordering reflects a simple cost asymmetry. Gitleaks can find a hardcoded credential in seconds. npm audit can flag a critical CVE in under a minute. SonarQube, by contrast, performs deep control-flow and data-flow analysis across the entire codebase. A meaningfully slower operation and one running on a fixed-capacity self-hosted VM rather than GitHub’s elastic-hosted runner pool. If a fast, cheap check is going to fail the pipeline anyway, there’s no reason to also consume self-hosted runner time on the most expensive scan first. Letting the cheap gates run first and fail fast means the self-hosted runner’s limited capacity is spent only on commits that have already cleared every faster check.
Why fetch-depth: 0
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
with:
fetch-depth: 0
Every other checkout step in the broader pipeline uses the default shallow clone (**fetch-depth: 1), because most scanners only need the current file tree. SonarQube is the exception. Its new code detection, blame-based issue ownership, and SCM-based metrics all depend on reading actual Git history, who last touched a line, when a file was introduced, and what changed between analysis runs. A shallow clone has no history to read; `fetch-depth: 0`** fetches the complete commit history so SonarQube's SCM provider integration has the data it needs to attribute issues accurately rather than reporting everything as unowned.
The Exclusions
-Dsonar.exclusions=**/node_modules/**,**/dist/**,**/build/**,**/.github/**,**/.gitignore,**/.dockerignore
Each exclusion has a specific reason:
**node_modules** — third-party dependency code. Vulnerabilities in dependencies are already covered by npm audit and OWASP Dependency Check earlier in the pipeline. SonarQube's value here is analyzing code this team actually wrote.**dist/build** — generated output, not source. Scanning compiled or bundled output produces noise without actionable findings, since issues need to be fixed in source, not in generated artifacts.**.github** — CI configuration, not application logic.**.gitignore/.dockerignore** — plain configuration files with no code to analyse.

SonarQube quality gate result page showing all the branches

SonarQube quality gate result page for Sheet-lab Service Branch

SonarQube quality gate result page for Edit-PDF-Service Branch

SonarQube quality gate result page for Account Service Branch

SonarQube quality gate result page for Home Service Branch
Key Takeaways
- Multi-branch SonarQube analysis on the Community Edition requires a community-maintained image.
**mc1arke/sonarqube-with-community-branch-plugin:lts** restores per-branch analysis and PR decoration to the open-source edition. Which is essential for any platform where each service or feature lives on its own long-running branch. - A self-hosted runner is not optional when the analysis server is network-isolated. If
**SONAR_HOST_URL** resolves to a private address, only a runner on that same network can reach it. GitHub-hosted runners have no route into a VirtualBox host-only network. - Separating the runner VM from the SonarQube VM keeps resource concerns isolated. The analysis server’s memory and CPU stay dedicated to indexing and scanning; the runner’s lifecycle is managed independently.
**fetch-depth: 0is non-negotiable for SonarQube specifically**, even when every other job in the pipeline uses a shallow clone. Blame-based metrics need full Git history.
Conclusion
Adding SonarQube to a CI pipeline is usually presented as a five-line YAML addition. For a platform with 13 actively developed branches, it is genuinely an infrastructure project: a provisioned analysis server running a community plugin to unlock multi-branch support without a commercial license, a dedicated self-hosted runner on the same private network to reach it, and a position in the pipeline DAG chosen specifically because of how expensive the scan is relative to everything that runs before it.
None of these decisions are visible in a SonarQube quickstart guide. They only become visible once you’re running deep SAST against more than one branch on infrastructure you actually control.
The full pipeline source, including the complete [**ci-security-scans.yml](https://github.com/Godfrey22152/MICROSERVICE-PDF-LABS/blob/b6aca1a40b05defd49307e73846f186548764407/.github/workflows/ci-security-scans.yml) and every other stage this scan feeds into, is available at: [github.com/Godfrey22152/MICROSERVICE-PDF-LABS](https://github.com/Godfrey22152/MICROSERVICE-PDF-LABS)**
This article is part of a series on building a production-grade DevSecOps pipeline across 13 microservices. Previous articles in the series cover the orchestrator architecture, keyless container image signing with Cosign and Sigstore, and the Allure security dashboard published to GitHub Pages.
Tags: DevOps · DevSecOps · SonarQube · GitHub Actions · Self-Hosted Runners · Static Code Analysis · CI/CD · Vagrant · Docker · Microservices
메타데이터
- post_id
- c184bd891d7b
- slug
- self-hosting-sonarqube-for-a-13-branch-microservices-pipeline-infrastructure-network-isolation-c184bd891d7b
- url
- https://medium.com/@godfreyifeanyi50/self-hosting-sonarqube-for-a-13-branch-microservices-pipeline-infrastructure-network-isolation-c184bd891d7b
- canonical_url
- https://medium.com/@godfreyifeanyi50/self-hosting-sonarqube-for-a-13-branch-microservices-pipeline-infrastructure-network-isolation-c184bd891d7b
- author_url
- https://medium.com/@godfreyifeanyi50
- status
- ok
- fetched_at
- 2026-09-18 18:11:55