---
title: "cert-manager and external-dns: Automatic TLS and DNS on Kubernetes"
description: "How to install and configure cert-manager for automatic TLS certificates and external-dns for automatic DNS record management, and how to use them together for fully automated Ingress setup."
url: https://agent-zone.ai/knowledge/kubernetes/cert-manager-and-external-dns/
section: knowledge
date: 2026-02-22
categories: ["kubernetes"]
tags: ["cert-manager","external-dns","tls","dns","lets-encrypt","ingress"]
skills: ["cert-manager-setup","external-dns-configuration","tls-automation","dns-automation"]
tools: ["kubectl","helm"]
levels: ["intermediate"]
word_count: 781
formats:
  json: https://agent-zone.ai/knowledge/kubernetes/cert-manager-and-external-dns/index.json
  html: https://agent-zone.ai/knowledge/kubernetes/cert-manager-and-external-dns/?format=html
  api: https://api.agent-zone.ai/api/v1/knowledge/search?q=cert-manager+and+external-dns%3A+Automatic+TLS+and+DNS+on+Kubernetes
---


# cert-manager and external-dns

These two controllers solve the two most tedious parts of exposing services on Kubernetes: getting TLS certificates and creating DNS records. Together, they make it so that creating an Ingress resource automatically provisions a DNS record pointing to your cluster and a valid TLS certificate for the hostname.

## cert-manager

cert-manager watches for Certificate resources and Ingress annotations, then obtains and renews TLS certificates automatically.

### Installation

```bash
helm repo add jetstack https://charts.jetstack.io
helm install cert-manager jetstack/cert-manager \
  --namespace cert-manager \
  --create-namespace \
  --set crds.enabled=true
```

The `crds.enabled=true` flag installs the CRDs as part of the Helm release. Verify with `kubectl get pods -n cert-manager` -- you should see cert-manager, cert-manager-cainjector, and cert-manager-webhook all Running.

### Issuer vs ClusterIssuer

An **Issuer** is namespace-scoped. It can only issue certificates for resources in its own namespace. A **ClusterIssuer** is cluster-scoped and works across all namespaces. For most setups, use a ClusterIssuer.

**ACME HTTP01 challenge (Let's Encrypt):**

```yaml
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: letsencrypt-prod
spec:
  acme:
    server: https://acme-v02.api.letsencrypt.org/directory
    email: ops@example.com
    privateKeySecretRef:
      name: letsencrypt-prod-key
    solvers:
      - http01:
          ingress:
            ingressClassName: nginx
```

HTTP01 works by cert-manager temporarily creating an Ingress that serves a challenge token at `http://<domain>/.well-known/acme-challenge/<token>`. This requires that your domain already points to the cluster's Ingress controller and that port 80 is reachable from the internet.

**ACME DNS01 challenge (for wildcards or private clusters):**

```yaml
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: letsencrypt-dns
spec:
  acme:
    server: https://acme-v02.api.letsencrypt.org/directory
    email: ops@example.com
    privateKeySecretRef:
      name: letsencrypt-dns-key
    solvers:
      - dns01:
          cloudflare:
            email: ops@example.com
            apiTokenSecretRef:
              name: cloudflare-api-token
              key: api-token
```

Create the API token secret:

```bash
kubectl create secret generic cloudflare-api-token \
  --from-literal=api-token=your-cloudflare-api-token \
  -n cert-manager
```

DNS01 is required for wildcard certificates (`*.example.com`). It proves domain ownership via TXT records. Supported providers include Cloudflare, Route53, Google Cloud DNS, and Azure DNS. For Route53, use IAM Roles for Service Accounts (IRSA) on EKS or explicit access keys.

### Always start with staging. Let's Encrypt production has strict rate limits. Use the staging server first:

```
server: https://acme-staging-v02.api.letsencrypt.org/directory
```

Switch to production only after confirming certificates are issued correctly.

### Certificate Resources and Ingress Integration

You can create Certificate resources explicitly, but the common pattern is letting cert-manager create them automatically from Ingress annotations:

```yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: app-ingress
  annotations:
    cert-manager.io/cluster-issuer: letsencrypt-prod
spec:
  ingressClassName: nginx
  tls:
    - hosts:
        - app.example.com
      secretName: app-tls
  rules:
    - host: app.example.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: web-app
                port:
                  number: 80
```

cert-manager sees the annotation, creates a Certificate resource, performs the ACME challenge, and populates the `app-tls` Secret. Renewal happens automatically 30 days before expiry.

Debug certificate issues by walking the chain: Certificate -> CertificateRequest -> Order -> Challenge. Use `kubectl describe` on each to find where it is stuck.

## external-dns

external-dns watches Services and Ingress resources, then creates DNS records in your DNS provider to match.

### Installation

```bash
helm repo add external-dns https://kubernetes-sigs.github.io/external-dns
helm install external-dns external-dns/external-dns \
  --namespace external-dns \
  --create-namespace \
  --set provider.name=cloudflare \
  --set env[0].name=CF_API_TOKEN \
  --set env[0].valueFrom.secretKeyRef.name=cloudflare-api-token \
  --set env[0].valueFrom.secretKeyRef.key=api-token \
  --set policy=upsert-only \
  --set domainFilters[0]=example.com
```

Key values: `provider.name` supports `cloudflare`, `aws` (Route53), `google` (Cloud DNS), `azure`, and more. Set `policy=upsert-only` to avoid accidental deletions (vs `sync` which deletes too). Always set `domainFilters` to restrict which zones external-dns can touch. Set `txtOwnerId` to a unique identifier when running multiple clusters.

### How It Works

external-dns reads the `external-dns.alpha.kubernetes.io/hostname` annotation on Ingress or Service resources:

```yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: app-ingress
  annotations:
    external-dns.alpha.kubernetes.io/hostname: app.example.com
    external-dns.alpha.kubernetes.io/ttl: "300"
spec:
  ingressClassName: nginx
  rules:
    - host: app.example.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: web-app
                port:
                  number: 80
```

external-dns creates an A record (or CNAME for LoadBalancer hostnames) pointing `app.example.com` to the Ingress controller's external IP. The same annotation works on Services of type LoadBalancer.

external-dns uses TXT ownership records to track which DNS records it manages, preventing conflicts with manually created records or other external-dns instances. If records are not being updated, check that TXT ownership records exist and match the current `txtOwnerId`.

## Using Both Together

The full automation flow: create an Ingress with both annotations, and the system handles the rest.

```yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: app-ingress
  annotations:
    cert-manager.io/cluster-issuer: letsencrypt-prod
    external-dns.alpha.kubernetes.io/hostname: app.example.com
spec:
  ingressClassName: nginx
  tls:
    - hosts:
        - app.example.com
      secretName: app-tls
  rules:
    - host: app.example.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: web-app
                port:
                  number: 80
```

The sequence: (1) external-dns creates the DNS A record. (2) cert-manager starts the ACME HTTP01 challenge. (3) Let's Encrypt resolves the domain to the cluster and validates. (4) cert-manager populates the TLS secret. (5) The Ingress controller serves HTTPS.

If you use HTTP01 challenges, the DNS record must exist before cert-manager can complete the challenge. external-dns typically creates records within 1-2 minutes, and cert-manager retries on failure, so they converge naturally. If issuance fails, check DNS propagation with `dig app.example.com` and verify port 80 is reachable.

