Skip to main content
Version: 0.11

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 you want a working reference to copy from, or when Install an agent and DNS-01 providers have told you the shape of each setting but you want to see them all fit together once, correctly, before doing it on your own host.

Use staging first

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 instance with CertOps enabled (CERTOPS_ENABLED=true, plus CERTOPS_SIGNING_ENCRYPTION_KEY and CERTOPS_REGISTRATION_ENCRYPTION_KEY set) and the CertOps worker running. See Enable CertOps.
  • 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:Edit on 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.
  • certbot installed on the agent host (/usr/bin/certbot; any recent version works, this was last verified against both a 1.21 and a 2.x build).

Step 1 - Create a bootstrap token and install the agent

On Certificate operations (Agents tab, /certops/agents), click Deploy an agent and create a bootstrap token first (it is shown exactly once). Then run the installer on the target host:

sudo TOKENTIMER_AGENT_BOOTSTRAP_TOKEN=ttboot_... ./scripts/install-agent.sh \
--api-url http://localhost:4000 \
--workspace-id 980865fa-c292-4870-929b-497f8867878c \
--write-path /etc/ssl/tokentimer \
--allow-insecure-local-http
--api-url must point at the API, not the dashboard

This is the single most common setup mistake. If your dashboard's dev server runs on a different port than the API (for example the API on :4000 and a Vite dashboard dev server on :5173), pointing --api-url at the dashboard port makes every registration attempt fail with AgentProtocolError: agent registration failed with HTTP 405, because the dashboard's dev server has no route for /api/v1/certops/agent/register and returns a generic method-not-allowed. A production instance normally serves both from the same origin, so this only bites local/dev setups where the API and the dashboard run as two separate processes on two separate ports. Point --api-url at the port your API actually listens on, and confirm with curl -i <api-url>/api/v1/certops/agent/register returning 401 (unauthenticated, meaning the route exists), not 404/405.

--allow-insecure-local-http and a plain http:// URL are for exactly this kind of local/dev instance; use https:// in any real deployment. Confirm the agent registered:

systemctl status tokentimer-agent
journalctl -u tokentimer-agent -n 50 --no-pager

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 that was actually used for the issuance below, with only the domain, workspace id, and credential path left as real values (redact/replace the domain and workspace id for your own instance):

{
"serverUrl": "http://localhost:4000",
"workspaceId": "980865fa-c292-4870-929b-497f8867878c",
"allowInsecureLocalHttp": true,
"declaredTargetSelectors": ["agent-test.tokentimer.io"],
"declaredCommandProfileNames": ["certbot-csr"],
"policy": {
"allowedCommands": {
"certbot-csr": {
"argv": [
"/usr/bin/certbot",
"--agree-tos",
"--register-unsafely-without-email"
]
}
},
"allowedPaths": ["/etc/ssl/tokentimer"],
"allowedCaEndpoints": ["https://acme-staging-v02.api.letsencrypt.org/directory"],
"allowedDnsZones": ["tokentimer.io"],
"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 during testing:

  1. argv is the allowlisted executable plus fixed leading flags only. The agent itself appends certonly --csr <path>, the DNS hook flags, --server <caEndpoint>, and the output paths. Do not add certonly, --csr, or a domain here: doing so duplicates what the agent already appends, and certbot fails to parse the resulting command line.
  2. --agree-tos and --register-unsafely-without-email (or a real --email) belong in argv. certbot needs an ACME account, and without one of these two flags 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. Add a real --email you@example.com instead of --register-unsafely-without-email for anything beyond a disposable test, since the unsafe form means you never hear from Let's Encrypt about problems with your certificates.
  3. declaredTargetSelectors and declaredCommandProfileNames are read once, at registration. Editing them later and restarting the agent does not update what the control plane already 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. This is current, documented behavior, not a bug, but it is unintuitive enough to be worth calling out here.
  4. Both execution.enabled: true and execution.dryRun: false are required. A fresh install ships with enabled: false and dryRun: true; setting only one of the two still yields a dry-run plan with zero filesystem or process side effects, and the job reports dry_run_complete instead 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:

  1. 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 with ProtectHome=true, which makes /home, /root, and /run/user invisible 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.

  1. Add an acmesh-signcsr command profile in config.json, the same way certbot-csr was added above:
{
"declaredCommandProfileNames": ["certbot-csr", "acmesh-signcsr"],
"policy": {
"allowedCommands": {
"acmesh-signcsr": { "argv": ["/opt/acme.sh/acme.sh"] }
}
}
}
  1. 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

This is the exact request body that produced a real issuance (workspace id and hostnames are the same ones the test actually used):

curl -sS -b cookies.txt -X POST \
http://localhost:4000/api/v1/workspaces/980865fa-c292-4870-929b-497f8867878c/certops/jobs \
-H 'Content-Type: application/json' \
-d '{
"operation": "renew",
"idempotencyKey": "cf-worked-example-1",
"payload": {
"certificateId": "agent-test.tokentimer.io",
"targetSelector": "agent-test.tokentimer.io",
"target": { "type": "domain", "reference": "agent-test.tokentimer.io" },
"sans": ["agent-test.tokentimer.io"],
"commandRef": "certbot-csr",
"caEndpoint": "https://acme-staging-v02.api.letsencrypt.org/directory",
"dnsZone": "tokentimer.io",
"dnsProvider": "cloudflare",
"certPath": "/etc/ssl/tokentimer/agent-test.tokentimer.io.pem"
}
}'
certPath must be a file, not a directory

certPath 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 /etc/ssl/tokentimer/agent-test.tokentimer.io.pem (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:

journalctl -u tokentimer-agent -f

Step 5 - Verify

curl -sS -b cookies.txt \
http://localhost:4000/api/v1/workspaces/980865fa-c292-4870-929b-497f8867878c/certops/jobs/<jobId>

A succeeded job looks like this (trimmed to the interesting fields; this is the real response from the run this page documents):

{
"job": {
"status": "succeeded",
"payload": {
"certPath": "/etc/ssl/tokentimer/agent-test.tokentimer.io.pem",
"caEndpoint": "https://acme-staging-v02.api.letsencrypt.org/directory",
"dnsZone": "tokentimer.io",
"dnsProvider": "cloudflare"
}
}
}

On the host, the certificate is now in place:

$ openssl x509 -in /etc/ssl/tokentimer/agent-test.tokentimer.io.pem -noout -subject -issuer -dates
subject=CN = agent-test.tokentimer.io
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

The (STAGING) issuer confirms this came from Let's Encrypt staging as intended, ready to swap caEndpoint for the production directory once you are satisfied.

Dashboard visibility depends on subjectId

This job was created with a bare certificateId string and no subjectType/subjectId, which is 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.

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:

curl -sS -b cookies.txt -X POST \
http://localhost:4000/api/v1/workspaces/980865fa-c292-4870-929b-497f8867878c/certops/jobs \
-H 'Content-Type: application/json' \
-d '{
"operation": "issue",
"idempotencyKey": "cf-worked-example-issue-1",
"payload": {
"targetSelector": "agent-test.tokentimer.io",
"target": { "type": "domain", "reference": "agent-test.tokentimer.io" },
"sans": ["agent-test.tokentimer.io"],
"commandRef": "certbot-csr",
"caEndpoint": "https://acme-staging-v02.api.letsencrypt.org/directory",
"dnsZone": "tokentimer.io",
"dnsProvider": "cloudflare",
"certPath": "/etc/ssl/tokentimer/agent-test.tokentimer.io.pem"
}
}'

The response is a normal job with a server-assigned subjectId, and payload.certificateId is filled in with the same id (the server owns both; sending either one yourself is rejected with HTTP 400):

{
"job": {
"operation": "issue",
"status": "pending",
"subjectType": "managed_certificate",
"subjectId": "00b78bfb-a2e4-42f7-8836-2367b1a41afd",
"payload": {
"certificateId": "00b78bfb-a2e4-42f7-8836-2367b1a41afd",
"certPath": "/etc/ssl/tokentimer/agent-test.tokentimer.io.pem"
}
}
}

The certificate is on the Certificates page from that moment, at status provisioning, before the agent has done anything. Reposting the identical request with the same idempotencyKey returns that same job rather than starting a second ACME order, so a retry after a network timeout is safe. Reusing the key with a different body is rejected as a conflict (HTTP 409), which is the intended protection: one key means one certificate.

The other ways to get a bare issuance into inventory after the fact still work: point a later job at an existing managed certificate's subjectId (for a renewal), or wait for agent filesystem discovery to pick the certificate up on its next scan.

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.tokentimer.io. Nothing further is needed: the certificate now shows state: "auto" in its renewal object, and the scheduler will renew it on its own once it is within the renewal window (30 days before expiry by default). This was verified end to end on this same setup: forcing a certificate inside its renewal window (by raising renew_before_days on its derived profile) caused the scheduler to create a real renew job on its next sweep, which the agent claimed and executed with no manual intervention. See Where the renewal profile comes from for the full mechanics, 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 producing this walkthrough, in the order they were found.

SymptomRoot causeFix
AgentProtocolError: agent registration failed with HTTP 405--api-url pointed at a dashboard dev server, not the APIPoint --api-url at the API's own origin; see the callout in Step 1
certbot fails at the ACME step with an account-registration errorcertbot-csr's argv had no --agree-tos/--register-unsafely-without-email/--email, so certbot cannot create an account non-interactivelyAdd --agree-tos plus --register-unsafely-without-email or a real --email to policy.allowedCommands.certbot-csr.argv
Job reports dry_run_complete and nothing is deployedOnly one of execution.enabled/execution.dryRun was flippedSet both enabled: true and dryRun: false, then restart the agent
ACME order actually succeeds (visible in letsencrypt.log) but the job still fails, and the DNS-01 hook output looks like a propagation timeoutCertbot crashed after the CA issued the certificate, while saving the chain: OSError: [Errno 30] Read-only file system: '/0000_chain.pem', because --chain-path/--fullchain-path were not pinned and certbot's default fell back to the process's read-only working directory under ProtectSystem=strict. This was a real bug in the agent; it is fixed as of the version that ships --chain-path/--fullchain-path pinned as siblings of certPath.Upgrade the agent. If you still see this on an older build, check /opt/tokentimer-agent/state/acme/certbot/logs/letsencrypt.log directly rather than trusting the DNS hook's own error, which was reporting a downstream symptom, not the real cause
deploy: could not read existing destination: EISDIR: illegal operation on a directory, readcertPath in the job payload was a directory, not a filePoint certPath at the concrete destination file, see the callout in Step 4
Job succeeds, certificate is deployed, but nothing shows up on the Certificates pageThe job was a bare renew with no subjectId, and there was no prior discovery of this certificateExpected for a bare first-time issuance; use an issue job to create the managed certificate upfront, see the note in Step 5
POST /certops/jobs with "operation": "issue" returns HTTP 500The instance is running a build where the database has not yet learned the issue operation, so the job insert fails on certificate_jobs_operation_checkUpgrade, or re-run migrations: issue requires the migration that widens certificate_jobs.operation. Nothing is written on failure, so simply retry after migrating
An identical retried issue request returns HTTP 409 instead of the original jobAn older build reconstructed the retried request differently from the original, so its request hash did not match and the retry was reported as a conflicting reuse of the keyFixed; upgrade. A genuine 409 means the same idempotencyKey was sent with a different body, which is intentionally rejected
DNS-01 propagation takes a long time or times outCloudflare had not yet propagated the TXT record to the authoritative nameservers the agent pollsIncrease 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 liveThe 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 slowlyConfirm 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 domainacme.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 jobFixed 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 wrongacme.sh writes most of its own diagnostics, including the exact skip/error reason, to stdout rather than stderr; older agent builds only ever surfaced stderrExcerptCurrent 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 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.

Cleanup

  • Revoke the test certificate through the certificate's Revoke action once you no longer need it (staging certificates cannot be revoked through Let's Encrypt's production OCSP/CRL infrastructure, but TokenTimer's own revoked-state tracking still applies).
  • Remove the throwaway DNS record if you created one solely for this test: the agent's own cleanup step removes the _acme-challenge TXT record after each attempt, so nothing should remain, but it is worth confirming with dig if you are decommissioning a whole test zone.
  • Retire the agent (see Retiring and uninstalling) if it was only ever meant for this test.