DMI Internship
React Deployment — Azure DevOps Multi-Stage CI/CD Pipeline
Built a multi-stage Azure DevOps CI/CD pipeline (Build → Test → Deploy) that automatically deploys a React application to an Ubuntu VM running NGINX. Infrastructure provisioned with Terraform, VM configured with Ansible, deployment over SSH.

Overview
This project extends previous infrastructure automation work into a full CI/CD workflow. Rather than deploying files manually, every commit to the main branch automatically triggers a multi-stage Azure DevOps pipeline that builds the React application, runs unit tests, publishes the build artifact, and deploys it to a live Ubuntu VM via SSH.
The result is true continuous delivery: the developer commits code, the pipeline validates and deploys it, and the application is live — without manual intervention at any stage.
Problem
Manual deployment of web applications is slow, error-prone, and does not scale. The challenge was to build a pipeline that mirrors the way professional engineering teams ship code: automated build, automated test gate, artifact-based deployment, and a web server correctly configured for single-page application routing.
Architecture
Azure Repos (main branch)
└── Trigger: commit to main
└── Azure Pipeline
├── Stage 1: Build
│ Install Node.js 20
│ npm install
│ npm run build
│ Publish /build as artifact: react-build
│
├── Stage 2: Test
│ npm test -- --watchAll=false
│ Pipeline fails here if tests fail
│
└── Stage 3: Deploy
Download artifact
CopyFilesOverSSH → /var/www/html
SSH: configure NGINX, set permissions, restart
Infrastructure (Terraform):
- Resource Group:
react-app-rg(West US 3) - VNet:
10.10.0.0/16, Subnet:10.10.1.0/24 - NSG: inbound TCP 22 (SSH) and TCP 80 (HTTP) only
- Static public IP + NIC
- Ubuntu 22.04 VM (
Standard_B2ats_v2), password authentication
VM Configuration (Ansible):
- NGINX installed and enabled
- Web root:
/var/www/html - SPA routing:
try_files $uri /index.html— handles React client-side routing correctly - Permissions:
www-dataownership,755on web root
Pipeline Configuration
The pipeline YAML defines three stages with explicit dependsOn and condition: succeeded() — the deploy stage only runs if build and test both pass:
trigger:
- main
variables:
sshService: 'ssh-to-nginx-vm'
artifactName: 'react-build'
webRoot: '/var/www/html'
The SSH Service Connection (ssh-to-nginx-vm) is configured in Azure DevOps and used by both CopyFilesOverSSH@0 and SSH@0 tasks — keeping credentials out of the pipeline YAML entirely.
Key Engineering Decisions
- Artifact-based deployment — the pipeline deploys the compiled
/builddirectory, not raw source code. The VM never runsnpm installornpm run build. Separation between build environment and runtime environment is clean. - Test as a gate — the test stage is a hard dependency of deploy. A failing test prevents deployment — not as a convention but as a pipeline condition.
- SPA routing in NGINX —
try_files $uri /index.htmlensures that refreshing any React route (e.g./dashboard) returnsindex.htmlrather than a 404. A common misconfiguration when serving React apps behind a web server. - Credentials via Service Connection — SSH credentials are stored in Azure DevOps as a Service Connection and referenced by name in the YAML. Nothing sensitive appears in the pipeline definition or the repository.
Challenges and Resolutions
Out-of-Memory (OOM) error during npm install
The self-hosted agent VM initially had ~1 GB RAM. The npm install process was terminated by the kernel:
Killed npm install
Active: failed (Result: oom-kill)
Resolution: resized the VM to Standard_B2ats_v2 and added 2 GB swap:
sudo fallocate -l 2G /swapfile
sudo chmod 600 /swapfile
sudo mkswap /swapfile
sudo swapon /swapfile
The agent service was restarted and the pipeline workspace cleared before re-running. The lesson: self-hosted agent sizing matters. Node dependency resolution under npm install is memory-intensive and will OOM-kill on underpowered VMs.
Deployment not reflecting changes
After updating App.js and re-running the pipeline, the browser continued to show the old version. Two causes: React requires a full rebuild (not just file copy) before changes are visible, and browser caching served stale assets. Resolution: pipeline rebuild triggered by commit to main, hard refresh (Ctrl+Shift+R) in the browser.
Results
- Pipeline runs automatically on every commit to main — no manual deployment steps
- All three stages completed successfully: build → test → deploy
- React application served correctly at
http://20.xxx.xxx.180 - NGINX routing confirmed: client-side navigation works without 404 errors
- Second pipeline run verified idempotency — redeployment overwrites cleanly with no residual state
Key Learnings
- CI/CD pipelines should deploy built artifacts, not raw source code. The build step belongs in the pipeline, not on the target server.
- Self-hosted agents must be sized for their workload. Node.js dependency installation is memory-intensive —
npm installon a 1 GB VM will fail. - Swap memory is a legitimate mitigation for memory-constrained build agents, particularly for short-lived workloads.
- Separating build, test, and deploy into discrete stages makes the pipeline easier to debug — a test failure does not trigger a deploy, and the failure is immediately attributable to a specific stage.
- React SPA routing requires NGINX configuration beyond the default. The
try_filesdirective is not optional.