A cert-manager webhook solver for DNS-01 challenges using libdns providers. This allows you to use any DNS provider that has a libdns implementation for ACME DNS-01 certificate validation.
Currently enabled providers:
| Provider | Version | Credential Keys | Documentation |
|---|---|---|---|
| Alidns | v1.0.6-beta.3 | access_key_id, access_key_secret |
libdns/alidns |
| Cloudflare | latest | api_token |
libdns/cloudflare |
| deSEC | v1.0.1 | api_token |
libdns/desec |
| Hetzner | v2.0.1 | api_token |
libdns/hetzner |
| Linode | v0.5.0 | api_token |
libdns/linode |
| OVH | v1.1.0 | endpoint, application_key, application_secret, consumer_key |
libdns/ovh |
| Route53 | v1.6.0 | access_key_id, secret_access_key, region |
libdns/route53 |
Additional providers can be added easily - see Adding a New Provider section.
The repository includes an automated compatibility check against github.com/libdns/libdns v1.1.1.
- Latest generated report:
docs/01_provider-compat-latest.md - Current snapshot in that report: 68 compatible, 21 incompatible, 1 skipped
- Workflow that updates the report:
.github/workflows/libdns-provider-compat.yml
- Kubernetes cluster or OpenShift with cert-manager installed
- Helm v3+ for deployment
- Podman or Buildah for building the image
- A container registry (e.g., ghcr.io) for storing the image
# For GitHub Container Registry (ghcr.io)
echo "<GITHUB_PAT>" | podman login ghcr.io -u <username> --password-stdinYou need a GitHub PAT with
write:packagesscope. Create one at https://github.com/settings/tokens
podman build -t ghcr.io/<your-org>/cert-manager:v1 .For multi-arch builds (amd64 + arm64):
podman manifest create ghcr.io/<your-org>/cert-manager:v1
podman build --platform linux/amd64,linux/arm64 \
--manifest ghcr.io/<your-org>/cert-manager:v1 .
podman manifest push ghcr.io/<your-org>/cert-manager:v1podman push ghcr.io/<your-org>/cert-manager:v1If the image is public, no further configuration is needed.
If the image is private, create an ImagePullSecret in the target namespace:
kubectl create secret docker-registry ghcr-pull-secret \
--docker-server=ghcr.io \
--docker-username=<username> \
--docker-password=<GITHUB_PAT> \
-n cert-managerThen add it to the Helm install:
--set imagePullSecrets[0].name=ghcr-pull-secretkubectl apply -f https://github.com/cert-manager/cert-manager/releases/download/v1.16.2/cert-manager.yamlConfigure external DNS servers for DNS-01 challenge validation:
kubectl patch deployment cert-manager -n cert-manager --type='json' \
-p='[{"op": "add", "path": "/spec/template/spec/containers/0/args/-", "value": "--dns01-recursive-nameservers=8.8.8.8:53,1.1.1.1:53"},
{"op": "add", "path": "/spec/template/spec/containers/0/args/-", "value": "--dns01-recursive-nameservers-only"}]'If the cert-manager operator is already installed, configure it via the CertManager resource:
apiVersion: operator.openshift.io/v1alpha1
kind: CertManager
metadata:
name: cluster
spec:
controllerConfig:
overrideArgs:
- "--dns01-recursive-nameservers=8.8.8.8:53,1.1.1.1:53"
- "--dns01-recursive-nameservers-only"oc apply -f certmanager-config.yamlVerify the configuration was applied:
oc get pods -n cert-manager -l app=cert-manager
oc logs -n cert-manager -l app=cert-manager | grep recursiveNote: The cert-manager namespace may be
cert-managerorcert-manager-operatordepending on the operator version. Check with:oc get pods -A | grep cert-manager
helm install libdns-webhook ./deploy/libdns-webhook \
--namespace cert-manager \
--set image.repository=ghcr.io/<your-org>/cert-manager \
--set image.tag=v1 \
--set groupName=acme.yourdomain.comImportant: The groupName must be a unique domain you control to avoid conflicts with other webhooks.
On OpenShift, adjust the namespace and cert-manager service account if needed:
helm install libdns-webhook ./deploy/libdns-webhook \
--namespace openshift-cert-manager \
--set image.repository=ghcr.io/<your-org>/cert-manager \
--set image.tag=v1 \
--set groupName=acme.yourdomain.com \
--set certManager.namespace=openshift-cert-manager \
--set certManager.serviceAccountName=cert-managerVerify the deployment:
# Check the webhook pod
kubectl get pods -n cert-manager -l app.kubernetes.io/name=libdns-webhook
kubectl logs -n cert-manager -l app.kubernetes.io/name=libdns-webhook
# Check the APIService is registered
kubectl get apiservices | grep acme.yourdomain.comCreate a Kubernetes Secret containing your DNS provider credentials (see Provider-Specific Notes for required keys):
apiVersion: v1
kind: Secret
metadata:
name: dns-provider-credentials
namespace: cert-manager
type: Opaque
stringData:
api_token: "<YOUR_API_TOKEN>"kubectl apply -f dns-credentials.yamlapiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: letsencrypt-staging
spec:
acme:
server: https://acme-staging-v02.api.letsencrypt.org/directory
email: your-email@example.com
privateKeySecretRef:
name: letsencrypt-staging-account
solvers:
- dns01:
webhook:
groupName: acme.yourdomain.com
solverName: libdns
config:
provider: desec
ttl: 3600
secretRef:
name: dns-provider-credentials
namespace: cert-managerRecommendation: Test with the Staging server first to avoid hitting Let's Encrypt rate limits.
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: letsencrypt-prod
spec:
acme:
server: https://acme-v02.api.letsencrypt.org/directory
email: your-email@example.com
privateKeySecretRef:
name: letsencrypt-prod-account
solvers:
- dns01:
webhook:
groupName: acme.yourdomain.com
solverName: libdns
config:
provider: desec
ttl: 3600
secretRef:
name: dns-provider-credentials
namespace: cert-managerapiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: my-certificate
namespace: default
spec:
secretName: my-tls-secret
issuerRef:
name: letsencrypt-prod
kind: ClusterIssuer
dnsNames:
- example.com
- "*.example.com"
duration: 2160h # 90 days
renewBefore: 720h # Renew 30 days before expiry# Certificate status
kubectl get certificate -n default
kubectl describe certificate my-certificate -n default
# Challenge status (if pending)
kubectl get challenges -A
kubectl describe challenge -A
# Webhook logs
kubectl logs -n cert-manager -l app.kubernetes.io/name=libdns-webhook -f
# Verify DNS TXT record
dig TXT _acme-challenge.example.com @8.8.8.8Once the certificate is issued, it will be stored in the Secret specified by secretName:
kubectl get secret my-tls-secret -n default -o jsonpath='{.data.tls\.crt}' | base64 -d | openssl x509 -text -nooutThe webhook binary supports the following options:
# Show webhook version (SemVer)
./webhook --version
# List compiled-in DNS providers
./webhook --list-providers
# Example output:
# v0.5.0
#
# Compiled-in DNS providers:
# - alidns
# - cloudflare
# - desec
# - hetzner
# - linode
# - ovh
# - route53This is useful to verify which providers are available in your build.
The webhook configuration is provided in the ClusterIssuer/Issuer config section:
| Field | Type | Required | Description |
|---|---|---|---|
provider |
string | Yes | DNS provider name (desec, cloudflare, hetzner, route53, alidns, ovh, linode) |
secretRef.name |
string | Yes | Name of the Kubernetes Secret with provider credentials |
secretRef.namespace |
string | No | Namespace of the Secret (defaults to challenge namespace) |
ttl |
int | No | DNS record TTL in seconds (default: 300; for deSEC values below 3600 are automatically raised to 3600) |
zone |
string | No | Override the auto-detected DNS zone |
deSEC / Cloudflare / Hetzner / Linode (single API token):
apiVersion: v1
kind: Secret
metadata:
name: dns-provider-credentials
namespace: cert-manager
type: Opaque
stringData:
api_token: "<YOUR_API_TOKEN>"Route53 (AWS credentials):
apiVersion: v1
kind: Secret
metadata:
name: route53-credentials
namespace: cert-manager
type: Opaque
stringData:
access_key_id: "AKIAIOSFODNN7EXAMPLE"
secret_access_key: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
region: "us-east-1" # optional
session_token: "" # optional, for temporary credentialsAlidns (Alibaba Cloud):
apiVersion: v1
kind: Secret
metadata:
name: alidns-credentials
namespace: cert-manager
type: Opaque
stringData:
access_key_id: "<ACCESS_KEY_ID>"
access_key_secret: "<ACCESS_KEY_SECRET>"
region_id: "cn-hangzhou" # optional
security_token: "" # optional, for temporary credentialsOVH:
apiVersion: v1
kind: Secret
metadata:
name: ovh-credentials
namespace: cert-manager
type: Opaque
stringData:
endpoint: "ovh-eu" # ovh-eu, ovh-ca, ovh-us, kimsufi-eu, etc.
application_key: "<APP_KEY>"
application_secret: "<APP_SECRET>"
consumer_key: "<CONSUMER_KEY>"Key values that can be overridden during helm install:
| Value | Default | Description |
|---|---|---|
groupName |
acme.example.com |
API group name (must be a domain you own) |
image.repository |
ghcr.io/your-org/cert-manager |
Container image repository |
image.tag |
latest |
Image tag |
image.pullPolicy |
IfNotPresent |
Image pull policy |
certManager.namespace |
cert-manager |
Namespace where cert-manager is installed |
certManager.serviceAccountName |
cert-manager |
cert-manager service account name |
replicaCount |
1 |
Number of webhook replicas |
logLevel |
2 |
klog verbosity level |
- Minimum TTL is 3600 seconds (enforced by deSEC). If omitted or lower, webhook automatically uses 3600.
- API to DNS propagation can take up to 2 minutes
- API token can be created at https://desec.io/tokens
- Use an API token with
Zone:DNS:Editpermissions - Scoped tokens are recommended over global API keys
- IAM user needs
route53:ChangeResourceRecordSetsandroute53:ListHostedZonespermissions - The
regionfield is optional (defaults tous-east-1)
- Create an AccessKey at https://ram.console.aliyun.com/manage/ak
- The
region_idfield is optional (defaults tocn-hangzhou) - For temporary credentials, provide
security_token
- Create API credentials at https://api.ovh.com/createToken/
- Required permissions:
GET /domain/zone/*,POST /domain/zone/*,PUT /domain/zone/*,DELETE /domain/zone/* - Endpoint values:
ovh-eu,ovh-ca,ovh-us,kimsufi-eu,kimsufi-ca,soyoustart-eu,soyoustart-ca
- Create an API token at https://cloud.linode.com/profile/tokens
- Token requires
Domainsread/write permissions
- Create an API token at https://dns.hetzner.com/settings/api-token
- Token requires read/write permissions
When requesting both a wildcard (*.example.com) and base domain (example.com) certificate, the webhook handles creating multiple TXT records at _acme-challenge.example.com with different values.
dnsNames:
- example.com # Needs TXT record with key1
- "*.example.com" # Needs TXT record with key2 (same DNS name!)The webhook uses GetRecords + SetRecords to merge multiple TXT values at the same DNS name.
kubectl logs -n cert-manager -l app.kubernetes.io/name=libdns-webhookkubectl get challenges -A
kubectl describe challenge <challenge-name> -n <namespace>kubectl get apiservices | grep acme.yourdomain.com
kubectl get apiservice v1alpha1.acme.yourdomain.com -o yaml1. "DNS record not yet propagated"
- Wait for DNS TTL to expire (can be up to 1 hour for providers with high minimum TTL like deSEC)
- Verify the TXT record exists:
dig TXT _acme-challenge.yourdomain.com @8.8.8.8 - deSEC API-to-DNS propagation can take up to 2 minutes
2. "failed to get secret"
- Ensure the credentials secret exists in the correct namespace
- Check RBAC permissions for the webhook service account
- The Helm chart creates a
secret-readerClusterRole automatically
3. Pod fails to start on OpenShift
- The Helm chart is configured for OpenShift SCC compatibility (
runAsNonRoot: true, norunAsUser/fsGroup) - Do not set
runAsUserorfsGroupexplicitly - let OpenShift assign UIDs from the namespace range
4. "unknown DNS provider"
- Check that the provider name in the ClusterIssuer config matches a registered provider
- Currently available:
alidns,cloudflare,desec,hetzner,linode,ovh,route53 - Use
--list-providersto see compiled-in providers
5. APIService not registered
- Check that cert-manager CA injection is working:
kubectl get apiservice v1alpha1.acme.yourdomain.com -o yaml | grep caBundle - Verify the webhook service is reachable:
kubectl get svc -n cert-manager -l app.kubernetes.io/name=libdns-webhook
6. Image pull errors
- For ghcr.io: set the package visibility to Public under
https://github.com/users/<user>/packages/container/<package>/settings - Or create an ImagePullSecret (see Step 1.4)
Run tests without external control-plane dependencies:
make testThis example shows how to add the Hetzner provider (v2.0.1) which supports the latest libdns v1.1.1.
Ensure the libdns provider supports libdns v1.1.1. Check the provider's go.mod:
curl -sL "https://raw.githubusercontent.com/libdns/hetzner/v2.0.1/go.mod" | grep libdns
# Should show: github.com/libdns/libdns v1.1.1For the full and current compatible/incompatible provider list, use the generated compatibility report:
docs/01_provider-compat-latest.md.
Note: Providers with v2+ use a different module path (e.g.,
/v2suffix), and compatibility can change over time as provider modules evolve.
Create providers/hetzner.go:
package providers
import (
"fmt"
hetzner "github.com/libdns/hetzner/v2" // Note: v2 module path
)
func init() {
Register("hetzner", NewHetznerProvider)
}
// NewHetznerProvider creates a Hetzner DNS provider
//
// Required credentials:
// - api_token: Hetzner DNS API token
func NewHetznerProvider(config ProviderConfig) (DNSProvider, error) {
apiToken := config.Credentials["api_token"]
if apiToken == "" {
return nil, fmt.Errorf("hetzner: api_token is required")
}
return &hetzner.Provider{
AuthAPIToken: apiToken,
}, nil
}Add the provider to go.mod:
require (
// ... existing dependencies ...
github.com/libdns/hetzner/v2 v2.0.1
)Or use go get:
go get github.com/libdns/hetzner/v2@v2.0.1Run go mod tidy to update go.sum:
go mod tidy# Build the image
podman build -t your-registry/libdns-webhook:v2 .
# Push and update deployment
podman push your-registry/libdns-webhook:v2
kubectl set image deployment/libdns-webhook -n cert-manager \
libdns-webhook=your-registry/libdns-webhook:v2Create credentials secret:
apiVersion: v1
kind: Secret
metadata:
name: hetzner-credentials
namespace: cert-manager
type: Opaque
stringData:
api_token: "your-hetzner-dns-api-token"Reference in ClusterIssuer:
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: letsencrypt-prod
spec:
acme:
server: https://acme-v02.api.letsencrypt.org/directory
email: your-email@example.com
privateKeySecretRef:
name: letsencrypt-prod-account
solvers:
- dns01:
webhook:
groupName: acme.yourdomain.com
solverName: libdns
config:
provider: hetzner
ttl: 300 # Hetzner allows lower TTLs than deSEC
secretRef:
name: hetzner-credentials
namespace: cert-managerEach libdns provider must implement the DNSProvider interface:
type DNSProvider interface {
libdns.RecordAppender // AppendRecords(ctx, zone, records)
libdns.RecordDeleter // DeleteRecords(ctx, zone, records)
libdns.RecordGetter // GetRecords(ctx, zone)
libdns.RecordSetter // SetRecords(ctx, zone, records)
}The webhook uses:
GetRecords+SetRecordsfor Present() (to merge multiple TXT values)GetRecords+SetRecordsorDeleteRecordsfor CleanUp()
go test ./... ┌─────────────────┐
│ cert-manager │
└────────┬────────┘
│
│ DNS-01 Challenge
▼
┌───────────────────────────────────────────────────────────────┐
│ libdns-webhook │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ Present() │ │ CleanUp() │ │ Initialize() │ │
│ └──────┬───────┘ └──────┬───────┘ └──────────────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌─────────────────────────────────┐ │
│ │ Provider Registry │ │
│ │ ┌──────────┐ ┌──────────┐ │ │
│ │ │cloudflare│ │ desec │ ... │ │
│ │ └──────────┘ └──────────┘ │ │
│ └─────────────────────────────────┘ │
└───────────────────────────────────────────────────────────────┘
│
│ libdns interface
▼
┌─────────────────┐
│ DNS Provider │
│ API │
└─────────────────┘
MIT License - see LICENSE for details.
Contributions are welcome! Please open an issue or submit a pull request.