United Kingdom: +44 (0)208 088 8978

Virtual Machines and SSL with Azure and Farmer

Ryan is back with a handy guide to managing your TLS / SSL certificates on Azure virtual machines using Farmer and Powershell with Posh-ACME.


When deploying a modern web application, it is common to choose a 'platform as a service' (PAAS), such as Azure App Service or AWS Lambda.

This approach brings many benefits, mostly due to the fact that you are freed from the burden of maintaining the environment in which your app is running.

It also makes staging, scaling and access management much simpler.


Another approach, more akin to the traditional on-premises model, is 'infrastructure as a service' (IAAS).

Here, we provision a virtual machine. We are responsible for installing and updating the software that it runs, and controlling port access etc.

Having a machine in the cloud is much more reliable than hosting on-premises, and there are many tools in Azure to help manage traffic and prevent downtime in the event of outages, such as Traffic Manager and Availability Sets.

There is, however, a non-trivial amount of overhead in configuring and maintaining your own virtual machine.

For this reason, it is highly desirable to script and automate as much of this process as possible.

Not only does this save you time and prevent mistakes, but it also allows you to back up, version control and quickly replicate your infrastructure.

Azure Virtual Machines 💖 Farmer

I recently worked with a client to take their complex, hand-crafted-in-the-portal infrastructure and replicate it using Farmer.

This would give them redundancy, repeatability, and the option to quickly spin up new instances when testing changes and addressing new markets.

The project presented many challenges, and as a result we worked together to extend and enhance Farmer's capabilities in several areas.

These included, amongst many others,

That last one was a key part of addressing one of the most challenging requirements - the ability to automatically install and renew SSL certificates on the VM without requiring interaction from the system administrator.

TLS/SSL installation and renewal

Transport layer security

If you want to have a https website, which guarantees that data hasn't been tampered with between the sender and the recipient, you must have a certificate to prove that you are who you say you are.

Azure App Service can set up and manage these certificates for you automatically, but if you are using a virtual machine then you need to handle the process yourself.

Many 'certificate authorities' charge a fee to issue certificates. There is however a free option.

Let's Encrypt

Let's Encrypt are, in their own words, "a nonprofit certificate authority providing TLS certificates to 260 million websites".

They provide an API called ACME (Automated Certificate Management Environment) which allows you to request and renew certificates.

This API is pretty complex, and so they recommend you use a dedicated SDK, or 'ACME Client' to interact with it.

There are many to choose from. I went with Posh-ACME as it has great documentation, runs in Powershell and works well with Azure.

Proving you own the domain

In order to prove that you own the domain that you want a certificate for, Let's Encrypt provide a string which you must return from a designated endpoint within a short timeframe.

In order to set a DNS record in Azure, a user must have been assigned a DNS Zone Contributor role.

Because we want to administrate the certificates without user interaction, we must allow Azure to assign an identity to the VM itself ('system assigned identity'), and then assign the contributor role to that identity.

Finally, we must execute a Powershell script on the VM which will use Posh-ACME's Azure plugin to request a certificate, install it, and renew it using the Windows Task Scheduler.

Putting it all together

Farmer resources for VM + Identity + Role Assignment, plus executing the Powershell script on the deployed VM.

Note - you will need high level access to the Azure sub to be able to run this script, as if you can assign roles you can basically do anything!

open Farmer
open Farmer.Builders
open System.Management.Automation

let vmName = ""
let vmUsername = ""
let dnsZoneName = ""
let deployName = ""
let installScriptName = ""
let cert_contact_email = ""

let createRoleName (resourceName : string) (principleId : PrincipalId) (roleId : RoleId) =
    |> DeterministicGuid.create // copied from https://github.com/CompositionalIT/farmer/blob/dc1ca71cff8f6739baeca816e98e241bcdec2490/src/Farmer/Types.fs#L298
    |> string
    |> ResourceName

let dns = dnsZone {
    name dnsZoneName

let virtualMachine = vm {
    name vmName
    username vmUsername
    operating_system Vm.WindowsServer_2019Datacenter

let roleAssignment : Arm.RoleAssignment.RoleAssignment =
    let dnsId = (dns :> IBuilder).ResourceId
    { Name = createRoleName virtualMachine.Name.Value virtualMachine.SystemIdentity.PrincipalId Roles.DNSZoneContributor
      RoleDefinitionId = Roles.DNSZoneContributor
      PrincipalId = virtualMachine.SystemIdentity.PrincipalId
      PrincipalType = Arm.RoleAssignment.PrincipalType.MSI
      Scope = Arm.RoleAssignment.AssignmentScope.SpecificResource dnsId
      Dependencies = Set.ofList [ virtualMachine.ResourceId; dnsId ] }

let deployment = arm {
    location Location.UKSouth
    add_resource roleAssignment
    add_resource virtualMachine
    add_resource dns

|> Deploy.execute deployName Deploy.NoParameters
|> ignore

let subId =
    match Deploy.listSubscriptions() with
    | Ok subs ->
        |> Array.find (fun s -> s.IsDefault)
        |> fun s -> string s.ID
    | Error e -> failwithf "AZ Sub not found: %s" e

let installVMCmd = $"Set-ExecutionPolicy -ExecutionPolicy Unrestricted -Scope Process; Install-Module -Name Az.Compute -Scope CurrentUser -Force -AllowClobber; Import-Module Az.Compute;Invoke-AzVMRunCommand -ResourceGroupName '{deployName}' -Name '{virtualMachine.Name.Value}' -CommandId 'RunPowerShellScript' -ScriptPath '{installScriptName}' -Parameter @{{azSubscriptionId='{subId}';domainName='{dnsZoneName}';contactEmail='{cert_contact_email}'}}"

|> printfn "%O"

Powershell script for SSL install / renewal

# Passed in on command line
param ($azSubscriptionId, $domainName, $contactEmail)

# You could of course also use this script to do any other setup you need to on the VM such as install software

# Install and import Posh-ACME for current user
# You can install for all users, but that requires elevated permissions
Install-Module -Name Posh-ACME -Scope CurrentUser -Force
Import-Module Posh-ACME

# Pick a certificate server.
Set-PAServer LE_PROD # Use LE_STAGE when testing

# Create a new certificate and install it
$pArgs = @{
  AZSubscriptionId = $azSubscriptionId
  AZUseIMDS = $true # This is the switch that tells PoshAcme to use managed system identity when creating the TXT record.

New-PACertificate $domainName -AcceptTOS -Contact $contactEmail -Plugin Azure -PluginArgs $pArgs -Install

# Set up certificate renewal task
$taskAction = New-ScheduledTaskAction -Execute 'powershell.exe' -Argument '-command "if ($cert = Submit-Renewal) { Install-PACertificate $cert }"'
$taskDescription = "Try to renew the SSL certificate from Let's Encrypt using Posh-ACME"

# Try to renew twice a day - PoshAcme won't actually request from Let's Encrypt unless due for renewal
# Don't use on-the-hour to avoid peak Let's Encrypt traffic times
$taskTriggerAM = New-ScheduledTaskTrigger -Daily -At 3:42AM
$taskTriggerPM = New-ScheduledTaskTrigger -Daily -At 3:42PM
$taskNameAM = "Renew SSL AM - $domainName".Replace("*", "")
$taskNamePM = "Renew SSL PM - $domainName".Replace("*", "")

# Make sure renewal task is run as current user, whether logged in or not
# Current user must be used as renewal details are encrypted under their account
$user = "$env:UserDomain$env:UserName"
$credentials = Get-Credential -Credential $user
$password = $credentials.GetNetworkCredential().Password

# Register renewal with Windows Task Scheduler
try {
 Register-ScheduledTask -TaskName $taskNameAM -Action $taskAction -Trigger $taskTriggerAM -Description $taskDescription -User $user -Password $password
 Register-ScheduledTask -TaskName $taskNamePM -Action $taskAction -Trigger $taskTriggerPM -Description $taskDescription -User $user -Password $password
catch {
  Write-Host "Renew task not created:"
  Write-Host $_