.k8s Loading last commit info...
api
bin/doctrine
config
dependencies
drivers
etc/caddy
html
middlewares
routes
tests
.dockerignore
.gitignore
.phpunit.result.cache
Dockerfile
README.md
cli-config.php
composer.json
composer.lock
docker-compose.yml
phpunit.xml
README.md

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 variableDescriptionDefault
CLOUDNS_AUTH_IDClouDNS API authentication ID
CLOUDNS_AUTH_PASSWORDClouDNS API authentication password
CLOUDNS_TTLTTL for DNS challenge records (seconds)60
DEBUGEnable debug loggingfalse

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 secret
  • Deployment — the PHP webhook service
  • Service — exposes the webhook on port 80 inside the cluster
  • APIService (v1alpha1.acme.mamluk.net) — registers the webhook with the Kubernetes API server
  • ClusterIssuer (letsencrypt-prod-cloudns) — a cluster-wide issuer that routes DNS01 challenges to this webhook

Note on TLS: The APIService is configured with insecureSkipTLSVerify: true so 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 replace insecureSkipTLSVerify: true with caBundle: <base64-encoded-CA> in the APIService.

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 Running state.
  • The Service port does not match what the pod listens on (pod: 8080, service: 80 — check resources.yaml).
  • The pod is crashing — check kubectl logs for 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 APIService is Available.

Certificate stuck in "Pending"

kubectl describe certificate <name> -n <namespace>
kubectl describe challenge -n <namespace>
  • Confirm the dnsZones in the ClusterIssuer match the domain in the Certificate.
  • 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_TTL setting. 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

Please wait...
Connection lost or session expired, reload to recover
Page is in error, reload to recover