Let's Encrypt certificates on Kubernetes with cert-manager
DNSCove ships a cert-manager ACME DNS-01 solver
webhook. It answers Let's Encrypt challenges by writing the _acme-challenge
TXT record into your DNSCove zone, so cert-manager can issue and renew
certificates — including wildcards — for any domain served by the DNSCove
edge, with no public HTTP endpoint required.
The webhook authenticates to DNSCove with a scoped, per-zone acme-dns01
token that can do exactly one thing: solve challenges on its one zone. It holds
no other DNS credentials, so a leaked token can only ever touch _acme-challenge
TXT records on that single zone.
cert-manager ──(DNS-01)──▶ DNSCove webhook (Present/CleanUp)
│ reads the scoped token from a Secret
▼
POST https://api.dnscove.com/api/zones/{zoneId}/acme-challenge
▼
control plane merges the _acme-challenge TXT set ──▶ publishes to edge
▼
Let's Encrypt resolves _acme-challenge.<name> from ns1/ns2 ✓
Before you start
- cert-manager installed on the cluster (tested against v1.20) with an ACME account email.
- The target zone is ACTIVE in DNSCove and delegated to ns1.dnscove.com / ns2.dnscove.org — Let's Encrypt validates against the authoritative servers, so the challenge name must be answerable by the DNSCove edge. See the quickstart to get a zone active.
- Your DNSCove zone id (
Z…, from the console orGET /api/zones).
1. Install the webhook
The webhook runs as an aggregated apiserver in the cert-manager namespace. Its
image is distributed on the public GitHub Container Registry
(ghcr.io/ryabinski-labs/dnscove-acme-webhook), so no image pull secret is
required. Apply the manifest below:
# dnscove-acme-webhook.yaml
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: dnscove-acme-webhook
namespace: cert-manager
labels: { app: dnscove-acme-webhook }
---
# The webhook is an aggregated apiserver: it authenticates incoming apiserver
# requests (auth-delegator) and reads the extension-apiserver auth configmap.
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: dnscove-acme-webhook:auth-delegator
labels: { app: dnscove-acme-webhook }
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: system:auth-delegator
subjects:
- kind: ServiceAccount
name: dnscove-acme-webhook
namespace: cert-manager
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: dnscove-acme-webhook:extension-apiserver-authentication-reader
namespace: kube-system
labels: { app: dnscove-acme-webhook }
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: extension-apiserver-authentication-reader
subjects:
- kind: ServiceAccount
name: dnscove-acme-webhook
namespace: cert-manager
---
# Aggregated apiservers need to read API priority & fairness config.
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: dnscove-acme-webhook:flowcontrol
labels: { app: dnscove-acme-webhook }
rules:
- apiGroups: ["flowcontrol.apiserver.k8s.io"]
resources: ["prioritylevelconfigurations", "flowschemas"]
verbs: ["list", "watch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: dnscove-acme-webhook:flowcontrol
labels: { app: dnscove-acme-webhook }
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: dnscove-acme-webhook:flowcontrol
subjects:
- kind: ServiceAccount
name: dnscove-acme-webhook
namespace: cert-manager
---
# The one custom permission: read the scoped acme-dns01 token Secret. Scoped to
# the cert-manager namespace (nothing cluster-wide).
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: dnscove-acme-webhook:secret-reader
namespace: cert-manager
labels: { app: dnscove-acme-webhook }
rules:
- apiGroups: [""]
resources: ["secrets"]
verbs: ["get", "watch", "list"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: dnscove-acme-webhook:secret-reader
namespace: cert-manager
labels: { app: dnscove-acme-webhook }
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: dnscove-acme-webhook:secret-reader
subjects:
- kind: ServiceAccount
name: dnscove-acme-webhook
namespace: cert-manager
---
# cert-manager itself must be allowed to call the webhook's solver API group.
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: dnscove-acme-webhook:domain-solver
labels: { app: dnscove-acme-webhook }
rules:
- apiGroups: ["acme.dnscove.com"]
resources: ["*"]
verbs: ["create"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: dnscove-acme-webhook:domain-solver
labels: { app: dnscove-acme-webhook }
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: dnscove-acme-webhook:domain-solver
subjects:
- kind: ServiceAccount
name: cert-manager
namespace: cert-manager
---
# Serving TLS — cert-manager issues the webhook's own certificate.
apiVersion: cert-manager.io/v1
kind: Issuer
metadata:
name: dnscove-acme-webhook-selfsign
namespace: cert-manager
labels: { app: dnscove-acme-webhook }
spec:
selfSigned: {}
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: dnscove-acme-webhook-serving
namespace: cert-manager
labels: { app: dnscove-acme-webhook }
spec:
secretName: dnscove-acme-webhook-serving-tls
duration: 8760h # 1y
issuerRef:
name: dnscove-acme-webhook-selfsign
dnsNames:
- dnscove-acme-webhook.cert-manager.svc
- dnscove-acme-webhook.cert-manager.svc.cluster.local
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: dnscove-acme-webhook
namespace: cert-manager
labels: { app: dnscove-acme-webhook }
spec:
replicas: 1
selector:
matchLabels: { app: dnscove-acme-webhook }
template:
metadata:
labels: { app: dnscove-acme-webhook }
spec:
serviceAccountName: dnscove-acme-webhook
containers:
- name: webhook
image: ghcr.io/ryabinski-labs/dnscove-acme-webhook:latest
imagePullPolicy: Always
args:
- --secure-port=8443
- --tls-cert-file=/tls/tls.crt
- --tls-private-key-file=/tls/tls.key
env:
- name: GROUP_NAME
value: acme.dnscove.com
ports:
- name: https
containerPort: 8443
protocol: TCP
livenessProbe:
httpGet: { scheme: HTTPS, path: /healthz, port: https }
initialDelaySeconds: 10
readinessProbe:
httpGet: { scheme: HTTPS, path: /healthz, port: https }
initialDelaySeconds: 5
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
runAsNonRoot: true
runAsUser: 65532
runAsGroup: 65532
capabilities: { drop: ["ALL"] }
resources:
requests: { cpu: 10m, memory: 32Mi }
limits: { memory: 64Mi }
volumeMounts:
- name: serving-tls
mountPath: /tls
readOnly: true
volumes:
- name: serving-tls
secret:
secretName: dnscove-acme-webhook-serving-tls
---
apiVersion: v1
kind: Service
metadata:
name: dnscove-acme-webhook
namespace: cert-manager
labels: { app: dnscove-acme-webhook }
spec:
type: ClusterIP
selector: { app: dnscove-acme-webhook }
ports:
- name: https
port: 443
targetPort: https
protocol: TCP
---
# Registers the solver API group; cainjector fills caBundle from the serving cert.
apiVersion: apiregistration.k8s.io/v1
kind: APIService
metadata:
name: v1alpha1.acme.dnscove.com
labels: { app: dnscove-acme-webhook }
annotations:
cert-manager.io/inject-ca-from: cert-manager/dnscove-acme-webhook-serving
spec:
group: acme.dnscove.com
groupPriorityMinimum: 1000
versionPriority: 15
service:
name: dnscove-acme-webhook
namespace: cert-manager
version: v1alpha1
kubectl apply -f dnscove-acme-webhook.yaml
kubectl -n cert-manager rollout status deploy/dnscove-acme-webhook
Pin the image to a released tag or
@sha256:digest in production instead of:latest.
2. Mint a scoped token and store it as a Secret
Create an acme-dns01 token bound to your zone — the narrowest scope there
is. From the console: open the zone → API tokens → new token → scope
acme-dns01. Or over the API with a session/tenant-admin token:
TOKEN=$(curl -fsS -X POST https://api.dnscove.com/api/zones/<ZONE_ID>/tokens \
-H "Authorization: Bearer <SESSION_OR_TENANT_TOKEN>" \
-d '{"name":"cert-manager webhook","scope":"acme-dns01"}' | jq -r .token)
kubectl -n cert-manager create secret generic dnscove-acme-token \
--from-literal=token="$TOKEN"
The Secret lives in the cert-manager namespace (where the webhook reads it) and
holds only the token.
3. Create a ClusterIssuer
Point a cert-manager ClusterIssuer at the webhook. Fill in your email and
zoneId, and set selector.dnsZones to the domains this issuer should handle.
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: letsencrypt-dnscove
spec:
acme:
email: you@example.com
server: https://acme-v02.api.letsencrypt.org/directory
privateKeySecretRef:
name: letsencrypt-dnscove-account-key
solvers:
- dns01:
webhook:
groupName: acme.dnscove.com
solverName: dnscove
config:
apiBase: https://api.dnscove.com
zoneId: "Z1a2b3c4d5e6f70" # your DNSCove zone id
tokenSecretRef:
name: dnscove-acme-token
key: token
selector:
dnsZones:
- example.com
The solver config has exactly three fields:
| Field | Meaning |
|---|---|
apiBase |
Control-plane base URL — https://api.dnscove.com |
zoneId |
The DNSCove zone id the token is bound to (Z…) |
tokenSecretRef.name / .key |
Secret in the cert-manager namespace holding the token (key defaults to token) |
Start on staging. For your first issue, swap
serverto the Let's Encrypt staging endpointhttps://acme-staging-v02.api.letsencrypt.org/directory(and a separateprivateKeySecretRef). Staging has generous rate limits; move to production once a certificate issues cleanly.
4. Request a certificate
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: example-com-tls
namespace: default
spec:
secretName: example-com-tls
issuerRef:
name: letsencrypt-dnscove
kind: ClusterIssuer
dnsNames:
- example.com
- "*.example.com" # wildcards work — DNS-01 is the only way to get them
Watch it issue:
kubectl describe certificate example-com-tls
kubectl get certificate example-com-tls -w # READY becomes True
Ingresses can request certs the same way with the
cert-manager.io/cluster-issuer: letsencrypt-dnscove annotation.
Troubleshooting
| Symptom | Likely cause |
|---|---|
Certificate stuck False, challenge pending |
The zone isn't ACTIVE/delegated yet, so Let's Encrypt can't resolve _acme-challenge from ns1/ns2. Confirm the zone serves. |
Webhook pod ImagePullBackOff |
The cluster can't pull ghcr.io/ryabinski-labs/dnscove-acme-webhook. Confirm the tag exists and your network can reach ghcr.io. |
Webhook pod CreateContainerConfigError |
Serving-TLS Secret not issued yet — check dnscove-acme-webhook-serving Certificate is Ready. |
Challenge fails with 403 |
The token's scope or zone binding is wrong. It must be an acme-dns01 (or zone-admin) token for this zoneId. |
apiservice ... not available |
cainjector hasn't populated the caBundle — check the cert-manager.io/inject-ca-from annotation and that cainjector is running. |
Notes
- Renewal depends on the DNSCove API. cert-manager renews automatically, and
each renewal calls
POST /api/zones/{id}/acme-challenge. Keep the token valid (or unexpiring) so renewals don't silently fail. - Same-cluster dogfooding. If the webhook runs in the same cluster as the
DNSCove control plane, set
apiBaseto the in-cluster Service DNS (e.g.http://dnscove-api.<namespace>.svc.cluster.local) — calling the cluster's own external load-balancer IP hairpins and resets. For every other cluster, the publichttps://api.dnscove.comis correct. - The raw ACME endpoint this builds on is documented in the
API reference (
POST /api/zones/{id}/acme-challenge) if you need to integrate a different ACME client.