<< go back

Azure deployment recipe for a Node-based application

Published at 10/21/2023, 8:24:15 PM

When creating pipelines, be it in Azure DevOps or any other deployment platform, it is sometimes difficult to structure the different steps required to build and deploy an application. There are things you know you need to do, and then there are things that you don’t know but which you will have to find out through trial and error.

To make this easier, the following is a simple opinionated recipe for a deployment with Azure Pipelines into Azure Cloud. The recipe will follow these assumptions:

  • Node-based application
  • Containerized with Docker
  • Stored in Azure Container Registry
  • Hosted as a web app in Azure App Service
  • Three different environments: dev, staging and production

Dockerfile

Dockerfile

from node:18-alpine

ENV APP_ROOT /src

COPY --chown=node:node ./ ${APP_ROOT}
USER node
WORKDIR ${APP_ROOT}

RUN npm install --production
RUN npm run build

EXPOSE 3000/tcp

ENTRYPOINT [ ”npm”, ”run” ]
CMD [ ”start”]

Let’s go through this file one piece at a time.

from node:18-alpine

First we install Node 18 using the Node Alpine container image. Node Alpine is a Node.js Docker image variant built on Alpine Linux, which has a comparatively smaller image size, faster builds and deployments and efficient resource usage.

ENV APP_ROOT /src

We set an environment variable named ”APP_ROOT” into the /src folder, where we assume the application source code resides. If your app code is in another folder, you could reference that path here instead. Later we can use this variable whenever we want to reference the /src folder.

COPY --chown=node:node ./ ${APP_ROOT}
USER node
WORKDIR ${APP_ROOT}

In this snippet the first line copies the contents of the current directory into the /src folder. You may not want to copy all the files in your project root, so in that case you can also specify the files you want with the COPY command.

The first line also specifies the ownership of the copied files as the ”node” user and group. Then the following line sets the user to run subsequent commands as to "node", to reduce the risk of running processes as the root user.

Finally the third line sets the working directory in the Docker image to the /src folder. This means that all subsequent commands will be executed in this directory.

RUN npm install --production
RUN npm run build

This part installs Node dependencies in production mode and builds the application ready for production. Here you should also run any other scripts the application needs to be prepared for production, such as building other modules or creating new files.

EXPOSE 3000/tcp

This line specifies that the Docker container will expose port 3000 using TCP. However, this is just a declaration and does not actually publish the port.

ENTRYPOINT [ ”npm”, ”run” ]
CMD [ ”start”]

Here the first line sets the default command to execute when the container starts, namely npm run. The second line provides default arguments to the command specified by ENTRYPOINT. It tells the container to run npm run start when it starts. By structuring it like this you may also specify other npm run commands that override the CMD command when running the image, e.g. docker run myDockerImage test.

Though, by default this configuration means when the container is accessed, the application is already running. You may or may not want to do it like this, it is also possible to start the app in the container after build and deployment inside Azure.

Azure Pipelines

For the Azure Pipelines configuration, we will use multiple files to separate the different parts of the build and deployment.
The entrypoint for each deployment is normally an azure-pipelines.yml file in the project root. However, I like to separate these into distinct files per environment. I also like to separate the different steps into templates, as per Azure Pipelines template syntax. Then I also like to have a separate file for all the variables used in the pipelines. Considering my previous points, the files would then follow a folder structure that looks something like this:

pipelines
│
└── templates
│   ├── build-template.yml
│   └── deploy-template.yml
│   └── vars.yml
│
├── dev.yml
├── prod.yml
└── stag.yml

The only differences between the pipeline files for each environment is the variable group they use in Azure and the environment used in deployment stage. Therefore, I will only present one of them: dev.yml. You may want to add some environment-specific checks and permissions for production or staging.

dev.yml

trigger:
  none

variables:
  - template: templates/vars.yml
  - group: variablegroup-dev

stages:
  - stage: BuildAndPush
    displayName: Build and push image (DEV)
    jobs:
      - job: BuildDev
        displayName: Build DEV image
        pool:
          vmImage: 'ubuntu-latest'
        steps:
          - template: templates/build-template.yml

  - stage: Deploy
    displayName: Deploy image (DEV)
    dependsOn: BuildAndPush
    jobs:
      - deployment: DeployDev
        displayName: Deploy image to DEV environment
        environment: 'myApp-dev'
        strategy:
          runOnce:
            deploy:
              steps:
                - template: templates/deploy-template.yml

The build has no triggers but you could define it so that e.g. code changes in a branch trigger the pipeline.

We define variables for all pipelines in the global stage: some variables from the vars.yml file and secrets and environment-specific variables from the variable group in Azure. "variablegroup-dev" in this case is the name of the variable group in Azure DevOps Pipelines.

The pipeline is divided in two parts:

  1. Build and push the container image in Azure Container Registry
  2. Deploy the image to Azure App Service

templates/build-template.yml

steps:
  - task: Docker@2
    displayName: Build and push image to Azure Container Registry
    inputs:
      command: buildAndPush
      containerRegistry: $(ACR_SERVICE_CONNECTION)
      repository: $(IMAGE_REPOSITORY)
      Dockerfile: $(DOCKERFILE)
      tags: |
        $(TAG)

This stage builds your app into a Docker image using your Dockerfile and pushes the image to ACR. (See Docker@2.) For this you will have to create a Service Connection in Azure DevOps. You will need to create a ”Docker” connection with a Service Principal to connect to ACR, and then reference its name in this variable (containerRegistry). Note: If you are using a resource from Azure, simply adding the name of your container registry here will not suffice.

You also need to define the name of the container repository here that you should have already created in Azure Container Registry. The Dockerfile and tags variables are defined in vars.yml which we will introduce later.

templates/deploy-template.yml

steps:
  - task: AzureRmWebAppDeployment@4
    displayName: Deploy image to Azure App Service
    inputs:
      ConnectionType: 'AzureRM'
      azureSubscription: '$(ARM_SERVICE_CONNECTION)'
      appType: 'webAppContainer'
      WebAppName: '$(WEB_APP_NAME)'
      DockerNamespace: '$(IMAGE_REGISTRY)'
      DockerRepository: '$(IMAGE_REPOSITORY)'
      DockerImageTag: '$(TAG)'

This stage deploys the image to Azure App Service. (See AzureRmWebAppDeployment@4.) For this you will have to create another Service Connection in Azure DevOps. This time an ”Azure Resource Manager” connection that references your resource group in Azure. Prefer Workload Identity federation for better security. You will need to reference the name of this Service Connection in the variable (azureSubscription). Note: If you are using a resource from Azure, simply adding the name or id of your Azure subscription here will not suffice.

You also need to reference the web app name in Azure App Service (WebAppName), the path to the image registry (DockerNamespace) and the name of the repository (DockerRepository). The fully qualified image name will be of the format: {DockerNamespace}/{DockerRepository}:{DockerImageTag}. For example, myregistry.azurecr.io/nginx:latest.

In the deploy stage we also defined an environment, "myApp-dev". This environment is referencing the environment that you should have created in Azure DevOps. Using an environment, you may specify environment-specific variables or checks and permissions to better control the applications closer to production.

templates/vars.yml

variables:
  # Azure
  #ACR_SERVICE_CONNECTION:      Variable from variable group
  #ARM_SERVICE_CONNECTION:      Variable from variable group
  #IMAGE_REGISTRY:              Variable from variable group
  #IMAGE_REPOSITORY:            Variable from variable group
  #WEB_APP_NAME:               	Variable from variable group

  # Docker
  DOCKERFILE:                   '$(Build.SourcesDirectory)/Dockerfile'
  TAG:                        	'$(Build.BuildNumber)'


I have defined all the variables used in the application in the vars.yml file for transparency’s sake. In Azure Pipelines, you don’t need to define the environment variables used from a variable group explicitly; as long as you have referenced the variable group appropriately. For example in my case all of my Azure variables come from the variable group - that is why they are commented out. But you could as well define them in the vars.yml file. As long as they are not secrets or environment-specific.

In the variable groups you would then also define any environment variables that you use in the application, for example API keys. The variables in the vars.yml file are only for the build and deployment of the container image.

The DOCKERFILE and TAG variables are created using the predefined variables in Azure Pipelines.