Tools: Complete Guide to Building a 4-Stage CI/CD Pipeline for a React App with Azure DevOps

Tools: Complete Guide to Building a 4-Stage CI/CD Pipeline for a React App with Azure DevOps

Why React Cannot Be Deployed Like Static HTML

The Four Stages Explained

Stage 1: Build

Stage 2: Test

Stage 3: Publish

Stage 4: Deploy

Something Important I Learned About Server Files

The Full Pipeline YAML

Result

Key Takeaways There is a big difference between deploying static HTML files and deploying a React application. I learned that difference firsthand in this project, and it changed how I think about pipelines. This is Project 3 in my Azure DevOps series. In the previous project, I deployed a static finance website by copying HTML files directly to an Nginx server using SSH. Clean, simple, effective. But React is a different story. You cannot just copy the source files and call it done. The code has to be compiled first. And that compilation step is what makes a proper multi-stage pipeline not just useful, but necessary. Four stages. Build, Test, Publish, Deploy. Each one depends on the previous. If any stage fails, the rest do not run. That is CI/CD working exactly the way it is supposed to. This is worth understanding before we get into the pipeline itself. When you write a React app, you write in JSX. JSX is not something a browser can read directly. Before it can go live, it has to go through a build process that compiles everything into plain HTML, CSS, and JavaScript. Running npm run build produces a /build directory containing those optimized, browser-compatible files. That /build folder is what actually gets deployed to the server, not the source code. So unlike Project 2 where I pushed the site files straight to Nginx, here the pipeline has to build the app first, verify the output, and only then copy it to the server. That extra step is what the first three stages of this pipeline are doing. This is where everything starts. The pipeline installs Node.js 18, runs npm install to pull in dependencies, then runs npm run build to compile the React app. The resulting /build directory is published as a pipeline artifact called react_build. Publishing it as an artifact is important. It means the compiled output is saved and can be downloaded by later stages, even if those stages run on a different agent. You are not recompiling the app at every stage. You build once, save the result, and pass it forward. After the build succeeds, the pipeline runs the unit tests. There is one flag here that is easy to miss and very important to include: Without --watchAll=false, the test runner goes into watch mode. It sits there waiting for file changes that will never come because this is a CI environment, not a local dev machine. The pipeline just hangs. Adding this flag tells the test runner to run once, report the results, and exit. Always include this in any CI pipeline running React tests. This stage downloads the react_build artifact and lists its contents. It might look like a minor step but it is doing something important: acting as a verification gate. Before anything touches the live server, you confirm that the right files are actually in the artifact. It is a sanity check that catches issues early, before they reach production. The final stage downloads the artifact, copies the compiled React files to /var/www/html on the Nginx VM over SSH, and then restarts Nginx to serve the updated content. The SSH service connection here (ubuntu-nginx-ssh-react) points to the same VM provisioned in the earlier project using Terraform. Midway through this project, I thought about editing index.html directly on the server to update the content being displayed. It seemed like the fastest way to make a small change. I am glad I paused and thought it through, because that would have been entirely wrong in a CI/CD setup. The next pipeline run would overwrite that change completely. The server is not the source of truth. The repository is. Any change that needs to go live should happen in the source code, in Azure Repos, through a commit. That commit triggers the pipeline, which rebuilds the app, runs the tests, and deploys a fresh version of the site. Your change goes through the full process before it ever hits the server. That is not just a best practice. It is the entire point of having a pipeline. CI/CD is designed to make the deployment process consistent and repeatable, and editing files directly on the server breaks that completely. Once you understand this, a lot of other DevOps concepts start to make more sense. A few things worth noting in this YAML: The dependsOn keyword is what creates the sequential chain. Each stage explicitly depends on the previous one, so if the build fails, the test stage never starts. If tests fail, nothing gets deployed. This is intentional. You do not want broken code reaching your server. The cleanTargetFolder: true setting wipes /var/www/html before copying new files. This ensures you are always deploying a clean build with no leftover files from previous runs. If you ran into permission errors on this in an earlier project (I did), make sure the azureuser account owns /var/www/html before the pipeline runs. All four stages went green. React app live on Nginx. The custom content I added to verify the deployment was showing correctly: Total pipeline time from commit to live: 2 minutes and 51 seconds. That is a fully compiled, tested, and deployed React application, triggered by a single push to main. No manual steps anywhere in the process. A few things I would want anyone following this to keep in mind: Never edit files directly on the server in a CI/CD setup. The pipeline will overwrite them on the next run. Your repo is the source of truth. Always. Always use --watchAll=false for React tests in CI. Without it, your pipeline will hang indefinitely. Use pipeline artifacts to pass build output between stages. Build once, share the result. Do not recompile at every stage. The dependsOn keyword is how you enforce order. Stages run in parallel by default in Azure DevOps. If you need them sequential, you have to say so explicitly. Three projects down in the Azure DevOps series. One more to go. Vivian Chiamaka Okose is a DevOps Engineer building real pipelines and writing about what she learns. Templates let you quickly answer FAQs or store snippets for re-use. Hide child comments as well For further actions, you may consider blocking this person and/or reporting abuse

Command

Copy

$ -weight: 500;">npm test -- --watchAll=false -weight: 500;">npm test -- --watchAll=false -weight: 500;">npm test -- --watchAll=false trigger: branches: include: - main pool: name: SelfHostedPool stages: - stage: Build jobs: - job: BuildJob steps: - checkout: self - task: NodeTool@0 inputs: versionSpec: '18.x' - script: | -weight: 500;">npm -weight: 500;">install -weight: 500;">npm run build displayName: Build React App - publish: build artifact: react_build - stage: Test dependsOn: Build jobs: - job: TestJob steps: - task: NodeTool@0 inputs: versionSpec: '18.x' - script: | -weight: 500;">npm -weight: 500;">install -weight: 500;">npm test -- --watchAll=false displayName: Run Tests - stage: Publish dependsOn: Test jobs: - job: PublishJob steps: - download: current artifact: react_build - script: ls $(Pipeline.Workspace)/react_build displayName: Verify Artifact Contents - stage: Deploy dependsOn: Publish jobs: - job: DeployJob steps: - download: current artifact: react_build - task: CopyFilesOverSSH@0 inputs: sshEndpoint: 'ubuntu-nginx-ssh-react' sourceFolder: '$(Pipeline.Workspace)/react_build' contents: '**' targetFolder: '/var/www/html' cleanTargetFolder: true - task: SSH@0 inputs: sshEndpoint: 'ubuntu-nginx-ssh-react' runOptions: 'inline' inline: '-weight: 600;">sudo -weight: 500;">systemctl -weight: 500;">restart nginx' trigger: branches: include: - main pool: name: SelfHostedPool stages: - stage: Build jobs: - job: BuildJob steps: - checkout: self - task: NodeTool@0 inputs: versionSpec: '18.x' - script: | -weight: 500;">npm -weight: 500;">install -weight: 500;">npm run build displayName: Build React App - publish: build artifact: react_build - stage: Test dependsOn: Build jobs: - job: TestJob steps: - task: NodeTool@0 inputs: versionSpec: '18.x' - script: | -weight: 500;">npm -weight: 500;">install -weight: 500;">npm test -- --watchAll=false displayName: Run Tests - stage: Publish dependsOn: Test jobs: - job: PublishJob steps: - download: current artifact: react_build - script: ls $(Pipeline.Workspace)/react_build displayName: Verify Artifact Contents - stage: Deploy dependsOn: Publish jobs: - job: DeployJob steps: - download: current artifact: react_build - task: CopyFilesOverSSH@0 inputs: sshEndpoint: 'ubuntu-nginx-ssh-react' sourceFolder: '$(Pipeline.Workspace)/react_build' contents: '**' targetFolder: '/var/www/html' cleanTargetFolder: true - task: SSH@0 inputs: sshEndpoint: 'ubuntu-nginx-ssh-react' runOptions: 'inline' inline: '-weight: 600;">sudo -weight: 500;">systemctl -weight: 500;">restart nginx' trigger: branches: include: - main pool: name: SelfHostedPool stages: - stage: Build jobs: - job: BuildJob steps: - checkout: self - task: NodeTool@0 inputs: versionSpec: '18.x' - script: | -weight: 500;">npm -weight: 500;">install -weight: 500;">npm run build displayName: Build React App - publish: build artifact: react_build - stage: Test dependsOn: Build jobs: - job: TestJob steps: - task: NodeTool@0 inputs: versionSpec: '18.x' - script: | -weight: 500;">npm -weight: 500;">install -weight: 500;">npm test -- --watchAll=false displayName: Run Tests - stage: Publish dependsOn: Test jobs: - job: PublishJob steps: - download: current artifact: react_build - script: ls $(Pipeline.Workspace)/react_build displayName: Verify Artifact Contents - stage: Deploy dependsOn: Publish jobs: - job: DeployJob steps: - download: current artifact: react_build - task: CopyFilesOverSSH@0 inputs: sshEndpoint: 'ubuntu-nginx-ssh-react' sourceFolder: '$(Pipeline.Workspace)/react_build' contents: '**' targetFolder: '/var/www/html' cleanTargetFolder: true - task: SSH@0 inputs: sshEndpoint: 'ubuntu-nginx-ssh-react' runOptions: 'inline' inline: '-weight: 600;">sudo -weight: 500;">systemctl -weight: 500;">restart nginx' Welcome to My React App (Finance APP) Deployed by: Vivian Chiamaka Okose | Date: 08/04/2026 Welcome to My React App (Finance APP) Deployed by: Vivian Chiamaka Okose | Date: 08/04/2026 Welcome to My React App (Finance APP) Deployed by: Vivian Chiamaka Okose | Date: 08/04/2026