United Kingdom: +44 (0)208 088 8978

Continuous integration and deployment – part 2

In part two of his introduction to CI / CD practices, Ryan walks through the process of building a simple deployment pipeline using the SAFE template and Farmer with Azure DevOps.

We're hiring Software Developers

Click here to find out more

In the first part of this blog I gave a high level overview of continuous integration and deployment.

We saw that being able to '1 click deploy' an entire application along with its infrastructure allows teams to respond rapidly to change and act upon feedback, shortening the loop between developers and end users and ultimately delivering a better product.

This time we are going to show how to set up automatic web application deployment using SAFE Stack, Farmer and Azure Pipelines.

In the interests of brevity, I will work on the assumption that you have already

Prepare the build script

Open up your SAFE app and navigate to the Build.fs module in the Build project.

Somewhere near the top, add the line

let artifactDir = Environment.environVarOrDefault "artifact-dir" "deploy"

This will allow us to pass in a custom bundle path from the pipeline.

Next, find the Azure target function and change

let web = webApp {
    name "SAFEPipelines" // Must be unique across all of Azure
    zip_deploy "deploy"


let web = webApp {
    name "SAFEPipelines"
    zip_deploy artifactDir // Using the environment var we just loaded

Finally, at the bottom of the file, change

    ==> "InstallClient"
    ==> "Bundle"
    ==> "Azure"


    ==> "InstallClient"
    ==> "Bundle"

This prevents the project from being re-bundled when you run the Azure deploy later.

Install the .NET Test SDK and Expecto adapter

In the root of the solution run the commands

dotnet tool restore

dotnet paket add Microsoft.NET.Test.Sdk -p Server.Tests
dotnet paket add YoloDev.Expecto.TestSdk -p Server.Tests

Open up your tests/Server.Tests/Server.Tests.fsproj file and add the element <GenerateProgramFile>false</GenerateProgramFile> to the first PropertyGroup. This prevents the Test SDK throwing an error related to project entry points.

Don't forget to push these changes up to your repository.

Connect your DevOps project to Azure

Open your DevOps project and select Project settings from the bottom of the left flyout menu.

From the Project settings menu, select Service connections and click New service connection.

From the popout menu, select Azure Resource Manager and then click next to select Service principal (automatic).

On the following screen, choose your Azure sub from the dropdown and give the service connection a name.

You don't need to select a Resource Group, this is used if you want to restrict access for the connection.

Tick Grant access for all pipelines and hit Save.

Create an Environment

Visit the Pipelines - Environments section of DevOps and click Create environment.

Name it CD, leave everything else at the defaults and click Create.

Create a pipeline

Open your project in Azure DevOps and select the Pipelines - Pipelines section.

If this is the first pipeline you have create in your organisation, you may be required to request access from Microsoft which can take a short while to be granted. This check was introduced in 2021 to combat crypto mining bots.

You will be prompted to connect your repository, and then given a choice of pipeline template. For this project, select 'Starter Pipeline'.

You will be presented with a YAML (.yml) file which is set up to run a 'Hello World' script.

There are many, many ways to configure pipelines depending on your use case and personal preferences, this is just one example. Feel free to experiment!

Replace it with the following:

  vmImage: 'windows-latest' # Use a Windows build host 

  isMain: $[eq(variables['Build.SourceBranch'], 'refs/heads/main')] # Set a variable to detect if we are on the main branch
  appArtifact: "app" # Set a variable which is used to name the published app


- stage: CI # This stage just builds and tests the app. It runs on every push to every branch.
  displayName: CI
  - job: Build
    displayName: Build and publish artifact
    - script: dotnet tool restore
      displayName: Restore dotnet local tools
    - script: dotnet paket restore
      displayName: Download nuget packages 
    - script: dotnet restore MyApp.sln # Set your solution name here
      displayName: Restore nuget packages 
    - script: npm install 
      displayName: Restore npm packages
    - task: MSBuild@1
      displayName: 'Build Server.Tests project'
        solution: 'tests/Server/*.fsproj'
        msbuildArchitecture: 'x64'
        clean: true
        restoreNugetPackages: false
    - task: VSTest@2 # Requires that the project has the .net test adapter installed (see earlier in the article)
      displayName: Run Server tests
        testSelector: 'testAssemblies' 
        testAssemblyVer2: 'tests\Server\bin\Debug\net5.0\Server.Tests.dll'
    - script: dotnet run Bundle # Create the app bundle
      displayName: Bundle app
    - publish: deploy # Publish the bundle as a pipeline artifact
      artifact: $(appArtifact)

- stage: CD  # This stage picks up the artifact from the CI stage and publishes it to Azure, along with the infrastructure
  dependsOn: CI
  condition: and(succeeded(), eq(variables.isMain, true)) # Only run if CI stage succeeded *and* we are on the main branch
  displayName: CD
  - deployment: ContinuousDeploy
    displayName: Deploy to dev test environment
    environment: CD # The environment we created earlier
          - download: current # Download the artifact from the previous stage. 
            artifact: $(appArtifact)
          - checkout: self # This stage also needs access to the repo to run the deploy script
          - task: AzureCLI@2 # The Azure CLI task allows publishing resources to Azure
              azureSubscription: 'My Subscription' # Replace with your sub name
              scriptType: 'batch'
              scriptLocation: 'inlineScript'
              inlineScript: | # Run the deploy script, passing the pipeline artifact directory as our environment var
                set artifact-dir=$(Pipeline.Workspace)/app
                dotnet tool restore
                dotnet run Azure
              workingDirectory: '$(Build.SourcesDirectory)'

Save and run

Once you save and run the pipeline, a build / deploy cycle will be started.

The .yml file will be saved in the root of your repository. It can be edited at any time in a text editor and pushed up using Git.

You can visit the Pipelines section of DevOps, select your pipeline and then select the active run to monitor its progress.

The first time you get to the CD stage, you will be required to click a couple of buttons to allow access to the Azure subscription and the CD environment.

If your build or deploy fails, you can click around to find out which stage it failed at and why.

Setting up a pipeline like this, especially in more complex projects, can require a lot of patience as you slowly work through one issue at a time. However, once you get it all running you rarely need to touch it again, so persevere!


I hope this has been a useful whistle-stop tour of CI / CD with Azure Devops. The principles applied here should also generalise to most similar services.

As I mentioned earlier, this has been a very limited view at what is possible, and you can pretty much configure any process that you might imagine - for example, we often set up multiple environments for testing and production and publish to them consecutively.

If you aren't already embracing automation, perhaps you may now have a clearer understanding of the benefits and feel empowered to give it a shot. It is a great investment in terms of software quality, time saving and therefore ultimately developer and client happiness.

There is a full example solution on Github (although you will need to tweak it to use your own Azure sub etc of course).