Skip to main content

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 threads them together: one certificate, from a clean install to a file on disk, against a Cloudflare-managed zone and Let's Encrypt's staging environment.

Use this page when Install an agent (Linux) 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.

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 plan that includes CertOps agents.
  • A Linux host with systemd and Node.js 22+ to install the agent on. This runbook's steps apply on Ubuntu 22.04, 24.04, and 26.04 LTS and AlmaLinux 9. Other systemd-based Debian/Ubuntu LTS and RHEL/Rocky 9+ releases should work the same way. Alpine Linux (musl/OpenRC) is not supported by the installer itself (no systemd, so no unit to install), though a manual, self-supervised run works there too - see Installing without systemd on the install page.
  • 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).
Running this on WSL2 instead of a real Linux host

WSL2 does run systemd (Ubuntu 24.04 and newer default to it), so everything on this page works there too, with one WSL-specific gotcha: the WSL2 VM itself shuts down whenever no Windows-side process is attached to it, and each individual wsl -d <distro> -- <command> invocation counts as a disconnect the moment it returns. Since the agent is a long-running systemd service, this silently kills and restarts it between commands run from PowerShell one at a time. Keep a session open for the duration of testing, in a separate window:

wsl -d Ubuntu-24.04 -- sleep 7200
Prerequisites on RHEL/Rocky/AlmaLinux family hosts

This runbook's commands (systemctl, journalctl, install, the agent itself) are identical across distro families. The only differences are how you get the prerequisites onto the box in the first place, shown here for AlmaLinux 9:

sudo dnf install -y epel-release # certbot lives in EPEL, not the base/AppStream repos
sudo dnf install -y certbot nodejs # dnf's base nodejs may be too old; use the NodeSource dnf module/repo for Node 22+ if so

For acme.sh instead of certbot (see Using acme.sh instead of certbot below), the install is the same git clone on every distro family; no EL-specific step is needed there.

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
If you built or copied this install command by hand

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". Confirm with curl -i https://tokentimer.ch/api/v1/certops/agent/register returning 401 (unauthenticated, meaning the route exists), not 404/405. 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 (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:

  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 --email (or --register-unsafely-without-email for throwaway testing) belong in argv. 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.
  3. declaredTargetSelectors and declaredCommandProfileNames are 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.
  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
Never replace config.json wholesale once the agent has registered

Registration writes agentId (and the config loader's other identity/pin state) back into this same config.json, merged with whatever was already there. If you manage this file with a tool that renders and overwrites the whole file from a template (rather than editing it in place), a render taken after the agent already registered will drop agentId from the on-disk file, and the next restart fails immediately with found a stored credential but no agentId in config.json; the config directory is inconsistent. Diff or re-read the file before overwriting it, or restrict automated config management to the fields that predate registration (serverUrl, policy, dnsProviders, execution, …) and leave agentId alone.

Using acme.sh instead of certbot

Everything above uses certbot. To use acme.sh instead, 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

From Certificate operations, use Create manual job (or POST /api/v1/workspaces/{id}/certops/jobs) with a body shaped like this:

{
"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",
"keyPath": "/etc/ssl/tokentimer/agent-test.example.com.key.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 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.

Always set keyPath explicitly, under an allowlisted path

If the job payload omits keyPath, the agent falls back to its own internal key-staging location (<state dir>/keys/<certificateId>.key.pem) as the deploy destination for the private key. That location is deliberately never on policy.allowedPaths (it is staging, not a production destination), so the deploy step rejects itself with deploy: target.keyPath rejected by policy (path_not_allowlisted): Path ".../state/keys/<id>.key.pem" is not contained within any allowlisted path. on the very first issuance, when the key is freshly generated and therefore always "rotated" relative to the (absent) previous one. Always give keyPath a concrete file under an allowlisted directory, exactly as shown above, so the agent copies the key there instead of trying to leave it in staging.

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.

Dashboard visibility depends on subjectId

This 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",
"keyPath": "/etc/ssl/tokentimer/agent-test.example.com.key.pem"
}
}

The response is a normal job with a server-assigned subjectId, and payload.certificateId is filled in with the same id (sending either one yourself is rejected with HTTP 400). Reposting the identical request with the same idempotencyKey returns that same job rather than starting a second ACME order. Reusing the key with a different body is rejected as a conflict (HTTP 409).

What happens next: automatic renewal

A successful issue job derives a renewal profile (Derived: agent-test.example.com). The certificate then auto-renews once it is inside the renewal window (30 days by default). Keep renew_before_days well under the CA lifetime. See Renew certificates automatically.

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

SymptomRoot causeFix
Registration fails with AgentProtocolError: agent registration failed with HTTP 405--api-url pointed at the wrong originAlways 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 errorcertbot-csr's argv had no --agree-tos/--email/--register-unsafely-without-email, so certbot cannot create an account non-interactivelyAdd --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 deployedOnly one of execution.enabled/execution.dryRun was flippedSet 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 timeoutCertbot crashed after issuance while saving the chain, because --chain-path/--fullchain-path defaulted to a read-only path under ProtectSystem=strictThe agent pins both paths as siblings of certPath. If this still happens, read /opt/tokentimer-agent/state/acme/certbot/logs/letsencrypt.log rather than the DNS hook error
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
deploy: target.keyPath rejected by policy (path_not_allowlisted): Path ".../state/keys/<id>.key.pem" is not contained within any allowlisted path.The job payload omitted keyPath, so the agent tried to deploy from its own internal key-staging directory, which is deliberately never on policy.allowedPathsSet keyPath explicitly to a concrete file under an allowlisted directory in the job payload, see the callout in Step 4
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
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 jobThe agent always passes --force to acme.sh --signcsr, so acme.sh's own schedule cannot override TokenTimer's
Any acmesh-signcsr failure reports acme step failed with exit code N: no stderr, with no clue what actually went wrongacme.sh writes most diagnostics to stdout, not stderrThe agent falls back to the stdout excerpt when stderr is empty, and attaches both to the job's validation.failed evidence
The service fails to start right after a fresh install, repeatedly, with Failed to set up mount namespacing: <path>: No such file or directory / status=226/NAMESPACE in journalctlThe installer binds each --write-path into the sandbox and creates the directory before writing the unitIf a hand-edited unit still fails, mkdir -p <path> && chown tokentimer-agent:tokentimer-agent <path> && chmod 0750 <path>, then systemctl restart tokentimer-agent

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 row 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.