AWS Certificate Manager (ACM) makes certificate provisioning easy enough that developers sometimes forget certificates can still expire. They can. In the cases where auto-renewal fails — which happen more often than the documentation implies — you get no warning until a browser shows the Your connection is not private error to real users.
This guide covers every scenario where SSL certificate expiry becomes your problem on AWS, and how to monitor each one.
ACM will automatically renew certificates it provisions, but only when these conditions hold:
ACM sends email reminders to the AWS account’s contact email at 45, 30, and 7 days before expiry — but only for certs with email validation. DNS-validated certs that fail renewal get no proactive alert until 45 days before expiry, and only if ACM’s renewal attempt has already started failing.
ACM never automatically renews certificates that you imported from an external CA (Let’s Encrypt, DigiCert, Sectigo, or a self-signed CA). Imported certs are the highest-risk category because they look identical in the ACM console to managed certs, and developers sometimes assume ACM handles renewal for all certs it stores.
Check your imported certs with the AWS CLI:
aws acm list-certificates \
--certificate-statuses ISSUED \
--query "CertificateSummaryList[?Type=='IMPORTED'].[CertificateArn,DomainName]" \
--output table
Get the expiry for each imported cert:
aws acm describe-certificate \
--certificate-arn arn:aws:acm:us-east-1:123456789:certificate/abc123 \
--query "Certificate.[NotAfter,DomainName,RenewalEligibility]" \
--output table
A quick script to find all imported certs expiring within 60 days:
#!/bin/bash
REGION=${AWS_REGION:-us-east-1}
THRESHOLD_DAYS=60
THRESHOLD_TS=$(date -d "+${THRESHOLD_DAYS} days" +%s 2>/dev/null \
|| date -v+${THRESHOLD_DAYS}d +%s) # Linux/macOS compat
aws acm list-certificates \
--region $REGION \
--certificate-statuses ISSUED \
--query "CertificateSummaryList[].CertificateArn" \
--output text | tr '\t' '\n' | while read arn; do
info=$(aws acm describe-certificate --region $REGION \
--certificate-arn "$arn" \
--query "Certificate.[DomainName,NotAfter,Type]" \
--output text)
domain=$(echo "$info" | awk '{print $1}')
expiry=$(echo "$info" | awk '{print $2}')
type=$(echo "$info" | awk '{print $3}')
expiry_ts=$(date -d "$expiry" +%s 2>/dev/null || date -jf "%Y-%m-%dT%H:%M:%S" "${expiry%.*}" +%s)
days=$(( (expiry_ts - $(date +%s)) / 86400 ))
if [ "$days" -le "$THRESHOLD_DAYS" ]; then
echo "⚠️ $domain ($type) — expires in $days days ($expiry)"
fi
done
Run this as a daily cron job and pipe output to your alerting system (PagerDuty, Slack, email).
If you’re terminating SSL directly on an EC2 instance (Nginx, Apache, or Node.js), ACM is not involved. You’re managing the certificate file yourself, and expiry monitoring is entirely your responsibility.
Check an individual certificate on-disk:
openssl x509 -enddate -noout -in /etc/ssl/certs/your-cert.pem
Get days remaining:
openssl x509 -enddate -noout -in /etc/ssl/certs/your-cert.pem \
| awk -F= '{print $2}' \
| xargs -I{} date -d "{}" +%s \
| xargs -I{} sh -c 'echo $(( ({} - $(date +%s)) / 86400 )) days remaining'
Check the live certificate that a domain is actually serving (catches cases where the file on disk differs from what’s loaded):
echo | openssl s_client -connect yourdomain.com:443 -servername yourdomain.com 2>/dev/null \
| openssl x509 -noout -enddate
For a fleet of EC2 instances, add a check to your monitoring cron:
# In /etc/cron.daily/check-ssl-expiry
#!/bin/bash
DOMAIN="yourdomain.com"
DAYS_WARN=30
DAYS_CRIT=7
days=$(echo | openssl s_client -connect $DOMAIN:443 2>/dev/null \
| openssl x509 -noout -enddate \
| awk -F= '{print $2}' \
| xargs -I{} date -d "{}" +%s \
| xargs -I{} sh -c 'echo $(( ({} - $(date +%s)) / 86400 ))')
if [ "$days" -le "$DAYS_CRIT" ]; then
echo "CRITICAL: $DOMAIN SSL cert expires in $days days" | mail -s "SSL CRITICAL" ops@yourcompany.com
elif [ "$days" -le "$DAYS_WARN" ]; then
echo "WARNING: $DOMAIN SSL cert expires in $days days" | mail -s "SSL WARNING" ops@yourcompany.com
fi
ALBs can have multiple certificates attached across multiple listeners. List them all:
# List all ALB listener certificates in us-east-1
aws elbv2 describe-load-balancers --region us-east-1 \
--query "LoadBalancers[].LoadBalancerArn" \
--output text | tr '\t' '\n' | while read alb_arn; do
aws elbv2 describe-listeners --load-balancer-arn "$alb_arn" \
--query "Listeners[?Protocol=='HTTPS'].ListenerArn" \
--output text | tr '\t' '\n' | while read listener_arn; do
aws elbv2 describe-listener-certificates \
--listener-arn "$listener_arn" \
--query "Certificates[].CertificateArn" \
--output text
done
done
Once you have the certificate ARNs, use the ACM describe-certificate command above to get the expiry dates.
describe-listener-certificates call above returns all of them.
CloudFront uses ACM certificates in us-east-1 regardless of where your origin is. If you’re using a custom SSL certificate on CloudFront (not the default CloudFront certificate), monitor it the same way as an ACM cert:
aws cloudfront list-distributions \
--query "DistributionList.Items[].{Domain:DomainName,Cert:ViewerCertificate.ACMCertificateArn}" \
--output table
Then describe each ACM cert ARN to get the expiry date.
ACM emits an ACM Certificate Approaching Expiration event to EventBridge when a certificate is 45, 30, 15, 7, 3, and 1 days from expiry. You can route these to an SNS topic for email or Slack alerts without any custom monitoring code:
# Terraform — EventBridge rule for ACM expiry events
resource "aws_cloudwatch_event_rule" "acm_expiry" {
name = "acm-certificate-expiry"
description = "Alert when ACM certificates approach expiry"
event_pattern = jsonencode({
"source" : ["aws.acm"],
"detail-type" : ["ACM Certificate Approaching Expiration"]
})
}
resource "aws_cloudwatch_event_target" "acm_expiry_sns" {
rule = aws_cloudwatch_event_rule.acm_expiry.name
target_id = "SendToSNS"
arn = aws_sns_topic.alerts.arn
}
This catches ACM-managed and imported certificate expiry events without polling. The limitation: it only fires for certs in ACM. EC2 instance certs are not covered.
All of the AWS-side approaches above check what ACM thinks the expiry is. They don’t verify what certificate your endpoints are actually serving. These are different things.
A certificate can show as valid in ACM while an old cached cert is being served at the edge. A Let’s Encrypt renewal can succeed and update the cert file but fail to reload Nginx. An ACM certificate update can fail to propagate to an ALB listener if the attachment failed silently.
External monitoring — actually connecting to your domain and checking the certificate from outside your infrastructure — catches these cases. It’s the check that confirms what users actually see.
Enter any domain to see the exact days remaining, expiry date, and issuer — from outside your infrastructure, the same view your users have.
Free SSL checker →Want automated alerts before your certificates expire? SSLWatch monitors your domains and sends email alerts at 30, 14, 7, and 1 days before expiry — no infrastructure required.
| Scenario | Best monitoring approach | Auto-renews? |
|---|---|---|
| ACM-managed cert with DNS validation in Route 53 | EventBridge ACM event → SNS + external checker | Yes (if DNS record intact) |
| ACM-managed cert with email validation | EventBridge ACM event → SNS + check email address is active | Yes (if email works) |
| Imported cert in ACM (Let’s Encrypt, DigiCert) | Daily aws acm describe-certificate script + alert |
No — manual renewal |
| EC2 instance cert (Nginx/Apache) | On-instance cron + openssl check + alert |
No (unless certbot configured) |
| ALB with multiple SNI certs | EventBridge + script iterating listener certificates | If managed by ACM |
| CloudFront with custom cert | Same as ACM cert (must be us-east-1) | If managed by ACM |
| All of the above, externally verified | External SSL monitoring service | N/A |
The minimum viable setup for most AWS environments: EventBridge → SNS for ACM events, plus an external monitoring check on your production domains to catch anything the internal tooling misses.