Worked example: issue a certificate with Cloudflare DNS-01
What this page is
Every other CertOps page documents one concern in isolation: installing the agent, configuring DNS-01, or creating a job. This page is the missing thread between them: one certificate, issued for real, from a clean install to a verified file on disk, with every command and every error actually hit along the way. It was produced by running the whole flow against a live TokenTimer instance, a real Cloudflare-managed zone, and Let's Encrypt's staging environment, then writing down exactly what worked.
Use this page when Install an agent and DNS-01 providers have told you the shape of each setting and you want to see them all fit together once, correctly, before doing it on your own host.
Every example on this page targets https://acme-staging-v02.api.letsencrypt.org/directory. Staging certificates are not trusted by browsers, but they are otherwise real ACME issuance against Let's Encrypt's infrastructure, and staging has much higher rate limits. Prove the whole chain there, then switch caEndpoint to https://acme-v02.api.letsencrypt.org/directory for a production certificate.
Prerequisites
- A TokenTimer plan that includes CertOps agents.
- A Linux host with systemd and Node.js 22+ to install the agent on.
- A domain with its DNS hosted on Cloudflare, and a scoped API token (
Zone.DNS:Editon that zone only, not the global API key). Create it at My Profile → API Tokens → Create Token on Cloudflare, using the Edit zone DNS template scoped to the one zone you will issue for. certbotinstalled on the agent host (/usr/bin/certbot; any recent version works).
Step 1 - Create a bootstrap token and install the agent
On Certificate operations, open Deploy an agent and create a bootstrap token first (it is shown exactly once). Copy the generated install command; it already has the correct --api-url for your instance. It looks like:
sudo ./scripts/install-agent.sh \
--api-url https://tokentimer.ch \
--workspace-id <your workspace id> \
--write-path /etc/ssl/tokentimer
Run it, and paste the bootstrap token at the prompt (or set TOKENTIMER_AGENT_BOOTSTRAP_TOKEN for unattended installs). Confirm the agent registered:
systemctl status tokentimer-agent
journalctl -u tokentimer-agent -n 50 --no-pager
Double-check --api-url points at your TokenTimer origin (https://tokentimer.ch for TokenTimer Cloud), not a local dev server or a different internal hostname. An --api-url pointed at the wrong origin fails registration with AgentProtocolError: agent registration failed with HTTP 405 if it happens to hit something that returns method-not-allowed for the registration path, which reads as a generic protocol error rather than "wrong URL". Always prefer the exact command the Deploy an agent panel generates over typing it from memory.
Step 2 - Store the Cloudflare credential
sudo install -d -m 0700 -o tokentimer-agent -g tokentimer-agent /etc/tokentimer-agent/dns
sudo install -m 0600 -o tokentimer-agent -g tokentimer-agent /dev/null \
/etc/tokentimer-agent/dns/cloudflare.json
sudo tee /etc/tokentimer-agent/dns/cloudflare.json >/dev/null <<'EOF'
{ "apiToken": "<your scoped Cloudflare API token>" }
EOF
The agent refuses this file outright if it is group- or other-readable, so create it 0600 from the start rather than fixing the mode after the fact.
Step 3 - Configure the agent
Edit /opt/tokentimer-agent/state/config.json. This is the full config shape that was actually verified end to end (replace the domain and workspace id with your own):
{
"serverUrl": "https://tokentimer.ch",
"workspaceId": "<your workspace id>",
"declaredTargetSelectors": ["agent-test.example.com"],
"declaredCommandProfileNames": ["certbot-csr"],
"policy": {
"allowedCommands": {
"certbot-csr": {
"argv": [
"/usr/bin/certbot",
"--agree-tos",
"--email", "ops@example.com"
]
}
},
"allowedPaths": ["/etc/ssl/tokentimer"],
"allowedCaEndpoints": ["https://acme-staging-v02.api.letsencrypt.org/directory"],
"allowedDnsZones": ["example.com"],
"allowedDnsProviders": ["cloudflare"]
},
"dnsProviders": {
"cloudflare": { "credentialsFile": "/etc/tokentimer-agent/dns/cloudflare.json" }
},
"execution": {
"enabled": true,
"dryRun": false
}
}
Four things in this file are easy to get wrong, and each one produced a real failure while verifying this walkthrough:
argvis the allowlisted executable plus fixed leading flags only. The agent itself appendscertonly --csr <path>, the DNS hook flags,--server <caEndpoint>, and the output paths. Do not addcertonly,--csr, or a domain here: doing so duplicates what the agent already appends, and certbot fails to parse the resulting command line.--agree-tosand--email(or--register-unsafely-without-emailfor throwaway testing) belong inargv. certbot needs an ACME account, and without one of these a fresh certbot install refuses to register non-interactively: the job fails at the ACME step with an account-registration error, not a DNS-01 error, which is easy to misdiagnose as a DNS problem.declaredTargetSelectorsanddeclaredCommandProfileNamesare read once, at registration. Editing them later and restarting the agent does not retroactively change what the control plane recorded for this agent at its first registration; a job that needs a capability added after the fact will not route to this agent until it is edited.- Both
execution.enabled: trueandexecution.dryRun: falseare required. A fresh install ships withenabled: falseanddryRun: true; setting only one of the two still yields a dry-run plan with zero filesystem or process side effects, and the job reportsdry_run_completeinstead of actually issuing anything.
Restart after editing:
sudo systemctl restart tokentimer-agent
Using acme.sh instead of certbot
Everything above uses certbot. To use acme.sh instead, verified end to end against the same Cloudflare zone:
- Install acme.sh itself somewhere the hardened unit can actually execute it from. Do not use acme.sh's own installer default of
~/.acme.sh: the unit runs withProtectHome=true, which makes/home,/root, and/run/userinvisible to the process, so an executable placed there cannot be launched at all, and the job fails before it even starts. Install it outside any home directory instead, for example:
sudo git clone https://github.com/acmesh-official/acme.sh /opt/acme.sh
install-agent.sh already creates and manages acme.sh's own working state (its --home/--config-home directory, including the dns_certops.sh dnsapi hook symlink) under <state dir>/acme/acme.sh; only the acme.sh executable itself needs this separate, non-home install location.
- Add an
acmesh-signcsrcommand profile inconfig.json, the same waycertbot-csrwas added above:
{
"declaredCommandProfileNames": ["certbot-csr", "acmesh-signcsr"],
"policy": {
"allowedCommands": {
"acmesh-signcsr": { "argv": ["/opt/acme.sh/acme.sh"] }
}
}
}
- Use
"commandRef": "acmesh-signcsr"in the job payload in Step 4 instead of"certbot-csr". Nothing else in the job body changes.
acme.sh has one failure mode certbot's --csr mode does not: --signcsr reuses acme.sh's own internal renewal-timing state and can silently skip a renewal that TokenTimer's control plane already approved. See the last two rows of the troubleshooting table below if a renew job on an existing acme.sh-issued certificate fails with an unhelpful "exit code 2, no stderr".
Step 4 - Create the job
From Certificate operations, use Create manual job (or POST /api/v1/workspaces/{id}/certops/jobs) with a body shaped like this, the same shape verified end to end:
{
"operation": "renew",
"idempotencyKey": "cf-worked-example-1",
"payload": {
"certificateId": "agent-test.example.com",
"targetSelector": "agent-test.example.com",
"target": { "type": "domain", "reference": "agent-test.example.com" },
"sans": ["agent-test.example.com"],
"commandRef": "certbot-csr",
"caEndpoint": "https://acme-staging-v02.api.letsencrypt.org/directory",
"dnsZone": "example.com",
"dnsProvider": "cloudflare",
"certPath": "/etc/ssl/tokentimer/agent-test.example.com.pem"
}
}
certPath must be a file, not a directorycertPath is the exact destination file for the leaf certificate, not the directory it lives in. Pointing it at /etc/ssl/tokentimer (the allowlisted directory) instead of a file inside it gets past every policy check, because the directory itself is allowlisted, and only fails at the deploy step with deploy: could not read existing destination: EISDIR: illegal operation on a directory, read. policy.allowedPaths allows a directory tree; certPath must resolve to one concrete file under it.
Watch the agent claim and run the job on the host:
journalctl -u tokentimer-agent -f
Step 5 - Verify
On Certificate operations, open the job and confirm it reached succeeded. On the host:
openssl x509 -in /etc/ssl/tokentimer/agent-test.example.com.pem -noout -subject -issuer -dates
A successful staging issuance shows the (STAGING) issuer, confirming this came from Let's Encrypt staging as intended; for example:
subject=CN = agent-test.example.com
issuer=C = US, O = Let's Encrypt, CN = (STAGING) Baloney Bulgur YE2
notBefore=Jul 26 10:20:02 2026 GMT
notAfter=Oct 24 10:20:01 2026 GMT
Swap caEndpoint for https://acme-v02.api.letsencrypt.org/directory once you are satisfied, to get a browser-trusted certificate.
subjectIdThis job was created with a bare certificateId string and no subjectType/subjectId, a fully supported way to run a one-off or break-glass issuance. It works end to end exactly as shown above, but because no subjectId ties the job to a managed-certificate row, nothing appears in the Certificates dashboard for it: the certificate exists and is deployed, but it is dashboard-invisible until a subsequent agent filesystem discovery scan picks it up, or you point a later renewal job at it once it has a subjectId.
For a first-time issuance that you do want tracked, use an issue job instead of the renew shape above. It takes the same execution payload minus certificateId, requires an idempotencyKey, and creates the managed certificate at status provisioning before dispatch, so it is in the dashboard immediately and flips to active from the agent's evidence when the job succeeds:
{
"operation": "issue",
"idempotencyKey": "cf-worked-example-issue-1",
"payload": {
"targetSelector": "agent-test.example.com",
"target": { "type": "domain", "reference": "agent-test.example.com" },
"sans": ["agent-test.example.com"],
"commandRef": "certbot-csr",
"caEndpoint": "https://acme-staging-v02.api.letsencrypt.org/directory",
"dnsZone": "example.com",
"dnsProvider": "cloudflare",
"certPath": "/etc/ssl/tokentimer/agent-test.example.com.pem"
}
}
What happens next: automatic renewal
If you used the issue job from the note above (not the bare renew job), TokenTimer derived a renewal profile automatically the moment the job succeeded, named Derived: agent-test.example.com. Nothing further is needed: the certificate now shows state: "auto" in its renewal object, and it will renew on its own once it is within the renewal window (30 days before expiry by default). This was verified end to end: forcing a certificate inside its renewal window (by raising renew_before_days on its derived profile) caused a real renew job to be created and claimed with no manual intervention. See Where the renewal profile comes from, and the caution there about not setting renew_before_days too high.
The bare renew job used for the primary walkthrough above gets no such profile, because it never touched a managed-certificate row: without one there is nothing to derive a profile onto, so that certificate will not auto-renew regardless of how it is configured.
Troubleshooting: everything that actually went wrong
Every row below is a real failure hit while verifying this walkthrough end to end, in the order they were found.
| Symptom | Root cause | Fix |
|---|---|---|
Registration fails with AgentProtocolError: agent registration failed with HTTP 405 | --api-url pointed at the wrong origin | Always use the exact install command the Deploy an agent panel generates; see the note in Step 1 |
| certbot fails at the ACME step with an account-registration error | certbot-csr's argv had no --agree-tos/--email/--register-unsafely-without-email, so certbot cannot create an account non-interactively | Add --agree-tos plus --email <address> (or --register-unsafely-without-email for throwaway testing) to policy.allowedCommands.certbot-csr.argv |
Job reports dry_run_complete and nothing is deployed | Only one of execution.enabled/execution.dryRun was flipped | Set both enabled: true and dryRun: false, then restart the agent |
| The ACME order actually succeeds but the job still fails, and the DNS-01 hook output looks like a propagation timeout | A certbot chain-saving crash after successful issuance (--chain-path/--fullchain-path not pinned, defaulting to a read-only path under ProtectSystem=strict). Fixed in current agent builds, which pin both paths as siblings of certPath. | Upgrade the agent. If this still happens, read /opt/tokentimer-agent/state/acme/certbot/logs/letsencrypt.log directly rather than trusting the DNS hook's own error, which reports a downstream symptom, not the real cause |
deploy: could not read existing destination: EISDIR: illegal operation on a directory, read | certPath in the job payload was a directory, not a file | Point certPath at the concrete destination file, see the callout in Step 4 |
| Job succeeds, certificate is deployed, but nothing shows up on the Certificates page | The job was a bare renew with no subjectId, and there was no prior discovery of this certificate | Expected for a bare first-time issuance; use an issue job to create the managed certificate upfront, see the note in Step 5 |
| DNS-01 propagation takes a long time or times out | Cloudflare had not yet propagated the TXT record to the authoritative nameservers the agent polls | Increase dnsPropagation.timeoutMs, or diagnose directly with dig _acme-challenge.<domain>. TXT @<zone's own nameserver> while the job is running |
DNS-01 propagation always times out after the full dnsPropagation.timeoutMs, even though dig @1.1.1.1 _acme-challenge.<domain> TXT shows the record live | The agent host has no IPv6 route. checkAuthoritative: true (the default) polls every A and AAAA address behind the zone's nameservers under verificationMode: "all", so an unreachable IPv6 nameserver fails forever, not just slowly | Confirm with dig @<the AAAA nameserver IP> ... from the agent host (network unreachable, not a timeout, confirms this); set checkAuthoritative: false and add explicit IPv4 resolvers (["1.1.1.1", "8.8.8.8"]) to dnsPropagation |
Using acmesh-signcsr instead of certbot-csr: a renew job on a certificate that was already issued fails with acme step failed with exit code 2: no stderr, even though the DNS-01 challenge and issuance both worked moments earlier for the same domain | acme.sh's --signcsr reuses acme.sh's own internal renewal-timing logic, which persists a per-domain Le_NextRenewTime and silently exits 2 (RENEW_SKIP) on any invocation before that self-tracked time, regardless of whether TokenTimer's control plane (the actual authority on renewal timing) requested the job | Fixed in current agent builds, which always pass --force to acme.sh --signcsr since acme.sh's own schedule must never override TokenTimer's. Upgrade the agent if you see this |
Any acmesh-signcsr failure reports acme step failed with exit code N: no stderr, with no clue what actually went wrong | acme.sh writes most of its own diagnostics, including the exact skip/error reason, to stdout rather than stderr; older agent builds only ever surfaced stderrExcerpt | Current agent builds fall back to the stdout excerpt whenever stderr is empty, and attach both excerpts to the job's validation.failed evidence item (visible on the certificate's timeline) so the real acme.sh message is never lost. Upgrade the agent if you still see a bare "no stderr" |
When something fails and the DNS hook's own error does not explain it, letsencrypt.log at <state dir>/acme/certbot/logs/letsencrypt.log on the agent host is the ground truth: it shows the full ACME conversation, including cases (like the chain-path bug above) where the actual CA-facing part of the job succeeded and the failure happened afterward, purely on the local filesystem.