Provision TLS certificates with Cert-Manager and ACME-DNS on Kubernetes

Volodymyr Danyliv
8 min readJan 15, 2025

--

This article will explore a topic often overlooked or not fully explained. We will discuss how to provision TLS certificates on Kubernetes using cert-manager and an ACME-DNS solver for DNS-01 challenges.

I assume you are already familiar with the cert-manager, have some basic knowledge of Terraform, and understand the Kubernetes basics.

What we need:

  • Terraform
  • Kubernetes cluster
  • Cert-manager installed
  • Traefik (I prefer it as it is simple to install and configure)
  • Familiar with acme-dns documentation

I will create a Terraform module that will help us to provision acme-dns server with additional resources that give us the possibility to expose the DNS.

Before we start

I haven’t found any stable Helm chart to install acme-dns, this article also will not cover the creation of the helm chart and will only fill the gaps in the scope of Terraform module creation and installation.

The source code of the acme-dns server you can find here

Terraform module structure

I will try to make a Terraform module as simple as possible, the structure will look like this:

modules
- acme-dns
- main.tf
- acme-dns-config.tftpl
- acme-dns-services.tf
- variables.tf
- version.tf
  • main.tf — will cover the creation and configuration of the DNS server
  • acme-dns-config.tftpl — the configuration file
  • acme-dns-services.tf — helps route traffic to our DNS server
  • variables.tf — module variables
  • versions.tf — the providers version

Create version.tf file

terraform {
required_version = ">= 1.5.1"

required_providers {
kubernetes = {
source = "hashicorp/kubernetes"
version = "~> 2.31.0"
}
kubectl = {
source = "gavinbunney/kubectl"
version = "1.14.0"
}
}
}

I use hashicorp/kubernetes provider for all Kubernetes resource creation.

Create variables.tf

variable "namespace" {
type = string
default = "acme-dns"
description = "Kubernetes namespace where acme-dns resources will be deployed"
}

variable "host" {
type = string
description = "Base domain name for the acme-dns subdomain (e.g. example.com)"
}

variable "load_balancer_ip" {
type = string
description = "External IP address or LoadBalancer IP that acme-dns will use for the A record"
}

Create a namespace for acme-dns resources.

resource "kubernetes_namespace_v1" "acme_dns" {
metadata {
name = var.namespace
}
}

Create a ConfigMap for our future deployment.

resource "kubernetes_config_map_v1" "acme_dns_configuration" {
metadata {
name = "acme-dns-configuration"
namespace = kubernetes_namespace_v1.acme_dns.metadata[0].name
}

data = {
"config.cfg" = templatefile("${path.module}/acme-dns-config.tftpl", {
host = var.host
lb_ip = var.load_balancer_ip
})
}
}

This is just a basic ConfigMap that will contain a config.cfg key with a configuration required for acme-dns. You need to pass your hostname and load balancer IP address.

Create acme-dns-config.tftpl file and use the configuration below as an example.

[general]
# DNS interface. Note that systemd-resolved may reserve port 53 on 127.0.0.53
# In this case acme-dns will error out and you will need to define the listening interface
# for example: listen = "127.0.0.1:53"
listen = "0.0.0.0:53"
# protocol, "both", "both4", "both6", "udp", "udp4", "udp6" or "tcp", "tcp4", "tcp6"
protocol = "both4"
# domain name to serve the requests off of
domain = "dns.${host}"
# zone name server
nsname = "dns.${host}"
# admin email address, where @ is substituted with .
nsadmin = "dev.${host}"
# predefined records served in addition to the TXT
# predefined records served in addition to the TXT
records = [
# domain pointing to the public IP of your acme-dns server
"dns.${host}. A ${lb_ip}",
# specify that dns.customarydev.net will resolve any *.dns.customarydev.net records
"dns.${host}. NS dns.${host}.",
]
# debug messages from CORS etc
debug = true

[database]
# Database engine to use, sqlite3 or postgres
engine = "sqlite3"
# Connection string, filename for sqlite3 and postgres://$username:$password@$host/$db_name for postgres
# Please note that the default Docker image uses path /var/lib/acme-dns/acme-dns.db for sqlite3
connection = "/var/lib/acme-dns/acme-dns.db"
# connection = "postgres://user:password@localhost/acmedns_db"

[api]
# listen ip eg. 127.0.0.1
ip = "0.0.0.0"
# disable registration endpoint
disable_registration = false
# listen port, eg. 443 for default HTTP
port = "80"
# possible values: "letsencrypt", "letsencryptstaging", "cert", "none"
tls = "none"
# only used if tls = "cert"
tls_cert_privkey = "/etc/tls/example.org/privkey.pem"
tls_cert_fullchain = "/etc/tls/example.org/fullchain.pem"
# only used if tls = "letsencrypt"
acme_cache_dir = "api-certs"
# optional e-mail address to which Let's Encrypt will send expiration notices for the API's cert
notification_email = ""
# CORS AllowOrigins, wildcards can be used
corsorigins = [
"*"
]
# use HTTP header to get the client ip
use_header = false
# header name to pull the ip address / list of ip addresses from
header_name = "X-Forwarded-For"

[logconfig]
# logging level: "error", "warning", "info" or "debug"
loglevel = "debug"
# possible values: stdout, TODO file & integrations
logtype = "stdout"
# file path for logfile TODO
# logfile = "./acme-dns.log"
# format, either "json" or "text"
logformat = "text"
  • I use “dns.” subdomain, you can use any you like.
  • ${host} - example.com
  • ${lb_ip} — load balancer IP associated with DNS (we will use it for DNS record in future)
  • general.nsadmin — specify the administrative email address for the DNS server.

I recommend changing the database configuration point to Postgres cluster, this configuration uses sqlite3. For demo purposes, we will use the default one.

Create Deployment

resource "kubernetes_deployment_v1" "deployment_acme_dns_acme_dns" {
metadata {
name = "acme-dns"
namespace = kubernetes_namespace_v1.acme_dns.metadata[0].name
}

spec {
replicas = 1

selector {
match_labels = {
app = "acme-dns"
}
}

template {
metadata {
labels = {
app = "acme-dns"
}
}

spec {
container {
name = "acme-dns"
image = "joohoi/acme-dns:latest"

port {
container_port = 53
protocol = "UDP"
}

port {
container_port = 53
protocol = "TCP"
}

port {
container_port = 80
protocol = "TCP"
}

volume_mount {
name = "acme-dns-configuration"
mount_path = "/etc/acme-dns"
}
}

volume {
name = "acme-dns-configuration"

config_map {
name = "acme-dns-configuration"
}
}
}
}
}

depends_on = [
kubernetes_config_map_v1.acme_dns_configuration
]
}

This Deployment uses a single replica of acme-dns, exposing ports 53 (TCP/UDP) and 80 (TCP). It mounts a configuration at /etc/acme-dns from the acme-dns-configuration ConfigMap.

Let's move to acme-dns-services.tf

This Terraform code creates a Kubernetes service named acme-dns-api to expose the acme-dns application within the cluster. It listens for HTTP traffic on port 80 and forwards it to the pods. The service uses the ClusterIP type, making it accessible only inside the cluster, perfect for internal communication.

resource "kubernetes_manifest" "acme_dns_api_service" {
manifest = {
apiVersion = "v1"
kind = "Service"

metadata = {
name = "acme-dns-api"
namespace = kubernetes_namespace_v1.acme_dns.metadata[0].name
}

spec = {
ports = [
{
name = "http"
port = 80
protocol = "TCP"
targetPort = 80
},
]
selector = {
app = "acme-dns"
}
type = "ClusterIP"
}
}
}
resource "kubernetes_manifest" "acme_dns_service" {
manifest = {
apiVersion = "v1"
kind = "Service"

metadata = {
name = "acme-dns"
namespace = kubernetes_namespace_v1.acme_dns.metadata[0].name
}

spec = {
ports = [
{
name = "dns-tcp"
port = 53
protocol = "TCP"
targetPort = 53
},
{
name = "dns-udp"
port = 53
protocol = "UDP"
targetPort = 53
},
]

selector = {
app = "acme-dns"
}

type = "ClusterIP"
}
}
}

Setting up DNS services in a Kubernetes cluster requires handling both TCP and UDP traffic because DNS queries can use either protocol. This Terraform setup uses Traefik as the ingress controller to manage DNS traffic. It defines separate routes for TCP and UDP, ensuring reliable routing and load balancing of DNS requests within the cluster.

resource "kubectl_manifest" "acme_dns_tcp_ingress" {
yaml_body = <<YAML
apiVersion: traefik.io/v1alpha1
kind: IngressRouteTCP
metadata:
name: acme-dns
namespace: ${kubernetes_namespace_v1.acme_dns.metadata[0].name}
spec:
entryPoints:
- tcp-dns
routes:
- match: HostSNI(`*`)
priority: 10
services:
- name: acme-dns
port: 53
weight: 10
nativeLB: true
YAML
}

resource "kubectl_manifest" "acme_dns_udp_ingress" {
yaml_body = <<YAML
apiVersion: traefik.io/v1alpha1
kind: IngressRouteUDP
metadata:
name: acme-dns
namespace: ${kubernetes_namespace_v1.acme_dns.metadata[0].name}
spec:
entryPoints:
- udp-dns
routes:
- services:
- name: acme-dns
port: 53
weight: 10
nativeLB: true
YAML
}

This Terraform code sets up two Kubernetes ingress routes with Traefik to handle DNS traffic for the acme-dns application. Both routes forward traffic to the acme-dns service on port 53, with one route managing TCP traffic via tcp-dns and the other managing UDP traffic via udp-dns. Native load balancing ensures reliable DNS functionality within the specified namespace

Delegating Your Subdomain

For acme-dns to serve DNS queries at dns.${host}, you must delegate that subdomain to your new authoritative server. In your main DNS provider (e.g., Route53, Cloudflare, or a domain registrar), create:

  1. A Record: dns.${host}<your LoadBalancer IP or external address>
  2. NS Record: dns.${host}dns.${host}

Alternatively, you can set the entire subdomain’s NS records to point to your cluster IP. Once this is done, Let’s Encrypt (or other ACME servers) can query the delegated subdomain for TXT records.

Integrating with cert-manager

Now that acme-dns is up and running, let’s see how to connect cert-manager so that it can request certificates via the acme-dns solver. We need two main objects:

  1. An Issuer (or ClusterIssuer) that references acme-dns
  2. A Certificate resource that uses that Issuer

Example Issuer

Below is a sample Issuer configuration. You can place this in the same namespace as your workload that needs the certificate, or in any other user-defined namespace:

apiVersion: cert-manager.io/v1
kind: Issuer
metadata:
name: letsencrypt-acme-dns-issuer
namespace: <namespace>
spec:
acme:
server: https://acme-v02.api.letsencrypt.org/directory
email: dev@example.com
privateKeySecretRef:
name: letsencrypt-acme-dns-issuer
solvers:
- dns01:
cnameStrategy: Follow
acmeDNS:
# This is the internal cluster URL for the acme-dns API service
host: http://acme-dns-api.acme-dns.svc.cluster.local
accountSecretRef:
name: domain-registration
key: registration.json

Use the in-cluster DNS name (acme-dns-api.acme-dns.svc.cluster.local) as the host. The secret domain-registration in <namespace> holds the acme-dns registration data in registration.json. Setting cnameStrategy to Follow ensures that CNAME records are resolved correctly.

Example Certificate

apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: <domain-name>
namespace: <namespace>
spec:
dnsNames:
- 'your.domain.com'
issuerRef:
name: letsencrypt-acme-dns-issuer
kind: Issuer
secretName: <domain-name>

cert-manager initiates a DNS-01 challenge via acme-dns. Let’s Encrypt verifies the TXT record and, once validated, issues the certificate. cert-manager stores the certificate in a Secret named <domain-name>.

In this article, I tried to show you how to deploy ACME-DNS on Kubernetes using Terraform, expose it via Traefik’s TCP and UDP entry points, and integrate it with cert-manager using a custom Issuer referencing ACME-DNS as the solver. This setup enables automated TLS certificates for any domain you control by delegating DNS challenges to your acme-dns server.

By combining Terraform, cert-manager, Traefik, and acme-dns, you will get a powerful and flexible way to handle DNS-01 challenges within Kubernetes — no matter where you keep your primary DNS records.

--

--

Volodymyr Danyliv
Volodymyr Danyliv

Written by Volodymyr Danyliv

Software engineer (Back-end, Cloud, DevOps), Scrappie.app

No responses yet