A self-hosted, security-first CI/CD pipeline built end-to-end on Azure
DevBoard is a task-tracker web app (Go backend + JS/TS frontend), but the app itself isn't the point — the pipeline that ships it is. This project is a hands-on implementation of a full DevSecOps workflow: every commit to main runs through static analysis, secret scanning, dependency scanning, container image checks, unit tests, a security-gated build, and a post-deploy dynamic scan — before landing on a production-style Azure environment reachable only through a zero-trust tunnel.
Built as a self-directed learning project after a DevOps internship, to go deeper into the "Sec" half of DevSecOps rather than just wiring together a green checkmark.
flowchart TB
Dev[Developer Push] --> GH[GitHub Actions<br/>Self-Hosted Runner]
subgraph CI["CI Pipeline"]
direction TB
SAST[SAST: gosec / eslint]
SECRET[Secret Scanning: gitleaks]
DEP[Dependency Scanning]
DOCKER[Docker Build Checks<br/>Trivy]
SONAR[SonarQube Scan]
TEST[Unit Tests]
end
GH --> CI
CI --> BUILD[Docker Build & Push<br/>Docker Hub]
BUILD --> DEPLOY[Deploy to Azure VM]
DEPLOY --> DAST[OWASP ZAP<br/>DAST Scan]
subgraph Azure["Azure VM — self-hosted-runner"]
FE[Frontend :8080]
BE[Backend :8081]
PG[(PostgreSQL :5432)]
SQ[SonarQube :9000]
end
DEPLOY --> Azure
Azure --> TUNNEL[Cloudflare Tunnel]
TUNNEL --> DNS1[devboard.atharva-dev.xyz]
TUNNEL --> DNS2[sonarqube.atharva-dev.xyz]
BASTION[Azure Bastion<br/>Standard SKU] -.SSH access only.-> Azure
Key design decisions:
- No public inbound ports on the VM. All app traffic reaches the VM through an outbound-only Cloudflare Tunnel; admin access goes through Azure Bastion. The NSG stays locked down.
- Self-hosted runner, not GitHub-hosted. Gives the pipeline direct access to deploy into the private VNet without exposing deployment credentials over the public internet.
- DAST is report-only, not a blocking gate. OWASP ZAP runs post-deploy on every push and produces a full report, but doesn't fail the build. This was a deliberate scoping call — full DAST-as-gate needs baseline tuning and false-positive triage that's a project of its own; documented here rather than silently glossed over.
| Stage | Tool | What it catches |
|---|---|---|
code-quality |
Static analysis (SAST) | Code smells, insecure patterns |
secret-scanning |
gitleaks | Committed credentials, API keys, tokens |
dependency-scanning |
GitHub-native | Vulnerable/outdated dependencies |
docker-checks |
Trivy | Container image vulnerabilities |
sonar-scanning |
SonarQube Community | Bugs, code smells, maintainability rating |
code-test |
Go test / npm test | Unit test regressions |
docker-push |
Docker Hub | Versioned, scanned image publish |
deploy |
GitHub Actions → Azure VM | Zero-touch deploy via self-hosted runner |
dast-scan |
OWASP ZAP baseline | Runtime vulnerabilities on the live app |
Infra & Cloud: Azure VM, Azure Bastion (Standard SKU), Cloudflare Tunnel, Cloudflare DNS CI/CD: GitHub Actions (self-hosted runner), Docker, Docker Hub Security tooling: SonarQube Community Edition, OWASP ZAP, gitleaks, Trivy App stack: Go (Gin), JavaScript/TypeScript frontend, PostgreSQL Observability: Docker healthchecks, systemd service monitoring
9 stages — code quality, secret scanning, dependency scanning, Docker checks, SonarQube scan, unit tests, Docker push, deploy, and DAST — all green in under 5 minutes.
Zero new issues, 0% duplication (required ≤3%), quality gate passed on every push.
Security, Reliability, and Maintainability ratings tracked across ~2.2k lines of code.
The deployed app, served through Cloudflare Tunnel with no public inbound ports on the VM.
Two subdomains routed through a single Cloudflare Tunnel to different local ports on the VM.
git clone https://github.com/AtharvaGhongade/devboard-project.git
cd devboard-project
cp .env.example .env # fill in local values
docker compose up -d| Service | URL |
|---|---|
| Frontend | http://localhost:8080 |
| Backend health check | http://localhost:8081/health |
| SonarQube | http://localhost:9000 |
| Postgres | localhost:5432 |
A few of the real debugging problems this pipeline survived (not just "it worked first try"):
- OOM kills on the Azure VM during SonarQube scans — root-caused to an undersized B1s VM, resolved by resizing to B2s.
- GitHub Actions secrets not propagating into reusable workflow calls — fixed with explicit
secrets: inherit. - Azure Bastion Developer SKU blocking WSL SSH tunnels — Native Client support requires Standard SKU; upgraded and reconfigured.
- Cloudflare Tunnel routing two subdomains through one tunnel — single
cloudflaredconfig with per-hostname ingress rules instead of separate tunnels.
Atharva Ghongade — DevOps / Cloud Engineer LinkedIn · GitHub