Build Your Own Certificate Expiry Notifications

Many of us use Let's Encrypt as a Certificate Authority (CA). One of the useful things they do is to send you notification emails if your certificate approaches its expiry date and hasn't been renewed. This email acted as a warning that something had gone wrong with certificate renewal, giving you an opportunity to fix that something before it was too late.

Unfortunately those Expiration Notification Emails will no longer be sent. If you want to read the official statement, you can find it here. Basically it just became too resource intensive to justify for a free service. And who can blame them. There's a lot of junk out there.

Don't panic, you have until the 4th of June 2025 to find an alternative.

What Are The Options Now?

Let's Encrypt recommend a provider in their official statement and I'm sure others will emerge as we get closer to the service shut off date. But you're here and like me, you think to yourself "surely I can build something to do this?". So I'll describe two ways you can monitor your certificates with as little effort as possible. The first design is actually more a primer than the full solution, but this might be enough for many readers. The second design is more complete and ultimately more hands off. It also uses free services and free is good.

Option 1. A Self Hosted Script

If you just want to have a script to check your sites and report back their current status, this is the solution for you.

The basics

  • We want to check our sites and see when their certificates expire.
  • At a predetermined period, let's say 30 days from expiry, we want to generate an alert if the certificate has not been renewed.

Put another way, if a certificate has less than 30 days until expiry, something has gone wrong and we want to know about it.

Checking the remaining validity of a certificate

Checking almost any certificate value, including the expiry date, is reasonably straightforward with openssl. Here I'll use openssl to connect to a website and collect the certificate information, then I'll redirect the output and use openssl again, this time to extract the enddate value.

1WEBSITE="kalfeher.com"
2openssl s_client -servername ${WEBSITE} -connect ${WEBSITE}:443 </dev/null 2> /dev/null|\
3openssl x509 -enddate -noout
1notAfter=Apr 14 00:51:16 2025 GMT

We could subtract the response time from a date 30 days into the future to see if the certificate has reached our warning threshold. But since openssl already has an option to do that, let's not reinvent the certificate checking tool.

The openssl x509 -checkend command requires that you supply the number of seconds into the future that will be checked for certificate expiry. If the certificate will expire inside the period you supplied, the command will print "Certificate will expire" and returns an exit status of '1'. If the certificate will not expire inside the provided period, the command will print "Certificate wil not expire" and exits with a status of '0'.

 1WEBSITE="kalfeher.com"
 2# will my website's cert expire in the next 90 seconds?
 3openssl s_client -servername ${WEBSITE} -connect ${WEBSITE}:443 </dev/null 2> /dev/null|\
 4openssl x509 -checkend 90 -noout
 5Certificate will not expire
 6
 7echo $?
 80
 9
10# will my website's cert expire in the next 8640000 seconds (100 days)?
11openssl s_client -servername ${WEBSITE} -connect ${WEBSITE}:443 </dev/null 2> /dev/null|\
12openssl x509 -checkend 8640000 -noout
13Certificate will expire
14
15echo $?                 
161
HTTPS records not supported

At the time I'm writing this openssl s_client will not resolve an endpoint using the HTTPS (SVCB) DNS records. Refer to the README file in the repo for some suggestions if this impacts you.

Scripting a check

So now that we have the basics of how to check for certificate expiry, we can write a function to do it for us. Since the intent is to provide early warning of a possible failed renewal process, we want to supply some useful information to whomever will read this alert as part of our output.

You can find the full script in my repo, but here is the important part.

I've left the MacOS compatible date conversion line in the script below to allow you to run the script on your pc without change. Just swap the UXEXPIRY= lines to use the Linux date format.

 1function checkexpiry ()
 2{
 3  EXPIRY=$(openssl s_client -servername ${WEBSITE} -connect ${WEBSITE}:443 </dev/null 2> /dev/null| openssl x509 -enddate -noout | cut -d= -f2)
 4  # MacOs date conversion
 5  UXEXPIRY=$(date -j -f "%b %d %H:%M:%S %Y %Z" "$EXPIRY" +%s)
 6  # Linux date conversion 
 7  #UXEXPIRY=$(date -d "$EXPIRY" +%s)
 8  WILLEXP=$(openssl s_client -servername ${WEBSITE} -connect ${WEBSITE}:443 </dev/null 2> /dev/null| openssl x509 -checkend ${EXP} -noout)
 9  if [[ $WILLEXP == *"will expire"* ]]; then
10          echo "ALERT|${WEBSITE}|${EXPIRY}|${UXEXPIRY}"
11          exit 1
12  else
13          echo "OK|${WEBSITE}|${EXPIRY}|${UXEXPIRY}"
14          exit 0
15  fi
16}

In the function above, we collect the expiry date as well as check for validity period. This allows us to supply some contextual information to the output.

Hosting and notification

So far, the commands I've shown in this post and the cert-exp-check.sh included in the blog repo will check a single domain and output the results of the check into stdout.

1./cert-exp-check.sh kalfeher.com 30
2OK|kalfeher.com|Apr 14 00:51:16 2025 GMT|1744591876

What we really want is something that runs regularly, probably once a day. We want the script to live on a host that is always available or at least reliably available on a schedule. The host will also need to have a reliable network path to all sites, which could mean both internal only and Internet accessible sites.

To assist with scheduling your check, I've included some example systemd files for the gen Z'ers and an example cron file for the socks and sandals crew. Both sets of examples can be found in the repo for this post.

You could rely on either cron or systemd triggering an action if the script returns non zero, in order to be alerted. However I recommend something a little more robust that reviews the logs of the script on a regular basis and contacts you if any domains are listed with an ALERT status.

The downside of Self Hosting

The thing about self hosting monitoring of any kind, is that you end up having to monitor the monitor. Then you have to monitor the monitor of the monitor. It also costs money. You can save money by using systems you already have, but then you are back to monitoring your monitor. There's also more parts to this solution:

  1. Script scheduling
  2. The check script
  3. Reachability from the script to your sites
  4. Reliable notification if a certificate is approaching expiry

Each of the components above will require some amount of attention from you, which will be ongoing, forever. For many of my readers that attention is ok because its a hobby or fun to do and it won't be that much time compared to other activities. That's why I've included this option here. But the Let's Encrypt Email notice did not require any attention (from us at least). So this solution doesn't exactly replace that service like for like.

Option 2. Serverless

The serverless approach will ultimately mean we can deploy a monitoring service and forget about it once it's set up. You might still need to make changes over time as you add new websites or update who receives warning notices. But you won't need to invest as much of your attention ensuring everything is working properly. Also with the exception of AWS ECR (~2 cents/mth), everything described here will fall under the AWS Free tier.

I'm going to show you how to deploy a container to AWS Lambda to do the monitoring for you. I think you could achieve something very similar on Azure, GCP, Linode or other cloud service providers.

The basics again

There's no real change to our goals from those listed in the Self Hosted option.

  • We want to check our sites and see when their certificates expire.
  • At a predetermined period, let's say 30 days from expiry, we want to generate an alert if the certificate has not been renewed.

We'd also like to recover from any transient failures in checking or notification.

Storing the image

We'll first create an ECR repository to store the container image that will be used for the Lambda function. Lambda only supports private repositories, so that's what we'll create.

1# on your pc
2aws ecr create-repository \
3    --repository-name cert-expiry-checker

Note down the value of "repositoryArn": from the output. You'll need it for the next step.

The container image

To keep things simple, we'll use a bash script to do the certificate checking and to generate the notification if the certificate validity falls within our warning period.

  • We'll configure the script at runtime with the domains we want to check and the validity period we want to check for
  • We'll also provide the SNS topic to which we want to send notices

Here is the function the container will execute:

 1# function.sh
 2function handler () {
 3  EVENT_DATA=$1
 4  SITES=$(echo $EVENT_DATA | sed 's/\\//g' | jq ".sites[]"| tr -d '"')
 5  DAYS=$(echo $EVENT_DATA | sed 's/\\//g' | jq ".days"| tr -d '"')
 6  SNSTOPIC=$(echo $EVENT_DATA | sed 's/\\//g' | jq ".snstopic"| tr -d '"')
 7  EXP=$(( 86400 * $DAYS ))
 8  NOW=$(date +%s)
 9  DEADLINE=$(( $NOW + $EXP ))
10  MESSAGE=""
11  while IFS= read -r site; do
12    WEBSITE=$site
13    EXPIRY=$(openssl s_client -servername ${WEBSITE} -connect ${WEBSITE}:443 </dev/null 2> /dev/null| openssl x509 -enddate -noout | cut -d= -f2)
14    TSTAMP=$(date -d "$EXPIRY" +%s)
15    if [ "$TSTAMP" -lt "$DEADLINE" ]; then
16      RESPONSE="ALERT|${WEBSITE}|${EXPIRY}|${TSTAMP}"
17      MESSAGE="${MESSAGE}${WEBSITE} Expires: ${EXPIRY} " 
18    else
19      RESPONSE="OK|${WEBSITE}|${EXPIRY}|${TSTAMP}"
20    fi
21    echo $RESPONSE 1>&2
22  done <<< "$SITES"
23if [ -z "$MESSAGE" ]; then
24  RESULT="No Certificates Expiring"
25else
26  RESULT="Certificates Expiring: ${MESSAGE}"
27aws sns publish \
28  --topic-arn "${SNSTOPIC}" \
29  --subject "ALERT | Expiring Certificates" \
30  --message "Certificates for the following sites will expire in the next ${DAYS} days: ${MESSAGE}"
31fi
32  RESULT="${RESULT}- Check Completed: OK"
33  echo $RESULT
34}

Unlike our earlier script, we can't use the -checkend option because a non zero signal inside our function will cause the container to immediately exit. Therefore we have to use timestamp comparison:

1if [ "$TSTAMP" -lt "$DEADLINE" ]; then

Since Lambda functions use stderr to send output to logs, we redirect our log messages using 1>&2.

1echo $RESPONSE 1>&2

In a previous post I explained how to build and deploy Bash scripts to Lambda. I recommend reading that to understand the full build process. Use the "repositoryArn": value from the previous step to upload the container to the correct location.

SNS for notifications

We'll use SNS to send notifications when we detect that domains are about to expire.

1# on your pc
2aws sns create-topic --name certificateWarningsMyOrg

Note down the value of TopicArn from the output.

We'll subscribe someone to the list for email notification using the TopicArn value from earlier. You can update subscribers at any time.

1# on your pc
2aws sns subscribe --topic-arn arn:aws:sns:nn-moonbase-1:1234567890:certificateWarningsMyOrg --protocol email --notification-endpoint certificates@mydomain.example

You'll need to validate the subscription by replying to the confirmation message sent from AWS.

Deploy the container to Lambda

We need to create the Lambda function.

When defining the function, you will need to select the image option.
You will then need to specify the URI to the container you uploaded to ECR earlier.
Be sure to choose the correct architecture for the Lambda function. The examples in the repo all assume Graviton instances, so that's what we should select in the Lambda function definition.

Let Lambda publish to SNS

We need to allow the function to publish to the SNS topic. To do that, we create a very simple IAM policy:

 1{
 2  "Version": "2012-10-17",
 3  "Statement": [
 4      {
 5          "Effect": "Allow",
 6          "Action": "sns:Publish",
 7          "Resource": "arn:aws:sns:nn-moonbase-1:1234567890:certificateWarningsMyOrg"
 8      }
 9  ]
10}

And add that to our Lambda Functions execution role.

Scheduling and input

The function doesn't have any domains, warning periods or SNS topics defined within its code. So we'll need to supply those as input to the function. The function expects to receive these parameters as json because that's how all input is delivered to Lambda. We will schedule the Lambda function by creating an EventBridge Schedule.

We want a recurring schedule.
The cron schedule type makes the most sense for our use case.
Once you have entered the expression you'll see the next 10 invocation times. Check these to make sure it's what you expect. Pay attention to the timezone.
After selecting Lambda as the target for the schedule, you'll have the opportunity to configure the payload. You can update this value at any time.
Just in case our function fails for any reason, it makes sense to retry the invocation. While automatically retrying allows us to have some resilience to transient failures, I like keep my retry attempts to a small value. That way I don't get nasty usage surprises if something is fundamentally wrong with the function causing it to always fail.

The serverless approach

Most of the services I've described have very generous Free Tier allowances which are well above what the certificate expiry function will consume. Even with hundreds of certificates you shouldn't hit any Free Tier limits. The only service that will cost you money is ECR. Based on the current size of the container, expect to pay 2 or 3 cents USD a month if you store just the latest container image in ECR.

There's no doubt that the serverless approach will be conceptually more complicated for many people when compared to simply scheduling a script in cron. It's true that more moving parts will be required for the self hosted solution. But to many readers, conceiving those moving parts will be more intuitive because of your experience. So go with the option you feel most comfortable with.

The warning period

In my examples I've stuck with 30 days until expiry as the threshold for generating an alert. I strongly recommend that you do not hard code this. The general trend amongst browser and operating system developers is to prefer shorter and shorter certificate validity periods. Later this year Let's Encrypt will offer certificates with just 6 days of validity.

If renewal lengths have shortened, you may hit the 30 day threshold before you even attempt to renew. Generating alerts during normal operations will fatigue anyone receiving them and make the warnings more likely to be ignored. Therefore be prepared to lower the number of warning days in the future. Naturally this will also give you less time to fix a failed certificate renewal.

Website Monitoring Services

There's website monitoring services that will check your certificates for you. The Let's Encrypt recommended provider will monitor 250 certificates for free. Although the warning period appears to be locked to 7 days. Other providers had free tiers, but those seemed to exclude certificate expiry checks, which were in the paid tiers instead.

Get the code

You can find both the simple bash script and the Lambda container definition in my blog_code repo. I've also included the json for the IAM policy to add to the Lambda execution role, allowing it to publish to your SNS topic. Naturally you will have to update the ARN to the relevant value.

Missing step

If I've missed a step or you don't understand what value to put in place of an example I've included, then don't be afraid to open an issue or just ask me @kalfeher@infosec.exchange.