Enabling DNS-01 TLS for Internal Kubernetes Services
Table of Contents
Ever visited a website and seen that little padlock icon in your browser? That’s TLS at work, encrypting the connection between you and the server. But what happens when you want that same security for services running on your home network that aren’t accessible from the internet? Today I solved this exact problem, and I want to walk you through not just how I did it, but why each piece matters.
Part 1: Understanding the Basics
What is TLS and Why Do We Need It?
TLS (Transport Layer Security) is a protocol that encrypts data traveling between two pointsβlike your browser and a web server. Without TLS:
- Anyone on the network can read your traffic (passwords, data, everything)
- Anyone can pretend to be the server you’re connecting to
- Anyone can modify data in transit without you knowing
When you see https:// instead of http://, that’s TLS in action. The “S” literally stands for “Secure.”
What is a Certificate?
A TLS certificate is like a digital ID card for a website. It contains:
- The domain name it’s valid for (e.g.,
paperless.landryzetam.net) - A public key used for encryption
- A digital signature from a trusted authority proving it’s legitimate
- An expiration date (certificates don’t last forever)
When your browser connects to a site, it checks the certificate to verify:
- “Am I really talking to who I think I’m talking to?”
- “Is this certificate signed by someone I trust?”
Who Signs Certificates? Certificate Authorities
A Certificate Authority (CA) is a trusted organization that verifies domain ownership and signs certificates. Your browser comes pre-loaded with a list of trusted CAs.
Let’s Encrypt is a free, automated CA that has revolutionized TLS adoption. Before Let’s Encrypt, certificates cost money and required manual renewal. Now, anyone can get valid certificates for free.
The Challenge: Proving You Own a Domain
Before Let’s Encrypt signs a certificate, you must prove you control the domain. They offer two main methods:
| Method | How It Works | Best For |
|---|---|---|
| HTTP-01 | Place a file at http://yourdomain/.well-known/acme-challenge/ | Public websites |
| DNS-01 | Create a TXT record in your DNS | Internal services, wildcards |
Part 2: The Problem with Internal Services
My Situation
I deployed Paperless-ngx (a document management system) to my Kubernetes homelab. I wanted to access it at https://paperless.landryzetam.net, but:
- The DNS record points to an internal IP:
10.85.30.214 - This IP is only reachable from my home network
- Let’s Encrypt servers on the internet cannot reach it
Why HTTP-01 Fails
The HTTP-01 challenge works like this:
- You request a certificate for
paperless.landryzetam.net - Let’s Encrypt says: “Prove you own it by putting this file at this URL”
- Let’s Encrypt tries to fetch
http://paperless.landryzetam.net/.well-known/acme-challenge/xyz - Problem: Let’s Encrypt can’t reach
10.85.30.214βit’s a private IP!
The error message tells the story:
authorization error for paperless.landryzetam.net:
no valid A records found for paperless.landryzetam.net
The Solution: DNS-01
DNS-01 proves domain ownership differently:
- You request a certificate for
paperless.landryzetam.net - Let’s Encrypt says: “Create a DNS TXT record with this value”
- You create
_acme-challenge.paperless.landryzetam.netwith the specified value - Let’s Encrypt queries DNS (which is public) and verifies the record
- Certificate issued!
The key insight: DNS is always publicly queryable, even if the actual service is internal. Let’s Encrypt doesn’t need to reach your serverβit just needs to see the DNS record.
Part 3: The Components We’ll Use
Before diving into implementation, let’s understand each tool and technology in the data flow:
Traefik (Ingress Controller)
What it is: An ingress controllerβsoftware that actually handles incoming HTTP/HTTPS traffic in Kubernetes.
What it does:
- Listens on ports 80 (HTTP) and 443 (HTTPS)
- Reads Ingress resources to know how to route traffic
- Terminates TLS (decrypts HTTPS using certificates)
- Forwards decrypted requests to backend services
Why we need it: Kubernetes doesn’t handle external traffic by itself. An Ingress resource is just a configurationβyou need an ingress controller like Traefik, Nginx, or HAProxy to actually process the traffic.
Analogy: If Ingress is the GPS directions, Traefik is the actual car that follows them.
Kubernetes Service
What it is: An abstraction that defines how to access a set of Pods.
What it does:
- Provides a stable IP address and DNS name for your application
- Load balances traffic across multiple Pod replicas
- Decouples “how to reach the app” from “which Pods are running”
Why we need it: Pods are ephemeralβthey come and go, changing IP addresses each time. A Service provides a consistent endpoint (paperless-ngx:8000) that always routes to healthy Pods.
Types:
ClusterIP: Only accessible within the cluster (default)NodePort: Exposes on each node’s IPLoadBalancer: Provisions an external load balancer
Kubernetes Secret
What it is: A Kubernetes resource for storing sensitive data.
What it does:
- Stores data like passwords, tokens, and certificates
- Base64 encodes values (not encryption, just encoding!)
- Can be mounted as files or exposed as environment variables
For TLS certificates: cert-manager stores the certificate and private key in a Secret. Traefik reads this Secret to serve HTTPS.
Important: Secrets in Kubernetes are only base64 encoded by defaultβnot encrypted. That’s why we use External Secrets Operator to sync from a proper secret store.
Cloudflare
What it is: A DNS provider and CDN (Content Delivery Network) service.
What it does for us:
- Hosts DNS records for
landryzetam.net - Provides an API to programmatically manage DNS records
- Allows cert-manager to create
_acme-challengeTXT records automatically
DNS basics: DNS (Domain Name System) translates human-readable names like paperless.landryzetam.net into IP addresses like 10.85.30.214. When you type a URL, your computer asks DNS servers “what’s the IP for this domain?”
Why Cloudflare?: Any DNS provider with an API works. Cloudflare is popular because it’s free, fast, and has excellent API support.
AWS Secrets Manager
What it is: A managed service from Amazon Web Services for storing and retrieving secrets.
What it does:
- Stores secrets encrypted at rest
- Controls access via IAM policies (who can read what)
- Provides audit logging (who accessed what, when)
- Supports automatic rotation for some secret types
Why we need it: We need to store the Cloudflare API token somewhere secure. Options include:
- Git (β Never commit secrets!)
- Kubernetes Secrets directly (β οΈ Works, but secrets live only in cluster)
- AWS Secrets Manager (β Centralized, encrypted, audited)
How it works with ESO: External Secrets Operator connects to AWS, reads the secret, and creates a Kubernetes Secret with the same value.
ACME Protocol
What it is: Automatic Certificate Management Environmentβthe protocol used to communicate with Let’s Encrypt.
What it does:
- Standardizes how clients request certificates
- Defines challenge types (HTTP-01, DNS-01, etc.)
- Handles certificate issuance and renewal
The flow:
- cert-manager sends a Certificate Signing Request (CSR) to Let’s Encrypt
- Let’s Encrypt responds with a challenge (“prove you own this domain”)
- cert-manager completes the challenge (creates DNS record)
- Let’s Encrypt verifies and issues the certificate
- cert-manager downloads and stores the certificate
Why it matters: ACME made free, automated certificates possible. Before ACME, getting a certificate meant emails, manual verification, and payment.
cert-manager
What it is: A Kubernetes add-on that automates certificate management.
What it does:
- Requests certificates from CAs like Let’s Encrypt
- Handles the challenge process (HTTP-01 or DNS-01)
- Stores certificates as Kubernetes Secrets
- Automatically renews certificates before they expire
Why we need it: Without cert-manager, you’d manually request certificates, copy them into your cluster, and set calendar reminders to renew them. cert-manager does all this automatically.
ClusterIssuer
What it is: A Kubernetes resource that tells cert-manager how to get certificates.
What it does:
- Defines which CA to use (Let’s Encrypt production, staging, etc.)
- Specifies which challenge method to use (HTTP-01 or DNS-01)
- Contains credentials for DNS providers (if using DNS-01)
Cluster vs Namespaced: A ClusterIssuer works across all namespaces. A regular Issuer only works in one namespace. I use ClusterIssuer so any application can request certificates.
External Secrets Operator (ESO)
What it is: A Kubernetes operator that syncs secrets from external sources.
What it does:
- Connects to secret stores like AWS Secrets Manager, HashiCorp Vault, etc.
- Creates Kubernetes Secrets from external secrets
- Keeps them in sync (refreshes periodically)
Why we need it: I need to give cert-manager a Cloudflare API token for DNS-01 challenges. But I can’t commit secrets to Git (that’s a security disaster). ESO lets me:
- Store the token in AWS Secrets Manager (secure, outside Git)
- Have ESO sync it into Kubernetes automatically
SecretStore
What it is: Tells External Secrets Operator where to find secrets.
What it does:
- Defines the connection to AWS Secrets Manager (or other providers)
- Contains authentication details
- Scoped to a namespace
ExternalSecret
What it is: Tells External Secrets Operator which secret to sync.
What it does:
- References a SecretStore
- Specifies which secret to fetch from AWS
- Creates a Kubernetes Secret with the fetched data
Ingress
What it is: A Kubernetes resource that manages external access to services.
What it does:
- Routes HTTP/HTTPS traffic to your services
- Handles TLS termination (decrypts HTTPS)
- Uses certificates from Kubernetes Secrets
With cert-manager: When you add the annotation cert-manager.io/cluster-issuer: "letsencrypt-dns01", cert-manager automatically requests a certificate for that ingress.
Part 4: The Implementation
Step 1: Create a Cloudflare API Token
Cloudflare manages my DNS. cert-manager needs API access to create TXT records.
- Go to https://dash.cloudflare.com/profile/api-tokens
- Click “Create Token”
- Choose “Custom token”
- Configure:
- Token name:
cert-manager-dns01 - Permissions:
ZoneβDNSβEdit - Zone Resources:
IncludeβSpecific zoneβlandryzetam.net
- Token name:
- Create and copy the token
Why these specific permissions? We’re following the principle of least privilege. This token can only edit DNS records for one specific zoneβit can’t access other Cloudflare features or other domains.
Step 2: Store the Token in AWS Secrets Manager
aws secretsmanager create-secret \
--name "cert-manager/cloudflare-api-token" \
--secret-string '{"api-token":"YOUR_TOKEN_HERE"}'
Why AWS Secrets Manager?
- Secrets never touch Git
- Access is controlled by IAM policies
- Audit trail of who accessed what
- Encryption at rest
Step 3: Update IAM Policy
External Secrets Operator runs with an IAM user that needs permission to read the new secret:
{
"Effect": "Allow",
"Action": [
"secretsmanager:GetSecretValue",
"secretsmanager:DescribeSecret"
],
"Resource": [
"arn:aws:secretsmanager:us-east-1:ACCOUNT:secret:cert-manager/*"
]
}
Step 4: Create the SecretStore
This tells ESO how to connect to AWS:
apiVersion: external-secrets.io/v1
kind: SecretStore
metadata:
name: aws-secret-store
namespace: cert-manager
spec:
provider:
aws:
service: SecretsManager
region: us-east-1
auth:
secretRef:
accessKeyIDSecretRef:
name: aws-credentials
key: AWS_ACCESS_KEY_ID
secretAccessKeySecretRef:
name: aws-credentials
key: AWS_SECRET_ACCESS_KEY
What’s happening here?
- We’re creating a SecretStore in the
cert-managernamespace - It connects to AWS Secrets Manager in
us-east-1 - Authentication uses AWS credentials stored in another Kubernetes secret
Step 5: Create the ExternalSecret
This tells ESO which secret to sync:
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: cloudflare-api-token
namespace: cert-manager
spec:
refreshInterval: 1h
secretStoreRef:
name: aws-secret-store
kind: SecretStore
target:
name: cloudflare-api-token-secret
data:
- secretKey: api-token
remoteRef:
key: cert-manager/cloudflare-api-token
property: api-token
What’s happening here?
- Every hour (
refreshInterval: 1h), ESO checks AWS - It fetches
cert-manager/cloudflare-api-tokenfrom AWS - It creates a Kubernetes Secret called
cloudflare-api-token-secret - The secret has a key
api-tokenwith the token value
Step 6: Create the DNS-01 ClusterIssuer
Now we tell cert-manager how to use DNS-01 with Cloudflare:
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: letsencrypt-dns01
spec:
acme:
server: https://acme-v02.api.letsencrypt.org/directory
email: [email protected]
privateKeySecretRef:
name: letsencrypt-dns01
solvers:
- dns01:
cloudflare:
apiTokenSecretRef:
name: cloudflare-api-token-secret
key: api-token
What’s happening here?
server: Points to Let’s Encrypt’s production APIemail: Let’s Encrypt sends expiry warnings hereprivateKeySecretRef: Where to store the ACME account private keysolvers: Use DNS-01 with Cloudflare, using our synced API token
Step 7: Update the Ingress
Finally, tell your ingress to use the new issuer:
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: paperless-ngx
namespace: paperless-ngx
annotations:
cert-manager.io/cluster-issuer: "letsencrypt-dns01"
spec:
ingressClassName: traefik
rules:
- host: paperless.landryzetam.net
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: paperless-ngx
port:
number: 8000
tls:
- hosts:
- paperless.landryzetam.net
secretName: paperless-tls-cert
The magic annotation: cert-manager.io/cluster-issuer: "letsencrypt-dns01" triggers cert-manager to:
- See the ingress wants TLS for
paperless.landryzetam.net - Check if
paperless-tls-certsecret exists - If not, request a certificate using the
letsencrypt-dns01issuer - Store the certificate in the secret
- Traefik picks up the secret and starts serving HTTPS
Part 5: Watching It Work
After applying the manifests, watch the certificate lifecycle:
# Check certificate status
$ kubectl get certificate -n paperless-ngx
NAME READY SECRET AGE
paperless-tls-cert False paperless-tls-cert 30s
# A challenge is created
$ kubectl get challenge -n paperless-ngx
NAME STATE DOMAIN AGE
paperless-tls-cert-1-xxx pending paperless.landryzetam.net 10s
# cert-manager created the DNS record!
$ dig +short TXT _acme-challenge.paperless.landryzetam.net
"-BiwNVBzCuCPW3_IPC1sZ2fZbNkRUB1SDQnYMFdq5FA"
# After ~60 seconds, certificate is ready
$ kubectl get certificate -n paperless-ngx
NAME READY SECRET AGE
paperless-tls-cert True paperless-tls-cert 90s
# HTTPS works!
$ curl -I https://paperless.landryzetam.net
HTTP/2 302
Part 6: The Complete Data Flow
Let’s trace what happens when you access https://paperless.landryzetam.net:
βββββββββββββββ ββββββββββββββββ βββββββββββββββ
β Browser ββββββΆβ Traefik ββββββΆβ Paperless β
β βββββββ (Ingress) βββββββ Service β
βββββββββββββββ ββββββββββββββββ βββββββββββββββ
β
β Uses certificate from
βΌ
ββββββββββββββββ
β K8s Secret β
β (TLS cert) β
ββββββββββββββββ
β²
β Created by
β
ββββββββββββββββ ββββββββββββββββ
β cert-manager ββββββΆβ Let's β
β βββββββ Encrypt β
ββββββββββββββββ ββββββββββββββββ
β
β Creates DNS record via
βΌ
ββββββββββββββββ
β Cloudflare β
β API β
ββββββββββββββββ
β²
β Token from
β
ββββββββββββββββ ββββββββββββββββ
β External ββββββΆβ AWS Secrets β
β Secrets Op βββββββ Manager β
ββββββββββββββββ ββββββββββββββββ
Key Takeaways
TLS certificates prove identity and enable encryption - Without them, anyone can eavesdrop or impersonate your services
DNS-01 is the answer for internal services - It proves domain ownership without requiring your service to be publicly accessible
cert-manager automates the entire certificate lifecycle - Request, challenge, issuance, renewalβall hands-free
External Secrets keeps credentials out of Git - Store sensitive data in AWS Secrets Manager, sync it into Kubernetes automatically
One ClusterIssuer serves all namespaces - Set it up once, use it everywhere with a simple annotation
Certificates auto-renew - cert-manager handles renewal before expiry, no manual intervention needed