Building a Production CI/CD Pipeline
Jenkins + SonarQube + Bitbucket, deploying to a shared multi-tenant Linux host. Built and operated in production for a multi-client e-commerce platform.
What this series is. A step-by-step build log of a real pipeline — the commands that worked, the errors that appeared, and the decisions behind each choice. Not a Jenkins tutorial. Every config value, failure mode, and fix here came out of an actual rollout.
The problem being solved
A single staging host runs API servers for a dozen restaurant-brand clients. Each client has its own Linux service account, its own nvm installation with a different Node version, and its own PM2 process tree. Deployments were manual: SSH in, git pull, npm ci, pm2 restart, hope nothing broke.
The goal: automated builds triggered from source control, with code quality and security gates that block a deploy when the code doesn't meet standard — without giving the CI system root, and without exposing the Jenkins controller to the internet.
Final architecture
Bitbucket (source)
│ SCM polling — outbound only, no inbound webhook
▼
Jenkins controller 10.10.1.215 (private network)
│
├─ Node version check ──→ enforce .nvmrc matches build tool
├─ npm ci
├─ Dependency audit ────→ notify-only
├─ TypeScript compile
├─ Lint + Format ───────→ BLOCKING
├─ Unit tests + coverage
├─ SonarQube scan ──────→ 10.10.1.231:9000
├─ Quality Gate ────────→ BLOCKING (abortPipeline: true)
▼
Deploy (only when currentResult == SUCCESS)
│ ssh ubuntu@staging → sudo -u <client> → source nvm → pm2 restart
▼
Staging host 10.10.1.223 — one service user per client
Series contents
| Part | Topic | What you'll be able to do |
|---|---|---|
| 1 | Jenkins Controller Setup | Install Jenkins on Ubuntu, harden it, pick the right plugin set, configure executors and build retention |
| 2 | SonarQube & Quality Gates | Stand up SonarQube, create projects, author a custom quality gate, understand New Code vs Overall Code |
| 3 | Wiring the Toolchain | Tokens, credentials, sonar-scanner install, SMTP notifications, the PATH trap that breaks non-interactive shells |
| 4 | Jenkinsfile Anatomy | Write a declarative pipeline stage by stage, including the notify-only pattern for staged rollouts |
| 5 | Multi-Client Deploys | Deploy to per-user nvm/PM2 environments over SSH with least-privilege sudo, no root anywhere |
| 6 | Gate Enforcement | Roll out blocking gates on a legacy codebase without stopping the business |
| 7 | Troubleshooting | Every real failure hit during the build — symptom, root cause, fix |
Environment reference
Values used throughout the series. Substitute your own.
| Jenkins controller | Ubuntu 24.04, Jenkins 2.555.3 LTS, Java 17 |
| SonarQube | 10.5.1 Community Edition, http://10.10.1.231:9000 |
| Scanner | sonar-scanner CLI 6.2.1 at /opt/sonar-scanner |
| Staging host | Ubuntu, one service user per client, per-user nvm + PM2 |
| CI deploy account | ubuntu — scoped NOPASSWD sudo to each service user |
| Source control | Bitbucket Cloud, HTTPS + app-password credential |
| SMTP | Zoho smtp.zoho.com:587 STARTTLS |
| App stack | NestJS API, TypeScript, Biome lint, Prettier, Jest |
Principles applied
- Least privilege end to end. Jenkins never gets root. The deploy account gets
sudoto exactly the service users that have pipelines, onesudoers.dfile each. - No inbound exposure. SCM polling instead of webhooks keeps the controller off the public internet. Latency cost is a few minutes; the security gain is the whole attack surface.
- Gates block deploys, not builds. The deploy stage is guarded on
currentResult == 'SUCCESS', so a failed gate can never ship code even if a later stage is added carelessly. - Staged enforcement. New checks land as notify-only first, get a baseline, then flip to blocking. Turning on every gate at once against a legacy codebase just stops all work.
- The pipeline is code. The Jenkinsfile lives in the repo on the deploy branch, so pipeline changes are reviewed and versioned like any other change.