Deploying React web applications in Microsoft Azure with Azure Pipelines

This post is the second in a two part series on React JS. The first post covered design decisions to make before starting a project, and this post provides tips for building and deploying React web applications in Microsoft Azure with Azure Pipelines.

Preparing build output for multiple environments.

A prerequisite step to building the Azure Pipelines YAML file is to review the web application’s build commands and environment variables.

The build output for a React project is a static bundle of HTML, CSS, and JavaScript files. If we have environment specific variables (such as an API server address), then they need to be baked into the JS bundle for each environment (ex: development, pre-production, production, etc).

If you bootstrapped your project with Create React App (CRA) then the toolchain automatically supports environment variables with the dotenv package. Using dotenv allows you to create environment variable files (.env) for each target environment for your deployments.

Then if you add the env-cmd package to your dependencies, you can build environment specific bundles like this in your package.json:

"scripts": {
  "start": "react-scripts start",
  "build:development": "env-cmd -f .env.dev react-scripts build",
  "build:preproduction": "env-cmd -f .env.ppe react-scripts build",
  "build:production": "env-cmd -f .env.prod react-scripts build",
  "test": "react-scripts test",
  "eject": "react-scripts eject"
}

Then you can run the build for a specific environment:

npm run-script build:production

These environment files and ‘build’ commands should be in place before moving to the next step.

Configuring an Azure Pipeline to build, test, and publish artifacts

Start by creating a new/empty Azure Pipelines YAML CI file. Towards the top of the file, we will include:

  • A continuous integration (CI) and pull request (PR) trigger that initiate the builds automatically.
  • A specified build image.
  • A variable that has the root project folder (since we re-use it so many times in the stages).
# Azure DevOps YAML pipeline
# File part 1

trigger:
  branches:
    include:
      - main
  paths:
    include:
      - 'src/MyProjectRootFolder/*'

pr:
  autoCancel: true
  branches:
    include:
      - '*'
  paths:
    include:
      - 'src/MyProjectRootFolder/*'

pool:
  vmImage: windows-latest

variables:
- name: WebProjectRoot
  value: src/MyProjectRootFolder

In the second part of the Azure Pipelines YAML file we perform the following tasks:

  • Run the ‘npm install’ task to install the packages.
  • Run the build command for each deployment environment (ex: dev, ppe, production). In the example file here I’m just building for the ‘development’ environment, but this step would be repeated for each additional deployment environment you have.
  • Copy the build output to the Azure pipelines artifact staging directory.
  • Run the unit tests.
  • Publish the artifacts (if triggered from main branch).
# Azure DevOps YAML pipeline
# File part 2

stages:
- stage: BuildStage
  displayName: 'Build, Test, and Publish'
  jobs:
    - job: BuildJob
      steps:
        - task: CmdLine@2
          name: Install
          displayName: Install Packages
          inputs:
            script: 'npm install'
            workingDirectory: '$(WebProjectRoot)'
            failOnStderr: false

        - task: CmdLine@2
          name: BuildDev
          displayName: Create Build Artifact (Dev)
          inputs:
            script: 'npm run-script build:development'
            workingDirectory: '$(WebProjectRoot)'
            failOnStderr: true

        - task: ArchiveFiles@2
          name: ZipArtifactsDev
          displayName: Zip and Copy to Staging (Dev)
          inputs:
            rootFolderOrFile: '$(WebProjectRoot)/build'
            archiveFile: '$(Build.ArtifactStagingDirectory)/deployment.development.zip'
            includeRootFolder: false

        - task: CmdLine@2
          name: Test
          displayName: Run Unit Test Suite
          inputs:
            script: 'npm test'
            workingDirectory: '$(WebProjectRoot)'
            failOnStderr: false

        - task: PublishBuildArtifacts@1
          name: Publish
          displayName: Publish Zip Artifact
          condition: and(succeeded(), eq(variables['build.sourceBranch'], 'refs/heads/main'))
          inputs:
            pathtoPublish: '$(Build.ArtifactStagingDirectory)'
            ArtifactName: 'WebDrop'

Once the YAML file is checked into source control, create the pipeline and specify the YAML file as the definition.

Azure infrastructure preparation

The next step in the pipeline is to make sure the Azure App Service resource is deployed and ready.

We can do this by creating a resource group in the Azure subscription and then running an ARM template deployment to create an App Service hosting plan resource and an App Service resource.

The AzureResourceGroupDeployment YAML task or the Azure PowerShell YAML task are helpful options, depending on your preference. Here is an example ARM template that deploys both resources:

{
  "$schema": "https://schema.management.azure.com/schemas/2015-01-01/deploymentTemplate.json#",
  "contentVersion": "1.0.0.0",
  "parameters": {
    "locationName": {
      "type": "string"
    },
    "appServicePlanName": {
      "type": "string"
    },
    "websiteName": {
      "type": "string"
    }
  },
  "variables": { },
  "resources": [
    {
      "type": "Microsoft.Web/serverfarms",
      "apiVersion": "2019-08-01",
      "name": "[parameters('appServicePlanName')]",
      "location": "[parameters('locationName')]",
      "sku": {
        "name": "S1",
        "tier": "Standard",
        "size": "S1",
        "family": "S",
        "capacity": 1
      },
      "kind": "app",
      "properties": {
        "perSiteScaling": false,
        "reserved": false,
        "targetWorkerCount": 0,
        "targetWorkerSizeId": 0
      }
    },
    {
      "type": "Microsoft.Web/sites",
      "apiVersion": "2019-08-01",
      "name": "[parameters('websiteName')]",
      "location": "[parameters('locationName')]",
      "dependsOn": [
        "[resourceId('Microsoft.Web/serverfarms', parameters('appServicePlanName'))]"
      ],
      "kind": "app",
      "properties": {
        "enabled": true,
        "serverFarmId": "[resourceId('Microsoft.Web/serverfarms', parameters('appServicePlanName'))]",
        "reserved": false,
        "siteConfig": {
          "alwaysOn": true,
          "http20Enabled": true,
          "minTlsVersion": "1.2"
        },
        "clientAffinityEnabled": true,
        "httpsOnly": true
      }
    }
  ],
  "outputs": { }
}

If you have multiple target deployment environments (dev, ppe, production, etc), then run the ARM template deployment once for each environment– but pass in different parameter values for the location, app service name and hosting plan name.

Configuring an Azure Pipeline to deploy code artifacts to Azure App Service

At this point we should have artifacts publishing to our package feeds automatically, and an Azure App Service in place and ready for deployment.

We can take our existing Azure Pipelines file an add some deployment variables. At bare minimum we need the Azure service connection name and the name of the Azure app service to deploy to.

In this example I have prefixed the variable names with the deployment environment and just included the ‘development’ environment for brevity:

# Azure DevOps YAML pipeline
# File part 4

variables:
- name: Development.Azure.SubscriptionName
  value: 'MyAzureSubscriptionServiceConnectionName'
- name: Development.Azure.WebAppName
  value: 'development-env-my-webapp-name'

Then in the stages section, we can add a new stage for the deployment of each target deployment environment. Again I just have one environment shown here but you can easily add the remaining environments. There are two main tasks:

  • Download the published artifacts.
  • Run an Azure Web App Deployment task.
# Azure DevOps YAML pipeline
# File part 5

- stage: DeployToDevelopmentEnv
  displayName: 'Deploy to Development Environment'
  condition: and(succeeded(), eq(variables['build.sourceBranch'], 'refs/heads/main'))
  dependsOn: BuildStage
  jobs:
    - job: DeployArtifact
      steps:
        - task: DownloadBuildArtifacts@0
          displayName: 'Download Build Artifacts'
          inputs:
            artifactName: 'WebDrop'
            
        - task: AzureRmWebAppDeployment@4
          displayName: 'Deploy artifact to Azure App Service'
          inputs:
            ConnectedServiceName: $(Development.Azure.SubscriptionName)
            WebAppName: $(Development.Azure.WebAppName)
            Package: "$(System.ArtifactsDirectory)/WebDrop/deployment.development.zip"
            UseWebDeploy: true
            TakeAppOfflineFlag: false
            RenameFilesFlag: false

With all of the build and deployment steps in the YAML pipelines file, it has likely grown to a pretty significant size. To break it into smaller, more re-usable pieces, leverage the templates feature.

This is especially helpful when you have multiple environments and want to avoid duplicate the same YAML steps over and over for each environment.

Adding a web.config re-write rule for React Router

The final setup task is optional, but recommended if you have URL routing inside your web app.

React Router is the standard URL router for React projects. A common issue for web app deployments in Azure App Service is handling the confusion between your application’s virtual router and the server-side routing that naturally occurs.

More specifically: the application routing works if you entered the site from the default route (index.html loads), but doesn’t work when you explicitly visit a route from the browser’s address bar (usually resulting in a 404 not found).

The issue here is that the Azure App Service web server (IIS) can’t find your virtual route — and it needs to be told to redirect those virtual routes back to index.html where they can be handled by your virtual router (React Router).

The solution is to create a new web.config file with the re-write/redirect rule (below), and then deploy that file to the App Service in the same folder as the root of your static bundle.

<?xml version="1.0" encoding="UTF-8"?>
<configuration>
  <system.webServer>
    <rewrite>
      <rules>
        <rule name="Rewrite Text Requests" stopProcessing="true">
          <match url=".*" />
          <conditions>
            <add input="{HTTP_METHOD}" pattern="^GET$" />
            <add input="{HTTP_ACCEPT}" pattern="^text/html" />
            <add input="{REQUEST_FILENAME}" matchType="IsFile" negate="true" />
          </conditions>
          <action type="Rewrite" url="/index.html" />
        </rule>
      </rules>
    </rewrite>
  </system.webServer>
</configuration>

Assuming the file is checked into source control in the root of your web project, you can add it to your artifact zip with the following task:

- task: ArchiveFiles@2
  name: AddWebConfigDev
  displayName: Add Web.Config (Dev)
  inputs:
    rootFolderOrFile: '$(WebProjectRoot)/web.config'
    archiveFile: '$(Build.ArtifactStagingDirectory)/deployment.development.zip'
    includeRootFolder: false
    replaceExistingArchive: false

4 thoughts on “Deploying React web applications in Microsoft Azure with Azure Pipelines

  1. Carlos Herrera November 26, 2020 / 12:12 am

    I could not agree more. I use azure pipelines for React too although I didn’t know how to rewrite and configure. Thank you for sharing this.

    Liked by 1 person

  2. Graeme December 10, 2021 / 2:36 am

    would you not be better using the release pipeline variables – then you build it once and in each environment release you change the variables in the build?

    Like

    • keithbabinec December 10, 2021 / 6:14 pm

      The npm build command builds the bundle (js/cs/html) artifacts, and you need to run this multiple times if you have multiple environments. Trying to change the variables on an already built bundle, from a release pipeline technically would be possible, but it would probably be a mess of search and replace code, I wouldn’t recommend it.

      Like

Leave a comment