ClouDNS Webhook for cert-manager
A cert-manager DNS01 ACME challenge webhook that manages DNS records via ClouDNS. Built on Kipchak (PHP).
How it works
cert-manager calls this service via the Kubernetes API server proxy whenever it needs to create or delete a _acme-challenge TXT record. The webhook handles both present (create) and cleanup (delete) actions through a single endpoint:
POST /apis/acme.mamluk.net/v1alpha1/cloudns
The webhook is registered with Kubernetes as an APIService resource (v1alpha1.acme.mamluk.net). This causes the Kubernetes API server to proxy cert-manager's challenge requests to the PHP service running inside the cluster.
Prerequisites
- Kubernetes cluster with cert-manager v1.x installed
- At least one ClouDNS account with API credentials (auth ID + auth password)
- Docker and access to a container registry (if you want to build the Docker image yourself)
Configuration
This webhook supports two separate ClouDNS accounts (Premium and Geo DNS), allowing you to manage DNS challenges across different account types.
| Environment variable | Description | Default |
|---|---|---|
CLOUDNS_AUTH_ID_PREMIUM | ClouDNS Premium API authentication ID | — |
CLOUDNS_AUTH_PASSWORD_PREMIUM | ClouDNS Premium API authentication password | — |
CLOUDNS_AUTH_ID_GEO | ClouDNS Geo DNS API authentication ID | — |
CLOUDNS_AUTH_PASSWORD_GEO | ClouDNS Geo DNS API authentication password | — |
CLOUDNS_TTL | TTL for DNS challenge records (seconds) | 60 |
DEBUG | Enable debug logging | false |
Integrating with an existing cert-manager installation
This section walks through deploying the webhook into a cluster where cert-manager is already running. None of the steps below touch your existing cert-manager installation — the webhook is a standalone deployment in the cert-manager namespace that cert-manager discovers automatically via the APIService registration.
Step 1 — Verify cert-manager is running
kubectl get pods -n cert-manager
You should see cert-manager, cert-manager-cainjector, and cert-manager-webhook pods all in Running state. The minimum supported version is cert-manager v1.0.
kubectl get crds | grep cert-manager.io
Confirm the cert-manager CRDs are present (certificates.cert-manager.io, clusterissuers.cert-manager.io, etc.).
Step 2 — Create the ClouDNS credentials secret
The webhook requires credentials for both Premium and Geo DNS accounts. Use the provided template to create the secret:
# Copy and edit the template
cp .k8s/secret.yaml.template .k8s/secret.yaml
# Edit .k8s/secret.yaml and fill in your credentials
# Then apply:
kubectl apply -f .k8s/secret.yaml
Or create it directly with kubectl:
kubectl create secret generic cloudns-api-credentials \
--namespace cert-manager \
--from-literal=auth-id-premium=YOUR_PREMIUM_AUTH_ID \
--from-literal=auth-password-premium=YOUR_PREMIUM_AUTH_PASSWORD \
--from-literal=auth-id-geo=YOUR_GEO_AUTH_ID \
--from-literal=auth-password-geo=YOUR_GEO_AUTH_PASSWORD
Verify it was created:
kubectl get secret cloudns-api-credentials -n cert-manager
Step 3 — Build and push the Docker image
docker build -t your-registry/cm-webhook-cloudns:latest .
docker push your-registry/cm-webhook-cloudns:latest
Then update the image: field in .k8s/deployment.yaml to point to your registry before deploying.
Step 4 — Configure the ClusterIssuers
The webhook now supports two separate ClusterIssuer resources for Premium and Geo DNS accounts. Use the provided template:
# Copy and edit the template
cp .k8s/clusterissuer.yaml.template .k8s/clusterissuer.yaml
# Edit .k8s/clusterissuer.yaml and update email addresses and dnsZones
# Then apply it in Step 5
The template includes two ClusterIssuer resources:
-
Premium DNS (
letsencrypt-prod-cloudns-premium):- Uses
groupName: acme.premium.mamluk.net - Configure
dnsZonesfor domains managed by your Premium account
- Uses
-
Geo DNS (
letsencrypt-prod-cloudns-geo):- Uses
groupName: acme.geo.mamluk.net - Configure
dnsZonesfor domains managed by your Geo DNS account
- Uses
To use the Let's Encrypt staging environment while testing, replace the server URL with:
https://acme-staging-v02.api.letsencrypt.org/directory
Step 5 — Deploy
Deploy all manifests from the .k8s/ directory:
kubectl apply -f .k8s/serviceaccount.yaml
kubectl apply -f .k8s/role-and-binding.yaml
kubectl apply -f .k8s/clusterrole-and-binding.yaml
kubectl apply -f .k8s/deployment.yaml
kubectl apply -f .k8s/service.yaml
kubectl apply -f .k8s/apiservice.yaml
kubectl apply -f .k8s/clusterissuer.yaml # After editing the template
This creates the following resources:
ServiceAccount/Role/RoleBinding/ClusterRole/ClusterRoleBinding— RBAC for the webhookDeployment— the PHP webhook service with both Premium and Geo DNS credentialsService— exposes the webhook on port 80 inside the clusterAPIService(bothv1alpha1.acme.premium.mamluk.netandv1alpha1.acme.geo.mamluk.net) — registers both webhook endpointsClusterIssuerresources — separate issuers for Premium and Geo DNS accounts
Note on TLS: The
APIServiceis configured withinsecureSkipTLSVerify: trueso the webhook can run plain HTTP inside the cluster. For production, mount a TLS certificate into the pod, configure the server to serve HTTPS, and replaceinsecureSkipTLSVerify: truewithcaBundle: <base64-encoded-CA>in theAPIService.
Step 6 — Verify the deployment
Check the webhook pod is running:
kubectl get pods -n cert-manager -l app=cm-webhook-cloudns
Check both APIService resources are available (this is how cert-manager finds the webhook):
kubectl get apiservice v1alpha1.acme.premium.mamluk.net
kubectl get apiservice v1alpha1.acme.geo.mamluk.net
The AVAILABLE column must show True for both. If it shows False, the pod is likely not ready yet or the Service is misconfigured — check with:
kubectl describe apiservice v1alpha1.acme.premium.mamluk.net
kubectl describe apiservice v1alpha1.acme.geo.mamluk.net
kubectl logs -n cert-manager -l app=cm-webhook-cloudns
Check both ClusterIssuer resources are ready:
kubectl get clusterissuer letsencrypt-prod-cloudns-premium
kubectl get clusterissuer letsencrypt-prod-cloudns-geo
The READY column should show True within a minute or two for both issuers.
Step 7 — Request a certificate
Choose the appropriate ClusterIssuer based on which ClouDNS account manages your domain:
For domains managed by Premium DNS:
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: example-com-tls
namespace: default
spec:
secretName: example-com-tls
issuerRef:
name: letsencrypt-prod-cloudns-premium
kind: ClusterIssuer
dnsNames:
- example.com
- "*.example.com"
For domains managed by Geo DNS:
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: example-com-tls
namespace: default
spec:
secretName: example-com-tls
issuerRef:
name: letsencrypt-prod-cloudns-geo
kind: ClusterIssuer
dnsNames:
- example.com
- "*.example.com"
kubectl apply -f certificate.yaml
kubectl describe certificate example-com-tls -n default
Watching cert-manager logs to confirm the webhook is called
In one terminal, tail the cert-manager controller logs:
kubectl logs -n cert-manager -l app=cert-manager -f
In another, tail the webhook pod logs:
kubectl logs -n cert-manager -l app=cm-webhook-cloudns -f
When cert-manager processes a DNS01 challenge you will see it call the appropriate webhook endpoint:
- Premium:
POST /apis/acme.premium.mamluk.net/v1alpha1/cloudns - Geo DNS:
POST /apis/acme.geo.mamluk.net/v1alpha1/cloudns
The webhook is called with action present to create the TXT record, then later with action cleanup to delete it.
Troubleshooting
APIService is not Available
kubectl describe apiservice v1alpha1.acme.premium.mamluk.net
kubectl describe apiservice v1alpha1.acme.geo.mamluk.net
Common causes:
- The webhook pod has not started yet — wait for
Runningstate. - The
Serviceport does not match what the pod listens on (check deployment.yaml and service.yaml). - The pod is crashing — check
kubectl logsfor startup errors.
ClusterIssuer stays NotReady
kubectl describe clusterissuer letsencrypt-prod-cloudns-premium
kubectl describe clusterissuer letsencrypt-prod-cloudns-geo
- If the ACME registration fails, check that the email address is valid and reachable.
- If the webhook call fails during registration, confirm the corresponding
APIServiceisAvailable.
Certificate stuck in "Pending"
kubectl describe certificate <name> -n <namespace>
kubectl describe challenge -n <namespace>
- Confirm the
dnsZonesin theClusterIssuermatch the domain in theCertificate. - Ensure you're using the correct
ClusterIssuer(Premium or Geo) for the account that manages your domain. - Check that the ClouDNS credentials are correct and the domain is managed by the corresponding ClouDNS account.
- DNS propagation after the TXT record is created can take up to 60 seconds depending on your
CLOUDNS_TTLsetting. cert-manager polls until the record is visible.
TXT record created but cert-manager does not detect it
cert-manager uses its own recursive DNS resolvers to verify TXT records. If you have split-horizon DNS or a very short TTL, increase CLOUDNS_TTL to 120 or higher and ensure the record is publicly visible:
dig TXT _acme-challenge.example.com @8.8.8.8
Checking webhook request/response directly
You can call the webhook endpoints manually through kubectl proxy:
Premium endpoint:
kubectl proxy &
curl -s -X POST \
http://localhost:8001/apis/acme.premium.mamluk.net/v1alpha1/cloudns \
-H "Content-Type: application/json" \
-d '{
"apiVersion": "acme.cert-manager.io/v1alpha1",
"kind": "ChallengePayload",
"request": {
"uid": "test-manual",
"action": "present",
"type": "dns-01",
"dnsName": "example.com",
"key": "test-token",
"resolvedFQDN": "_acme-challenge.example.com.",
"resolvedZone": "example.com.",
"allowAmbientCredentials": false
}
}'
Geo DNS endpoint:
Replace acme.premium.mamluk.net with acme.geo.mamluk.net in the URL above.
A successful response looks like:
{
"apiVersion": "acme.cert-manager.io/v1alpha1",
"kind": "ChallengePayload",
"response": {
"uid": "test-manual",
"success": true
}
}
Local development
Update docker-compose.yml with your ClouDNS credentials, then:
docker-compose up -d
Testing the webhook locally
Present (create TXT record):
Test the Premium endpoint:
curl -s -X POST http://localhost:80/apis/acme.premium.mamluk.net/v1alpha1/cloudns \
-H "Content-Type: application/json" \
-d '{
"apiVersion": "acme.cert-manager.io/v1alpha1",
"kind": "ChallengePayload",
"request": {
"uid": "test-1",
"action": "present",
"type": "dns-01",
"dnsName": "example.com",
"key": "test-challenge-value",
"resolvedFQDN": "_acme-challenge.example.com.",
"resolvedZone": "example.com.",
"allowAmbientCredentials": false
}
}'
Or test the Geo DNS endpoint (replace acme.premium.mamluk.net with acme.geo.mamluk.net).
Cleanup (delete TXT record):
Same payload with "action": "cleanup".
Health check:
curl http://localhost:80/healthz
Running tests
composer test
Project structure
api/
Controllers/
Base.php — base controller (wires up ClouDNS driver)
Healthz.php — GET /healthz
v1/
WebhookController.php — handles webhook requests for both Premium and Geo accounts
Interfaces/
DnsOperations.php — interface for DNS operations (enables mocking in tests)
Models/
WebhookRequest.php — parses cert-manager ChallengePayload
Services/
ClouDnsAdapter.php — adapts kipchak/driver-cloudns to DnsOperations interface
config/
kipchak.api.php — app name, debug flag, log level
kipchak.cloudns.php — ClouDNS connection credentials (Premium and Geo)
drivers/
drivers.php — Kipchak driver initialisation
routes/
webhook.php — route definitions for both webhook endpoints
.k8s/
apiservice.yaml — APIService registrations for both Premium and Geo
clusterissuer.yaml.template — Template for ClusterIssuer resources (fill in and apply)
clusterrole-and-binding.yaml — Cluster-wide RBAC
crds.yaml — Custom Resource Definitions
deployment.yaml — Deployment with both account credentials
role-and-binding.yaml — Namespace RBAC
secret.yaml.template — Template for secret with both account credentials
service.yaml — Service exposing the webhook
serviceaccount.yaml — ServiceAccount
cert-manager webhook protocol reference
cert-manager sends a ChallengePayload to the webhook:
{
"apiVersion": "acme.cert-manager.io/v1alpha1",
"kind": "ChallengePayload",
"request": {
"uid": "<unique-id>",
"action": "present",
"type": "dns-01",
"dnsName": "example.com",
"key": "<challenge-token>",
"resolvedFQDN": "_acme-challenge.example.com.",
"resolvedZone": "example.com.",
"allowAmbientCredentials": false
}
}
The webhook must respond with:
{
"apiVersion": "acme.cert-manager.io/v1alpha1",
"kind": "ChallengePayload",
"response": {
"uid": "<same-uid-from-request>",
"success": true
}
}
Acknowledgements
- cert-manager for the certificate management framework
- ClouDNS for DNS services and API
- Kipchak / Mamluk for the PHP API framework
License
MIT