Skip to content

libdns/cert-manager

Repository files navigation

cert-manager

Release

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.

Supported DNS Providers

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.

Compatibility Status (libdns v1.1.1)

The repository includes an automated compatibility check against github.com/libdns/libdns v1.1.1.

Prerequisites

  • 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

Deployment Guide

Step 1: Build and Push the Container Image

1.1 Login to your Container Registry

# For GitHub Container Registry (ghcr.io)
echo "<GITHUB_PAT>" | podman login ghcr.io -u <username> --password-stdin

You need a GitHub PAT with write:packages scope. Create one at https://github.com/settings/tokens

1.2 Build the Image

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:v1

1.3 Push the Image

podman push ghcr.io/<your-org>/cert-manager:v1

1.4 Image Visibility (ghcr.io)

If 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-manager

Then add it to the Helm install:

--set imagePullSecrets[0].name=ghcr-pull-secret

Step 2: Install and Configure cert-manager

Standard Kubernetes

kubectl apply -f https://github.com/cert-manager/cert-manager/releases/download/v1.16.2/cert-manager.yaml

Configure 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"}]'

OpenShift (with openshift-cert-manager-operator)

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.yaml

Verify the configuration was applied:

oc get pods -n cert-manager -l app=cert-manager
oc logs -n cert-manager -l app=cert-manager | grep recursive

Note: The cert-manager namespace may be cert-manager or cert-manager-operator depending on the operator version. Check with: oc get pods -A | grep cert-manager

Step 3: Deploy the Webhook

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.com

Important: 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-manager

Verify 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.com

Step 4: Create DNS Provider Credentials

Create 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.yaml

Step 5: Create a ClusterIssuer

Let's Encrypt Staging (recommended for testing)

apiVersion: 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-manager

Recommendation: Test with the Staging server first to avoid hitting Let's Encrypt rate limits.

Let's Encrypt Production

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-manager

Step 6: Request a Certificate

apiVersion: 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

Step 7: Verify

# 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.8

Once 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 -noout

Command Line Options

The 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
#   - route53

This is useful to verify which providers are available in your build.

Configuration Reference

Webhook Config Fields

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

Credential Secrets per Provider

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 credentials

Alidns (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 credentials

OVH:

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>"

Helm Values

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

Provider-Specific Notes

deSEC

  • 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

Cloudflare

  • Use an API token with Zone:DNS:Edit permissions
  • Scoped tokens are recommended over global API keys

Route53

  • IAM user needs route53:ChangeResourceRecordSets and route53:ListHostedZones permissions
  • The region field is optional (defaults to us-east-1)

Alidns (Alibaba Cloud)

OVH

  • 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

Linode

Hetzner

Wildcard + Base Domain Certificates

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.

Troubleshooting

Check Webhook Logs

kubectl logs -n cert-manager -l app.kubernetes.io/name=libdns-webhook

Check Challenge Status

kubectl get challenges -A
kubectl describe challenge <challenge-name> -n <namespace>

Check APIService Registration

kubectl get apiservices | grep acme.yourdomain.com
kubectl get apiservice v1alpha1.acme.yourdomain.com -o yaml

Common Issues

1. "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-reader ClusterRole automatically

3. Pod fails to start on OpenShift

  • The Helm chart is configured for OpenShift SCC compatibility (runAsNonRoot: true, no runAsUser/fsGroup)
  • Do not set runAsUser or fsGroup explicitly - 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-providers to 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)

Development

Testing

Run tests without external control-plane dependencies:

make test

Adding a New Provider

This example shows how to add the Hetzner provider (v2.0.1) which supports the latest libdns v1.1.1.

Step 1: Check Provider Compatibility

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.1

For 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., /v2 suffix), and compatibility can change over time as provider modules evolve.

Step 2: Create the Provider File

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
}

Step 3: Add the Dependency

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.1

Step 4: Update Dependencies

Run go mod tidy to update go.sum:

go mod tidy

Step 5: Rebuild and Deploy

# 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:v2

Step 6: Configure the Issuer

Create 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-manager

Provider Interface Requirements

Each 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 + SetRecords for Present() (to merge multiple TXT values)
  • GetRecords + SetRecords or DeleteRecords for CleanUp()

Running Tests

go test ./...

Architecture

                                    ┌─────────────────┐
                                    │   cert-manager  │
                                    └────────┬────────┘
                                             │
                                             │ DNS-01 Challenge
                                             ▼
┌───────────────────────────────────────────────────────────────┐
│                    libdns-webhook                             │
│  ┌──────────────┐  ┌──────────────┐  ┌──────────────┐         │
│  │   Present()  │  │   CleanUp()  │  │ Initialize() │         │
│  └──────┬───────┘  └──────┬───────┘  └──────────────┘         │
│         │                 │                                   │
│         ▼                 ▼                                   │
│  ┌─────────────────────────────────┐                          │
│  │      Provider Registry          │                          │
│  │  ┌──────────┐ ┌──────────┐     │                           │
│  │  │cloudflare│ │  desec   │ ... │                           │
│  │  └──────────┘ └──────────┘     │                           │
│  └─────────────────────────────────┘                          │
└───────────────────────────────────────────────────────────────┘
                                             │
                                             │ libdns interface
                                             ▼
                                    ┌─────────────────┐
                                    │   DNS Provider  │
                                    │      API        │
                                    └─────────────────┘

License

MIT License - see LICENSE for details.

Contributing

Contributions are welcome! Please open an issue or submit a pull request.

About

No description, website, or topics provided.

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors