DNSCove

DNSCove guide

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

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 server to the Let's Encrypt staging endpoint https://acme-staging-v02.api.letsencrypt.org/directory (and a separate privateKeySecretRef). 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