Free TLS certificates with Let's Encrypt (any server)
You don't need Kubernetes to get free, auto-renewing TLS from Let's Encrypt on a
domain served by DNSCove — including wildcard certificates. Any ACME client
that supports a DNS-01 hook (certbot, acme.sh, lego, …) can solve the
challenge through one DNSCove endpoint, authenticated by a scoped acme-dns01
token.
That token is the whole point: it can do exactly one thing — add and remove
values on the _acme-challenge TXT record of a single zone. It can't read your
other records, can't touch your A/MX/NS, and can't reach any other zone. So
the credential that lives on your web server, in a cron job, next to your private
keys, is one that can't hurt you if it leaks. (Contrast a Route 53 DNS-01 setup,
where the IAM policy that lets your client write the challenge —
route53:ChangeResourceRecordSets — can rewrite every record in the hosted
zone.)
your ACME client ──(DNS-01 hook)──▶ POST /api/zones/{zoneId}/acme-challenge
Authorization: Bearer <acme-dns01 token>
{ action: PRESENT, name, value }
│
control plane merges the _acme-challenge TXT set, publishes to the edge,
and only returns 200 once the record is live on ns1/ns2 ◀── no propagation guessing
│
Let's Encrypt resolves _acme-challenge.<domain> and issues the cert ✓
On Kubernetes? Use the cert-manager guide instead — it wraps this same endpoint in a DNS-01 solver webhook.
Before you start
- The certificate's domain is served by a zone that is ACTIVE in DNSCove and
delegated to ns1.dnscove.com / ns2.dnscove.org. Let's Encrypt validates against
the authoritative servers, so the
_acme-challengename has to be answerable by the DNSCove edge. See the quickstart to get a zone live. - Your DNSCove zone id (
Z…, from the console orGET /api/zones). - An ACME client installed on the host (certbot or acme.sh below).
1. Mint a scoped acme-dns01 token
Create the narrowest token there is, bound to your zone. From the console: open
the zone → API tokens → new token → scope acme-dns01. Or over the API with a
session / tenant-admin token:
curl -fsS -X POST https://api.dnscove.com/api/zones/<ZONE_ID>/tokens \
-H "Authorization: Bearer <SESSION_OR_TENANT_TOKEN>" \
-d '{"name":"letsencrypt on web-01","scope":"acme-dns01"}' | jq -r .token
# dnsc_… — copy it now; it isn't shown again.
Keep it on the server as an environment variable alongside the zone id:
export DNSCOVE_API_BASE=https://api.dnscove.com
export DNSCOVE_ZONE_ID=Z1a2b3c4d5e6f70
export DNSCOVE_ACME_TOKEN=dnsc_…
2. The challenge endpoint
Every client path below calls the same endpoint. It takes a tiny JSON body:
POST {DNSCOVE_API_BASE}/api/zones/{DNSCOVE_ZONE_ID}/acme-challenge
Authorization: Bearer {DNSCOVE_ACME_TOKEN}
| Field | Value |
|---|---|
action |
PRESENT to add the challenge value, CLEANUP to remove it |
name |
The challenge record, e.g. _acme-challenge.example.com |
value |
The key-authorization digest your ACME client computed (base64url) |
Two properties make this pleasant to script:
- Values are merged, not clobbered. A wildcard + apex order presents two
challenges at the same
_acme-challenge.example.comname;PRESENTadds each value to the set, andCLEANUPremoves just one. You never race yourself. - A 200 means it's already live. The call publishes to the edge and only returns success once the record is confirmed served on every nameserver — so your hook doesn't need a "sleep 60 and hope" before handing off to Let's Encrypt.
Path A — certbot
certbot drives DNS-01 through two hook scripts. Save this as
/usr/local/bin/dnscove-hook.sh and chmod +x it — it handles both the present
and cleanup calls:
#!/usr/bin/env sh
# certbot manual DNS-01 hook for DNSCove.
# Used for both --manual-auth-hook (PRESENT) and --manual-cleanup-hook (CLEANUP).
set -eu
: "${DNSCOVE_API_BASE:=https://api.dnscove.com}"
: "${DNSCOVE_ZONE_ID:?set DNSCOVE_ZONE_ID}"
: "${DNSCOVE_ACME_TOKEN:?set DNSCOVE_ACME_TOKEN}"
# certbot sets CERTBOT_AUTH_OUTPUT only on the cleanup hook.
if [ -n "${CERTBOT_AUTH_OUTPUT+x}" ]; then action=CLEANUP; else action=PRESENT; fi
curl -fsS -X POST \
"${DNSCOVE_API_BASE}/api/zones/${DNSCOVE_ZONE_ID}/acme-challenge" \
-H "Authorization: Bearer ${DNSCOVE_ACME_TOKEN}" \
-H 'Content-Type: application/json' \
-d "{\"action\":\"${action}\",\"name\":\"_acme-challenge.${CERTBOT_DOMAIN}\",\"value\":\"${CERTBOT_VALIDATION}\"}" \
>/dev/null
Issue the certificate — wildcards included:
certbot certonly \
--manual --preferred-challenges dns \
--manual-auth-hook /usr/local/bin/dnscove-hook.sh \
--manual-cleanup-hook /usr/local/bin/dnscove-hook.sh \
-d example.com -d '*.example.com'
certbot records the hook paths in the renewal config, so unattended renewal just works:
certbot renew --dry-run # exercise it once
Keep
DNSCOVE_ZONE_IDandDNSCOVE_ACME_TOKENavailable to the renewal environment (e.g. exported in the systemd timer / cron that runscertbot renew, or written into the hook script itself). The hook can't call the API without them.
Path B — acme.sh
acme.sh persists your credentials
and installs its own renewal cron, so it's the lightest way to keep certs fresh
on a plain host. Drop this minimal DNS API plugin at
~/.acme.sh/dnsapi/dns_dnscove.sh:
#!/usr/bin/env sh
# DNSCove DNS-01 plugin for acme.sh. Usage:
# export DNSCOVE_ZONE_ID=Z… DNSCOVE_ACME_TOKEN=dnsc_…
# acme.sh --issue --dns dns_dnscove -d example.com -d '*.example.com'
dns_dnscove_add() {
_dnscove_req PRESENT "$1" "$2"
}
dns_dnscove_rm() {
_dnscove_req CLEANUP "$1" "$2"
}
_dnscove_req() {
_action="$1"; _fulldomain="$2"; _txtvalue="$3"
DNSCOVE_API_BASE="${DNSCOVE_API_BASE:-$(_readaccountconf_mutable DNSCOVE_API_BASE)}"
DNSCOVE_API_BASE="${DNSCOVE_API_BASE:-https://api.dnscove.com}"
DNSCOVE_ZONE_ID="${DNSCOVE_ZONE_ID:-$(_readaccountconf_mutable DNSCOVE_ZONE_ID)}"
DNSCOVE_ACME_TOKEN="${DNSCOVE_ACME_TOKEN:-$(_readaccountconf_mutable DNSCOVE_ACME_TOKEN)}"
if [ -z "$DNSCOVE_ZONE_ID" ] || [ -z "$DNSCOVE_ACME_TOKEN" ]; then
_err "DNSCOVE_ZONE_ID and DNSCOVE_ACME_TOKEN must be set for the first issue."
return 1
fi
# Persist for unattended renewal (acme.sh re-reads these from its account conf).
_saveaccountconf_mutable DNSCOVE_API_BASE "$DNSCOVE_API_BASE"
_saveaccountconf_mutable DNSCOVE_ZONE_ID "$DNSCOVE_ZONE_ID"
_saveaccountconf_mutable DNSCOVE_ACME_TOKEN "$DNSCOVE_ACME_TOKEN"
export _H1="Authorization: Bearer $DNSCOVE_ACME_TOKEN"
export _H2="Content-Type: application/json"
_body="{\"action\":\"$_action\",\"name\":\"$_fulldomain\",\"value\":\"$_txtvalue\"}"
_url="$DNSCOVE_API_BASE/api/zones/$DNSCOVE_ZONE_ID/acme-challenge"
if ! _post "$_body" "$_url" "" "POST"; then
_err "DNSCove $_action request failed"
return 1
fi
_debug2 response "$response"
return 0
}
Then:
export DNSCOVE_ZONE_ID=Z1a2b3c4d5e6f70
export DNSCOVE_ACME_TOKEN=dnsc_…
acme.sh --issue --dns dns_dnscove -d example.com -d '*.example.com'
acme.sh saves the two variables into its account config, installs a daily renewal cron, and re-reads them at renewal time — no further setup.
Path C — lego and other clients
Any client with a "run a script" DNS provider works the same way. With
lego, use the exec provider pointed at a
small script that reads lego's EXEC_* environment and issues the same
PRESENT / CLEANUP calls from step 2. The
API reference documents POST /api/zones/{id}/acme-challenge in
full if you're wiring up a client not listed here.
Start on staging. For your first issue, point the client at the Let's Encrypt staging directory (
https://acme-staging-v02.api.letsencrypt.org/directory; certbot--test-cert, acme.sh--staging). Staging's rate limits are generous — switch to production once a cert issues cleanly.
Troubleshooting
| Symptom | Likely cause |
|---|---|
Validation times out / NXDOMAIN for _acme-challenge |
The zone isn't ACTIVE and delegated to ns1/ns2 yet, so Let's Encrypt can't resolve the challenge. Confirm the zone serves (dig _acme-challenge.example.com @ns1.dnscove.com). |
403 from the endpoint |
Token scope or zone binding is wrong. It must be an acme-dns01 (or zone-admin) token for this zoneId. |
400 name must be an _acme-challenge record |
The name you sent doesn't start with _acme-challenge. — pass the challenge FQDN, not the bare domain. |
502 PublishPending |
The record saved but hadn't propagated to every edge when the call returned. It's transient — the client's retry (or a re-run) succeeds. |
| Renewal fails months later | The acme-dns01 token or its env vars went missing from the renewal environment. Re-mint the token and update the cron/hook. |
Notes
- One token per host is fine.
acme-dns01tokens are cheap and scoped — issue a separate one per server so you can revoke a single machine without disturbing the rest. - Cleanup is optional but tidy. Challenge records carry a 60-second TTL and are harmless if left behind, but the hooks above remove them so the zone stays clean.
- Least privilege after issuance. This token can only solve challenges;
day-to-day record edits still need a
zone-admintoken. See the API reference.