Automate Let’s Encrypt certificate renewals with AWS Route53 DNS records

0 Flares Twitter 0 Facebook 0 LinkedIn 0 Email -- 0 Flares ×

I wrote already two articles about this topic. I know that managing SSL certificates can be a cumbersome task, so any option to automate the process is a great addition to any IT administrator toolbox. This is why Let’s Encrypt certificates are becoming so popular, not just because they are free but also because the automated platform that they use allow for some amazing automation solutions. In my first article Use Let’s Encrypt free certificates in Windows for Veeam Cloud Connect I explained the basics of Let’s Encrypt technology, and how to use its certificates on a Windows machine using ACMEsharp libraries with Powershell. Then, in the second article Improved Powershell script for Let’s Encrypt certificate renewals I optimized the script even more. But still, there was room for improvement and even more automation.


The limits of my previous powershell process

The limit of my actual script is in the DNS handler, that is the part where we can receive a new TXT resource record that we need to update into our DNS zone. Right now, the script use the “manual” option: once we receive in the output the new dns txt record we have to pause the script and go to our DNS server to update the Resource Record. As you may have noticed if you have used the script already, the RR name is always the same, but it has each time a new value. It changes each time a new challenge is started. Once the DNS zone has been updated, the script can be completed, but this manual step to update the DNS record is extremely annoying.

Time to find a way to automate this part too!


ACMEsharp and Route53

I’ve wasted a considerable amount of time to try and use ACMEsharp’s native “Challenge Handlers”, that are the types of challenge you can execute to complete a ownership challenge. I found initially some handlers listed among the available ones related to AWS, and that seemed promising, but in the end I was not able to make them work. Bugs, issues, really poor documentation ruined any effort I put into it.

So, I decided to switch complitely my approach, and instead of trying to find some pre-cooked solution, I chose this time to use native AWS technologies to complete this new version of the script. In the process, I also learned more about Powershell, string processing, and AWS Route53 automation!


Create a dedicated IAM user to update the DNS record

One of the most common security practice is the so-called least privilege: a user or a function should not have any additonal permission than the minimum required to complete a task. In AWS, my new procedure needs to just update one single Resource Record of a specified DNS Zone. Using the root account in AWS to do so would be a complete failure in security. Thanks to AWS IAM (Identity and Access Management) we can instead create a set of credentials with just the minimum required permissions.

For our use case, we only need: ChangeResourceRecordSets

And we also want to limit this account to the domain we want to work with, that is in my example. In AWS terms, we need the Amazon Resource Names (ARNs) to identify the hosted DNS zone. The format is this:


There’s no way (as of now) to limit the account to a specific resource record, so we need to accept the fact that this account can possibly change (and then damage) every record in the zone. There have been several requests to implement this feature (see here for example) but so far this is the highest granularity we can have.

So, the final rule in JSON format for our user is:

    "Version": "2012-10-17",
    "Statement": [
            "Sid": "VisualEditor0",
            "Effect": "Allow",
            "Action": "route53:ChangeResourceRecordSets",
            "Resource": "arn:aws:route53:::hostedzone/ZQUSU6S6339VA"


Automating the DNS record updates

As I said before, this time I decided to use native AWS tools instead of incomplete github projects. AWS has, as you can imagine, many tools to automate the interaction with their resources, and one is their Powershell modules. So, the first part of my new script goes and installs both ACMESharp and AWS. You can also note I found and adapted a nice function that checks if a module is already locally available, and in case it’s not it downloads and installs it:

 function Load-Module ($m) {

    # If module is imported say that and do nothing
    if (Get-Module | Where-Object {$_.Name -eq $m}) {
        write-host "Module $m is already imported."
    else {
        # If module is not imported, but available on disk then import
        if (Get-Module -ListAvailable | Where-Object {$_.Name -eq $m}) {
            Import-Module $m -Verbose
        else {
            # If module is not imported, not available on disk, but is in online gallery then install and import
            if (Find-Module -Name $m | Where-Object {$_.Name -eq $m}) {
                Install-Module -Name $m -Force -Verbose -Scope CurrentUser
                Import-Module $m -Verbose
            else {
                # If module is not imported, not available and not in online gallery then abort
                write-host "Module $m not imported, not available and not in online gallery, exiting."
                EXIT 1

Load-Module "ACMESharp"
Load-Module "AWSPowerShell"


I’ve also added some new checks for the Vault folder and its initialization, but those are not the topic of this post. You can see those sections in the complete script. The line where I injected the new code is after this one:

(Update-ACMEIdentifier $alias -ChallengeType dns-01).Challenges | Where-Object {$_.Type -eq "dns-01"} > challenge.txt

Before, I used some complex WinForms code to show the Challenge sent by Let’s Encrypt to the user. This time, I’m parsing the text file to extract the string I need:

 # Get the new TXT record from Let's Encrypt

$RRtext = Select-String challenge.txt -Pattern "RR Value" -CaseSensitive | Out-String -Stream
$separator = "["

$RRtext = $RRtext.split($separator)
$RRtext = $RRtext[2]
$RRtext = $RRtext.trimend("]")
$RRtext = """$RRtext"""


The text from Let’s Encrypt is always the same, and we need the string with the value of the resource record. This is clearly identified with the “RR Value” text in it. Then, with some string operations the code trims the un-needed text before and after, and adds the quotes around the text. The final result is our new TXT record in proper DNS format:


Then, we need to update the record. for this operation, we first need to configure the credentials that will be used in the script to connect to AWS. In order to make the script more secure and to do not share the credentials, those are not stored in the script. Instead we can use this code to store them in a profile:

$secret = "THISISNOTMYREALSECRETKEY" #Secret access key
Set-AWSCredential -AccessKey $access -SecretKey $secret -StoreAs awsroute53

The credentials are store in a profile, and I just need to load it each time I need to use it:

Set-AWSCredential -ProfileName awsroute53


Time to update the TXT record:

$change = New-Object Amazon.Route53.Model.Change

$change.Action = "UPSERT"
$change.ResourceRecordSet = New-Object Amazon.Route53.Model.ResourceRecordSet
$change.ResourceRecordSet.Name = ""
$change.ResourceRecordSet.Type = "TXT"
$change.ResourceRecordSet.TTL = 300
$change.ResourceRecordSet.ResourceRecords = (New-Object Amazon.Route53.Model.ResourceRecord($RRtext))

$params = @{
      ChangeBatch_Comment="Updated TXT record for with new Let'sEncrypt challenge"

Edit-R53ResourceRecordSet @params


The remaining part of the script goes on as described in the previous posts, by completing the challenge, retrieving the certificate and installing it where it’s needed.

I also realized that I only posted the script here, and I’ve never published it via GitHub. So I took the change to fix also this and you can now read the code directly here: