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
- ClouDNS account with API credentials (auth ID + auth password)
- Docker and access to a container registry
Configuration
| Environment variable | Description | Default |
|---|---|---|
CLOUDNS_AUTH_ID | ClouDNS API authentication ID | — |
CLOUDNS_AUTH_PASSWORD | ClouDNS 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
kubectl create secret generic cloudns-api-credentials \
--namespace cert-manager \
--from-literal=auth-id=YOUR_AUTH_ID \
--from-literal=auth-password=YOUR_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/resources.yaml to point to your registry before deploying.
Step 4 — Configure the ClusterIssuer
Edit .k8s/resources.yaml and update the ClusterIssuer at the bottom of the file:
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: letsencrypt-prod-cloudns
spec:
acme:
email: your-email@example.com # <-- your email for Let's Encrypt
server: https://acme-v02.api.letsencrypt.org/directory
privateKeySecretRef:
name: letsencrypt-prod-cloudns
solvers:
- dns01:
webhook:
groupName: acme.mamluk.net
solverName: cloudns
selector:
dnsZones:
- example.com # <-- your domain(s) managed by ClouDNS
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
kubectl apply -f .k8s/resources.yaml
This creates the following resources in the cert-manager namespace (nothing is created in other namespaces at this point):
ServiceAccount/Role/RoleBinding— RBAC for the webhook pod to read the credentials secretDeployment— the PHP webhook serviceService— exposes the webhook on port 80 inside the clusterAPIService(v1alpha1.acme.mamluk.net) — registers the webhook with the Kubernetes API serverClusterIssuer(letsencrypt-prod-cloudns) — a cluster-wide issuer that routes DNS01 challenges to this webhook
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 the APIService is available (this is how cert-manager finds the webhook):
kubectl get apiservice v1alpha1.acme.mamluk.net
The AVAILABLE column must show True. If it shows False, the pod is likely not ready yet or the Service is misconfigured — check with:
kubectl describe apiservice v1alpha1.acme.mamluk.net
kubectl logs -n cert-manager -l app=cm-webhook-cloudns
Check the ClusterIssuer is ready:
kubectl get clusterissuer letsencrypt-prod-cloudns
The READY column should show True within a minute or two.
Step 7 — 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-prod-cloudns
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 webhook (POST /apis/acme.mamluk.net/v1alpha1/cloudns) with action present, then later with action cleanup.
Troubleshooting
APIService is not Available
kubectl describe apiservice v1alpha1.acme.mamluk.net
Common causes:
- The webhook pod has not started yet — wait for
Runningstate. - The
Serviceport does not match what the pod listens on (pod: 8080, service: 80 — check resources.yaml). - The pod is crashing — check
kubectl logsfor startup errors.
ClusterIssuer stays NotReady
kubectl describe clusterissuer letsencrypt-prod-cloudns
- If the ACME registration fails, check that the email address is valid and reachable.
- If the webhook call fails during registration, confirm the
APIServiceisAvailable.
Certificate stuck in "Pending"
kubectl describe certificate <name> -n <namespace>
kubectl describe challenge -n <namespace>
- Confirm the
dnsZonesin theClusterIssuermatch the domain in theCertificate. - Check that the ClouDNS credentials are correct and the domain is managed by the 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 endpoint manually through kubectl proxy:
kubectl proxy &
curl -s -X POST \
http://localhost:8001/apis/acme.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
}
}'
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):
curl -s -X POST http://localhost:80/apis/acme.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
}
}'
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 — POST /apis/acme.mamluk.net/v1alpha1/cloudns
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
drivers/
drivers.php — Kipchak driver initialisation
routes/
webhook.php — route definitions
.k8s/
resources.yaml — Kubernetes manifests (Deployment, Service, APIService, ClusterIssuer)
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