Terraform, Let's Encrypt, and PowerDNS
Drew LeskeA colleague asked for some information on this so I’ll post this here in hopes it might be useful to someone out there (or just me in a year when I vaguely recall doing something similar.) This is a quick description of how I use Terraform to generate Let’s Encrypt certificates using our PowerDNS instance. This is more of a quick reference than anything else.
Providers, common variable definitions and configuration are in common.tf
(this is my convention, not a Terraform requirement). Here we define the
minimum version and list our providers. We don’t need to include the PowerDNS
provider here unless we’re managing DNS records directly: the ACME provider
supports PowerDNS and other DNS services through a library it encapsulates.
terraform {
required_version = ">= 0.14.0"
required_providers {
[...]
acme = {
source = "vancluever/acme"
version = "2.13.1"
}
}
}
Next we define common variables.
variable alerting_email {
description = "E-mail address for alerting"
type = string
}
variable pdns {
description = "PowerDNS configuration"
type = object({
api_key = string
uri = string
})
}
I define this pdns
variable here and then the following local variable based
on it. Though that seems redundant, it’s actually because in my use case I
also set up DNS stuff (not shown here), and I reuse the configuration there.
locals {
dns_config = {
provider = "pdns"
config = {
PDNS_API_KEY = var.pdns.api_key
PDNS_API_URL = var.pdns.uri
}
}
}
Over in certificates.tf
, I define everything I need for managing the
certificates. First I define the provider and a variable only used by this
file. The provider is pretty straightforward: we define the server URL for
Let’s Encrypt. The staging URL is for testing and is not rate-limited so it’s
good to use this for making sure the setup works.
I define a variable “certificates” which is a map (dict, hash) of lists of strings. The map key is a useful short name; the list of strings represent domains that will be used as SANs in the certificate, with the first string in the list being used as the primary domain. I’ll show an example of the variables later.
provider "acme" {
# Use this for testing. I always leave both lines here
#server_url = "https://acme-staging-v02.api.letsencrypt.org/directory"
server_url = "https://acme-v02.api.letsencrypt.org/directory"
}
variable certificates {
type = map(list(string))
default = {}
}
Next the key and registration. Note the private key resource is created first (thanks to Terraform’s dependency tracking) so it can be used for the registration. Note the registration uses the alerting email variable defined earlier.
resource "tls_private_key" "private_key" {
algorithm = "RSA"
}
resource "acme_registration" "reg" {
account_key_pem = tls_private_key.private_key.private_key_pem
email_address = var.alerting_email
}
Here we generate the certificate(s). This resource definition “expands” (I’m hesitant to say it loops, because this is declarative) for each certificate defined in the variables. Recall that the map key is just a name for readability, so it isn’t used in the definition though it will appear in the output. The first string in the map value’s list is used as the common name and the rest constitute the SAN.
resource "acme_certificate" "certificate" {
for_each = var.certificates
account_key_pem = acme_registration.reg.account_key_pem
common_name = each.value[0]
subject_alternative_names = length(each.value) > 1 ? each.value : null
dns_challenge {
provider = local.dns_config.provider
config = local.dns_config.config
}
}
I find it incredibly useful to spit out details about the certificates.
output "certificates" {
description = "Certificate details"
value = {
for name, domains in var.certificates:
name => {
certificate = acme_certificate.certificate[name].certificate_pem
issuer = acme_certificate.certificate[name].issuer_pem
key = acme_certificate.certificate[name].private_key_pem
url = acme_certificate.certificate[name].certificate_url
}
}
sensitive = true
}
I have a script to grok this output.
#!/usr/bin/env python3
#
# Reads certificate information from Terraform output and creates certificate
# files ready for use.
#
# use like: terraform output -json certificates | grokcerts.py
import sys
import json
data_json = "".join(sys.stdin.readlines())
data = json.loads(data_json)
for domain in data.keys():
print(domain)
# this is clumsy but it works
issuer = data[domain]['issuer'].split('-----END CERTIFICATE-----')[0] + '-----END CERTIFICATE-----'
with open(f"{domain}.key", "w") as fh:
fh.write(data[domain]['key'])
with open(f"{domain}.crt", "w") as fh:
fh.write(data[domain]['certificate'])
fh.write(issuer)
To define the variables, I might have the following:
alerting_email = "me@example.org"
certificates = {
wild = ["*.example.org"]
svcA = ["svcA.example.org"],
svcB = ["svcB.example.org", "svcB"]
}
pdns = {
"api_key" = "<powerdns_api_key>"
"uri" = "<powerdns_url>"
}