These days, it's challenging to imagine systems that have public API endpoints without TLS certificate protection. There are several ways to issue certificates:
Within the context of this post, I will mainly discuss free certificates that can be used inside of AWS, but not only by AWS services. Clearly, using anything other than AWS Certificate Manager makes no sense if you exclusively use managed AWS services and don't have strict security requirements. AWS Certificate Manager offers a very convenient and speedy method of issuing certificates via DNS or HTTP challenges; however, you face basic AWS limitations if you need to use these certificates outside of AWS services (API Gateway, ALB, NLB, etc.) , such as an EC2 instance running Nginx that needs a physical certificate file. Additionally, even if you request it, AWS Certificate Manager does not display the certificate content.
At this point it’s a good time to remind you about LetsEncrypt, a more widely used tool than Certificate Manager—at least because it doesn't depend on the cloud. Unfortunately, there are no built-in LetsEncrypt certificate issuance techniques available in AWS. It is possible to utilize the certbot tool for your EC2 or ECS services, but in that scenario, you will need to consider how to configure the renew process. I also don't want to combine different strategies since I think it's better to have a single procedure for everything since it reduces the whole system complexity.
Taking that into consideration, I created a Lambda function that automatically issues and renews LetsEncrypt certificates without requiring complex configuration. The certificate can be utilized at any AWS service using ARN along with AWS Certificate Manager certificates after the initial certificate issue. Additionally, you can use a physical certificate version that is kept in AWS Secrets Manager in whatever location you choose, whether it be an EC2 instance running Nginx or another place.
In this article, I'll assume that your DNS zone is managed by AWS Route53.
The Lambda function that is described in this article is written on Go v1.22. All outcome resources such as DNS records, secrets, or certificates are controlled by Amazon IAM role, that is created via Terraform code by default. The sequence of Lambda actions is the following:
aws_cloudwatch_event_target
. Event example:{
"domainName": "hackernoon.referrs.me",
"acmeUrl": "prod",
"acmeEmail": "[email protected]",
"reImportThreshold": 10,
"issueType": "default",
"storeCertInSecretsManager" : true
}
DNS-01
challenge if the number of days until the expiration date is fewer than the reImportThreshold
. This step involves Lambda creating a TXT
record matching the domain name to the AWS Route53 zone and waiting for your certificate to be ready.storeCertInSecretsManager
is true.The code
The Lambda is written on Go 1.22. Using as few libraries as possible helped me maintain my goal of keeping the code dry. The full list of required go libraries:
URL |
Description |
---|---|
Libraries, samples and tools to help Go developers develop AWS Lambda functions. | |
AWS SDK for the Go programming language. | |
LetsEncrypt / ACME client and library. | |
Reasonable handling of nullable values. | |
Structured, pluggable logging for Go. |
Docker image
I used gcr.io/distroless/static:nonroot as a basic docker image. For Go applications that don't require libc, this image is perfect. It is not completely empty as scratch
and includes the following:
Build process
In large software projects, overseeing the build process can turn into a laborious and time-consuming chore. Makefiles can help automate and streamline this process, ensuring that your project is built efficiently and consistently. For that reason, I prefer to use Makefile for all my Golang projects. The file is simple:
##@ General
help: ## Display this help.
@awk 'BEGIN {FS = ":.*##"; printf "\nUsage:\n make \033[36m<target>\033[0m\n"} /^[a-zA-Z_0-9-]+:.*?##/ { printf " \033[36m%-15s\033[0m %s\n", $$1, $$2 } /^##@/ { printf "\n\033[1m%s\033[0m\n", substr($$0, 5) } ' $(MAKEFILE_LIST)
fmt: ## Run go fmt against code.
go fmt ./...
vet: ## Run go vet against code.
go vet ./...
##@ Build
build: fmt vet ## Build service binary.
go build -o bin/lambda main.go
run: vet ## Run service from your laptop.
go run ./main.go
##@ Test
lint: ## Run Go linter
golangci-lint run ./...
test: ## Run Go tests
go test ./...
From the CICD side I used the typical setup for Go application
GitHub Actions as continuous integration.
ghcr.io as docker registry. Compared to DockerHub, this one offers two key features that make it my preference to use:
kvendingoldo/semver-action: my GitHub Actions plugin for automatic versioning. It’s A GitHub action that generates SemVer compatible tags for repository commits. The action can manage versions, generate GitHub releases, and control release branches inside of the repository. It works wonderfully with both single and monorepos.
Automatic changelog generation. I enjoy changelogs! In the context of other OpenSource projects that I manage (e.g., https://github.com/tofuutils/tenv, https://github.com/tofuutils/tofuenv, etc.), my team recognized the importance of informing users about changes.
golangci-lint. From my perspective, all code should be reviewed by a static code analyzer. SonarQube cannot be set up for all projects, however golangci, in my opinion, is sufficient for small to medium Go projects.
The code discussed in this page is the same for Terraform and OpenTofu, but starting with Terraform v1.6, Hashicorp has been modified the Terraform license to Business Source License (BSL) v1.1.If you're developing something commercial on top of Terraform, switch to OpenTofu as soon as possible.
If you need to manage multiple versions of OpenTofu or Terraform , use the
More Terraform / OpenTofu examples can be found in the examples folder within the Git repository.
To work with AWS LetsEncrypt Lambda via OpenTofu you need to do the following steps:
Add module into to your OpenTofu / Terraform code
module "letsencrypt_lambda" {
source = "[email protected]:kvendingoldo/aws-letsencrypt-lambda.git//files/terraform/module?ref=0.31.4"
blank_name = "kvendingoldo-letsencrypt-lambda"
tags = var.tags
cron_schedule = var.letsencrypt_lambda_cron_schedule
events = var.letsencrypt_lambda_events
ecr_proxy_username = var.ecr_proxy_username
ecr_proxy_access_token = var.ecr_proxy_access_token
}
Specify variables
variable "tags" {
default = {
hackernoon : "demo"
}
}
variable "ecr_proxy_username" {
default = "kvendingoldo"
}
variable "ecr_proxy_access_token" {
default = "ghp_xxx"
}
variable "letsencrypt_lambda_cron_schedule" {
default = "rate(168 hours)"
}
variable "letsencrypt_lambda_events" {
default = [
{
"acmRegion" : "us-east-1",
"route53Region" : "us-east-1",
"domainName" : "hackernoon.referrs.me",
"acmeUrl" : "stage",
"acmeEmail" : "[email protected]",
"reImportThreshold" : 100,
"issueType" : "default",
"storeCertInSecretsManager" : false
}
]
}
Pay attention to variables ecr_proxy_username
and ecr_proxy_access_token
. By default, AWS Lambda cannot pull images from sources other than AWS ECR. Fortunately, the AWS team created ECR Proxy cache, which can fetch images from publicly available registries such as DockerHub or GHCR and store them inside ECR. Despite this possibility, AWS does not allow images to be pulled without a token, even from open public repositories, thus you must acquire a personal GitHub token to gain access to pre-built Docker images. Alternatively, you can pull my GitHub repository, build the image locally, then upload it to your pre-existing ECR repository. In this scenario, the example can be modified as follows:
module "letsencrypt_lambda" {
source = "../../"
blank_name = "kvendingoldo-letsencrypt-lambda"
tags = var.tags
cron_schedule = var.letsencrypt_lambda_cron_schedule
events = var.letsencrypt_lambda_events
ecr_proxy_enabled = false
ecr_image_uri = "<YOUR_ACCOUNT_ID>.dkr.ecr.us-east-2.amazonaws.com/aws_letsencrypt_lambda:<VERSION>"
}
When you complete changing the code, run the following command to install OpenTofu by OpenTofu version switcher tenv:
$ tenv tofu install
And finally, execute the following commands to apply the produced code:
$ tofu init
$ tofu plan
$ tofu apply
Wait until the code is deployed to AWS and events are triggered. After a few minutes, you will see ready certificates inside the certificate manager. Example:
Starting now, AWS can use the issued certificate at any services by ARN.
If you need to use the certificate outside of AWS services or have access to its content, set the storeCertInSecretsManager
event option to true
. In this situation, when Lambda completes the basic execution, the certificate will be saved in AWS Secrets Manager. It gives users more flexibility: they can inspect the certificate's content, work with it directly from EC2, etc. To learn more about AWS Secrets Manager, read the official guide.
Name |
Description |
Possible values |
Default value |
Example |
Required |
---|---|---|---|---|---|
|
Formatter type for logs |
JSON | TEXT |
TEXT |
JSON |
❌ |
|
Application mode. Set |
cloud | local |
cloud |
cloud |
✅ |
|
Logging level |
panic|fatal|error|warn|info|debug|trace |
warn |
warn |
❌ |
|
Default AWS Region. After the deployment to AWS it settings automatically. |
<any valid AWS region> |
- |
us-east-1 |
✅ |
|
Domain name for which the certificate is being issued or renewed |
any valid domain name |
- |
hackernoon.referrs.me |
✅ |
|
The production LetsEncrypt URL will be utilized if it is set to |
prod | stage |
prod |
prod |
✅ |
|
Email address linked to the LetsEncrypt certificate |
any valid email |
✅ | ||
|
The certificate will be renewed if its time to live (TTL) equals |
any int > 0 |
10 |
10 |
✅ |
|
If |
“true” | “false” |
“false” |
“false” |
❌ |
In scope of work with aws-letsencrypt-lambda you may occasionally want to review the logs. It's quite easy to accomplish:
/aws/lambda/kvendingoldo-letsencrypt-lambda
Go to Lambda function that has been created via OpenTofu. Click to “Tests” button.
Fill Test Event
and click Test
{
"domainName": "<YOUR_VALID_DOMAIN>",
"acmeUrl": <stage | prod>,
"acmeEmail": "<ANY_VALID_EMAIL>",
"reImportThreshold": 10,
"issueType": "<default | force>",
"storeCertInSecretsManager" : <true | false>
}
Example #1:
{
"domainName": "hackernoon.referrs.me",
"acmeUrl": "prod",
"acmeEmail": "[email protected]",
"reImportThreshold": 10,
"issueType": "default"
}
Wait until execution will be completed. You can the execution log is available in Cloudwatch. Usually the initial issue takes around 5 minutes.
Clone https://github.com/kvendingoldo/aws-letsencrypt-lambda repository to your laptop
Configure AWS Cli credentials via the official guide.
Examine the environment variables section and set the minimum number of variables needed. Since LetsEncrypt will limit the amount of retries per hour for ACME_URL="prod"
, I advise using ACME_URL="stage"
for testing. Environment variables example:
export AWS_REGION="us-east-2"
export MODE=local
export DOMAIN_NAME="hackernoon.referrs.me"
export ACME_URL="stage"
export ACME_EMAIL="[email protected]"
export REIMPORT_THRESHOLD=10
export ISSUE_TYPE="default"
export STORE_CERT_IN_SECRETSMANAGER="true"
Execute the lambda locally via the following command:
go run main.go
Following Lambda's successful execution, the following log will appear.
INFO[0000] Starting lambda execution ...
INFO[0000] Lambda will use STAGING ACME URL; If you need to use PROD URL specify it via 'ACME_URL' or pass in event body
INFO[0000] Certificate found, arn is arn:aws:acm:us-east-2:004867756392:certificate/72f872fd-e577-43f4-ae38-6833962630af. Trying to renew ...
INFO[0000] Checking certificate for domain 'hackernoon.referrs.me' with arn 'arn:aws:acm:us-east-2:004867756392:certificate/72f872fd-e577-43f4-ae38-6833962630af'
INFO[0000] Certificate status is 'ISSUED'
INFO[0000] Certificate in use by []
INFO[0000] Certificate valid until 2024-08-31 13:50:49 +0000 UTC (89 days left)
INFO[0000] Try to get certificate for hackernoon.referrs.me domain
2024/06/02 17:56:23 [INFO] acme: Registering account for [email protected]
2024/06/02 17:56:24 [INFO] [hackernoon.referrs.me, www.hackernoon.referrs.me] acme: Obtaining bundled SAN certificate
2024/06/02 17:56:25 [INFO] [hackernoon.referrs.me] AuthURL: https://acme-staging-v02.api.letsencrypt.org/acme/authz-v3/12603809394
2024/06/02 17:56:25 [INFO] [www.hackernoon.referrs.me] AuthURL: https://acme-staging-v02.api.letsencrypt.org/acme/authz-v3/12603809404
2024/06/02 17:56:25 [INFO] [hackernoon.referrs.me] acme: Could not find solver for: tls-alpn-01
2024/06/02 17:56:25 [INFO] [hackernoon.referrs.me] acme: Could not find solver for: http-01
2024/06/02 17:56:25 [INFO] [hackernoon.referrs.me] acme: use dns-01 solver
2024/06/02 17:56:25 [INFO] [www.hackernoon.referrs.me] acme: Could not find solver for: tls-alpn-01
2024/06/02 17:56:25 [INFO] [www.hackernoon.referrs.me] acme: Could not find solver for: http-01
2024/06/02 17:56:25 [INFO] [www.hackernoon.referrs.me] acme: use dns-01 solver
2024/06/02 17:56:25 [INFO] [hackernoon.referrs.me] acme: Preparing to solve DNS-01
2024/06/02 17:56:26 [INFO] Wait for route53 [timeout: 5m0s, interval: 4s]
2024/06/02 17:57:00 [INFO] [www.hackernoon.referrs.me] acme: Preparing to solve DNS-01
2024/06/02 17:57:00 [INFO] Wait for route53 [timeout: 5m0s, interval: 4s]
2024/06/02 17:57:30 [INFO] [hackernoon.referrs.me] acme: Trying to solve DNS-01
2024/06/02 17:57:30 [INFO] [hackernoon.referrs.me] acme: Checking DNS record propagation. [nameservers=109.122.99.130:53,109.122.99.129:53]
2024/06/02 17:57:34 [INFO] Wait for propagation [timeout: 5m0s, interval: 4s]
2024/06/02 17:57:46 [INFO] [hackernoon.referrs.me] The server validated our request
2024/06/02 17:57:46 [INFO] [www.hackernoon.referrs.me] acme: Trying to solve DNS-01
2024/06/02 17:57:46 [INFO] [www.hackernoon.referrs.me] acme: Checking DNS record propagation. [nameservers=109.122.99.130:53,109.122.99.129:53]
2024/06/02 17:57:50 [INFO] Wait for propagation [timeout: 5m0s, interval: 4s]
2024/06/02 17:58:30 [INFO] [www.hackernoon.referrs.me] The server validated our request
2024/06/02 17:58:30 [INFO] [hackernoon.referrs.me] acme: Cleaning DNS-01 challenge
2024/06/02 17:58:30 [INFO] Wait for route53 [timeout: 5m0s, interval: 4s]
2024/06/02 17:59:09 [INFO] [www.hackernoon.referrs.me] acme: Cleaning DNS-01 challenge
2024/06/02 17:59:09 [INFO] Wait for route53 [timeout: 5m0s, interval: 4s]
2024/06/02 17:59:43 [INFO] [hackernoon.referrs.me, www.hackernoon.referrs.me] acme: Validations succeeded; requesting certificates
2024/06/02 17:59:43 [INFO] Wait for certificate [timeout: 30s, interval: 500ms]
2024/06/02 17:59:45 [INFO] [hackernoon.referrs.me] Server responded with a certificate.
INFO[0203] Certificate has been successfully imported. Arn is arn:aws:acm:us-east-2:004867756392:certificate/72f872fd-e577-43f4-ae38-6833962630af
INFO[0204] Secret updated successfully. SecretId: arn:aws:secretsmanager:us-east-2:004867756392:secret:hackernoon.referrs.me-NioT77
INFO[0204] Lambda has been completed
That is it. Starting now, AWS can use the issued certificate at any services by ARN or in other locations where it is physically necessary by obtaining it from the AWS Secrets Manager.
I've been using the Lambda function in production for almost four years. Over the years, various aspects of the initial implementation have changed:
Previously, AWS prohibited the usage of any non-ECR registries as Lambda sources. It has not changed, however AWS has added ECR proxy for GitHub, DockerHub, and a few additional registries. Without this functionality, we had to manually push Lambda images to our personal ECR and replace URL to the image in Terraform code. Now the OpenTofu code does it automatically via ECR Proxy.
In the beginning, I considered introducing various challenges such as http-01
or tls-alpn-01
, but no one questioned me about it for four years. It is still present on GitHub issues, and if this capability is required, we can work together to create it.
I didn't want to utilize LetsEncrypt certificates at pure EC2 instances when the project originally started, but these days it's standard practice. As I previously stated, in certain situations, a certificate can be retrieved from AWS Secrets Managed using the AWS cli.
I’ve written a lot of new Go code over the years, so I can tell that the original Lambda code in my repository isn't as fancy as it could be. There is a significant difference between it and my most recent Go project, tenv (OpenTofu, Terraform, Terragrunt, and Atmos version manager, written in Go), but in any case, the code is still generally supported, so making modifications to it won't be too problematic. Occasionally, I will undertake significant refactoring to make the code more elegant.
The same Lambda is being used for years in several different projects. Additionally, I'm co-founder of DevOps platform cloudexpress.app, where our team manages TLS certificates for all our clients using the AWS LetsEncrypt Lambda to simplify the automation processes.
Now let's talks about numbers. Over a period of 4 years, this project has helped many people and been used in numerous OpenSource and over 30 commercial projects. The Lambda issues more than 2000 certificates and don’t want to stop on that.
AWS LetsEncrypt Lambda is a suitable solution for you, if
If you discovered that at least one of these points applies to your situation, you are welcome to use AWS Lambda. Also, if you wish to participate in development, I am always open to new issues and Pull Requests at GitHub. Project URL: https://github.com/kvendingoldo/aws-letsencrypt-lambda.