Skip to the content.

Let's Encrypt SSL certificate with Azure pipelines

26 May 2023

Table of Content

1- Introduction

This will be a quick one. Who never wanted to have SSL and proper certs without spending money every year for every app ? Especially for PoC, I hate using self-signed certs and get that ugly warning when showcasing the results to stakeholders. To address this, and facilitate access to SSL for people not wallet-heavy, let’s encrypt came to life. And what a life saver it is. But then again, when testing something, I’d rely on manual certificate generation (with DNS challenge), having a proper automated solution taking time and compute I don’t specially have.

But I decided to address this problem and build a simple workflow for as cheap as I can, with the tools and skills at my disposal.

This article will provide you the means to create let’s encrypt certificates and periodically renew them for a few cents per month using:

I will keep it simple and use az CLI, but you can do everything with Terraform, obviously(!).

For this project I use a subdomain for private domain. Everything below is done for service.subdomain.domain.tld. If you don’t want to use subdomain, change the scripts accordingly.

The presented solution has been designed with clustering in mind. You’ll find a pipeline variable named nodecount further down this document. This count is used to generate altnames for the certificate service.subdomain.domain.tld such as service1.subdomain.domain.tld, service2.subdomain.domain.tld.

2- The components

If you already have the required resources, go directly to 3-

2-a Azure DNS

Azure DNS is a managed service to host your DNS Zone. Very straight forward. It’s basically the same service your registrar provide you with, to setup your DNS records.

To use Azure DNS for your domain, you will need to delegate the domain management to the DNS servers provided by azure. This step is specific to your registrar, so I won’t go into detail. But if you’re here, I bet you know how to do this (otherwise your registrar should have proper documentation).

Create your Azure DNS Zone with az CLI:

# Setup a bunch of env variables
export RG=MyResourceGroup
export LOCATION=westeurope
export ZONE=toto.com

# Create an resource group if needed
az group create --name $RG --location $LOCATION

# Create the toto.com zone
az network dns zone create -g $RG -n $ZONE 

The Api will return you a json with your newly created zone. The nameServers attribute gives you the list of DNS to set up with your registrar for the zone delegation:

{
  "etag": (redacted...),
  "id": (redacted...),
  "location": "global",
  "maxNumberOfRecordSets": 10000,
  "maxNumberOfRecordsPerRecordSet": null,
  "name": "toto.com",
  "nameServers": [
    "ns1-03.azure-dns.com.",
    "ns2-03.azure-dns.net.",
    "ns3-03.azure-dns.org.",
    "ns4-03.azure-dns.info."
  ],
  "numberOfRecordSets": 2,
  "registrationVirtualNetworks": null,
  "resolutionVirtualNetworks": null,
  "resourceGroup": "myresourcegroup",
  "tags": {},
  "type": "Microsoft.Network/dnszones",
  "zoneType": "Public"
}

2-b Azure Keyvault

If you’re not familiar, Keyvault is Azure’s secret management service (passwords, keys, certificates etc.). We will use it to store the generated certificates. It has a convinient versioning feature, as well as deletion protection, that makes it an ideal candidate for this use.

# Create keyvault
export KVNAME=mykeyvault$RANDOM
az keyvault create \
  --resource-group $RG \
  --name $KVNAME \
  --enable-rbac-authorization true \
  --location $LOCATION

# Create a service principal with required role to manage secrets in Keyvault
az ad sp create-for-rbac \
  --name letsencrypt \
  --role 'Key Vault Administrator' \
  --scopes $(az keyvault show -n $KVNAME |jq -r '.id') 

# Assign DNS Zone contributor role to the service principal
az role assignment create \
  --role 'DNS Zone Contributor' \
  --assignee letsencrypt \
  --scope $(az network dns zone show --resource-group $RG --name $ZONE |jq -r '.id')

The output will give you the precious password (a.k.a client secret). Keep it, you will need it later to setup azure pipelines.

Creating 'Key Vault Administrator' role assignment under scope '/subscriptions/(redacted...)/resourceGroups/MyResourceGroup/providers/Microsoft.KeyVault/vaults/(redacted...)'
The output includes credentials that you must protect. Be sure that you do not include these credentials in your code or check the credentials into your source control. For more information, see https://aka.ms/azadsp-cli
{
  "appId": "(redacted...)",
  "displayName": "letsencrypt",
  "password": "(redacted...)",
  "tenant": "(redacted...)"
}

Be careful with RBAC and IAM and as possible, always try to apply the least privilege strategy. The Above examples are good for testing but may not be acceptable for production environments.

2-c Acme.sh

Acme.sh is an ACME protocol client in shell language for bash, dash and sh. Everything you need to know is here: acme.sh.
The base usage is an http/https challenge. But you probably don’t (or don’t want to) have compute running for your ACME challenge. The script includes a manual validation using DNS verification instead long running service using letsencrypt API for http/https/dns automatic renewal. That’s the method we are going to leverage, but automated (scheduled) using azure pipeline. Thus we don’t keep compute running.

2-d Azure Devops (pipelines)

Azure Devops is a suite of managed services for application development. It includes work items, boards, wiki, but also git repositories and pipelines.
We will be using the latter. Azure pipeline allow you to run tasks and jobs automatically based on specific triggers (like PR, commits, schedules, etc.).

More about Azure Pipelines here.

If you don’t already have an Azure Devops organization, go create one for free (5 basic licenses included), and create a project.

3- Azure pipeline

3-a Azure Pipeline Definition file (yaml)

Azure Build pipelines rely on yaml definition files. Push the following file in a repository in your azure devops project, then go to next section to create the pipeline itself.

trigger:
- none

schedules:
- cron: '0 0 1 */2 *'
  displayName: Renew every two months
trigger:
- none

schedules:
- cron: '0 0 1,15 * *'
  displayName: Renew every 1st and 15th of the month
  branches:
    include:
    - main

variables:
  - group: 'static'
  - name: ARM_SUBSCRIPTION_ID
    value: '<your_sub_id>'
  - name: ARM_TENANT_ID
    value: '<your_tenant_id>'
  - name: keyvault
    value: '<your_keyvault_name>'
  - name: resourcegroup
    value: '<your_keyvault_resourcegroup_name>' #using same rg for dns zone and keyvault

pool:
  vmImage: ubuntu-latest

steps:
- task: Bash@3
  inputs:
    targetType: 'inline'
    script: |
      git clone https://github.com/acmesh-official/acme.sh.git
      export DOMAIN=$(domain)
      export SUBDOMAIN=$(subdomain)
      export RECORD=$(record)
      export NODECOUNT=$(nodecount)
      export KVCERTNAME=${RECORD//[-._]/}
      if [ $NODECOUNT>0 ]
      then
        NODE=1
        until [ $NODE -gt $NODECOUNT ]
        do
          ALTRECORDS="$ALTRECORDS -d $RECORD$NODE.$SUBDOMAIN.$DOMAIN"
          ((NODE++))
        done
      fi
      export TXT_ARRAY=($(./acme.sh/acme.sh --set-default-ca --server letsencrypt --issue -d $RECORD.$SUBDOMAIN.$DOMAIN $ALTRECORDS --dns --yes-I-know-dns-manual-mode-enough-go-ahead-please | grep -Po 'TXT value: \K.*'))

      az login --service-principal --username $(AZUREDNS_APPID) --password $(AZUREDNS_CLIENTSECRET) --tenant $ARM_TENANT_ID
      az account set -s $ARM_SUBSCRIPTION_ID
      az network dns record-set txt create -g $(resourcegroup) -z $DOMAIN -n _acme-challenge.$RECORD.$SUBDOMAIN
      az network dns record-set txt add-record -g $(resourcegroup) -z $DOMAIN -n _acme-challenge.$RECORD.$SUBDOMAIN -v ${TXT_ARRAY[0]:1:-1}

      NODE=1
      until [ $NODE -gt $NODECOUNT ]
      do
        az network dns record-set txt create -g $(resourcegroup) -z $DOMAIN -n _acme-challenge.$RECORD$NODE.$SUBDOMAIN
        az network dns record-set txt add-record -g $(resourcegroup) -z $DOMAIN -n _acme-challenge.$RECORD$NODE.$SUBDOMAIN -v ${TXT_ARRAY[$NODE]:1:-1}
        ((NODE++))
      done

      ./acme.sh/acme.sh --renew -d $RECORD.$SUBDOMAIN.$DOMAIN $ALTRECORDS --yes-I-know-dns-manual-mode-enough-go-ahead-please
      
      openssl pkcs12 -export -out $RECORD.$SUBDOMAIN.$DOMAIN.pfx -inkey $HOME/.acme.sh/$RECORD.$SUBDOMAIN.$DOMAIN'_ecc/'$RECORD.$SUBDOMAIN.$DOMAIN.key -in $HOME/.acme.sh/$RECORD.$SUBDOMAIN.$DOMAIN'_ecc/'fullchain.cer -passout pass:
      
      az keyvault certificate import --vault-name $(keyvault) -n $KVCERTNAME -f $RECORD.$SUBDOMAIN.$DOMAIN.pfx
      
      NODE=1
      until [ $NODE -gt $NODECOUNT ]
      do
        az network dns record-set txt delete -g $(resourcegroup) -z $DOMAIN -n _acme-challenge.$RECORD$NODE.$SUBDOMAIN --yes
        ((NODE++))
      done      
      az network dns record-set txt delete -g $(resourcegroup) -z $DOMAIN -n _acme-challenge.$RECORD.$SUBDOMAIN --yes

3-b Azure Pipeline Setup

Now that your definition file is pushed in your repository, you can go to Azure Devops to finalize the pipeline.

Create a variable group

First create a variable group in the pipeline library.
In the yaml, we referenced a variable group named static, so let’s create it.

variables:
  - group: 'static'
  - name: ARM_SUBSCRIPTION_ID
    value: '<your_sub_id>'
  - name: ARM_TENANT_ID
    value: '<your_tenant_id>'
  - name: keyvault
    value: '<your_keyvault_name>'
  - name: resourcegroup
    value: '<your_keyvault_resourcegroup_name>' #using same rg for dns zone and keyvault

Click on Library
vrariable group

Click on + Variable group
vrariable group

Name it static then create the following two environment variables. Now is the time to paste the password that was output when you created the service princiap in 2-. Be careful to use the same names that are defined in the shell script in the yaml file (AZUREDNS_APPIDand AZUREDNS_CLIENTSECRET).
Be sure to hit the lock button to hide the secret after creation.
vrariable group

Create a pipeline

Now we can create the pipeline from the yaml file. Go to the pipeline section and select Pipelines
pipeline

Then click on the New Pipeline button
pipeline

Select Azure Repos Git as source for your file
pipeline

then choose Existing Azure Pipelines YAML file
pipeline

In the next window, select the yaml file to be used in the drop down menu
pipeline

Don’t touch it yet, just select Save in the Run drop down menu.
pipeline

Your pipeline has been created and with the scheduled block, it will try running automatically according to the cron strategy configured. However, it’s not ready yet.

add variables to pipeline

Click Edit button
pipeline

Then Variables
pipeline

You surely noticed the bit below in the yaml definition file:

(Redacted...)
      export DOMAIN=$(domain)
      export SUBDOMAIN=$(subdomain)
      export RECORD=$(record)
      export NODECOUNT=$(nodecount)
(Redacted...)

These variables need to be defined at pipeline level. That way, using the same yaml as source, you will be able to create multiple service certificates, creating a new pipeline for each new certificate.

So you need to set those variables value so the certificate can be created
pipeline

4- Conclusion

That’s a wrap.
You can run manually the first time, then, the pipeline will automatically run according to the cron settings; remember letsencrypt certs lasts 90 days.

You now have a programatic renewal for your certs without the need to keep a long running workload. Azure Devops gives you 1000 minutes of free run per month, Azure DNS and Keyvault pricing are mostly request based and will costs you a few cents, maybe 1-2€, if you start using it regularly. In any case, this is far more cost effective than going through a well know CA for your SSL.

Be wary of rate limit with letsencrypt. Don’t regenerate the same cert too much or you’ll reach a threshold and be block for the next 168 hours.