Skip to main content

Install and upgrade an agent on Linux

Goal

Install the native TokenTimer agent on a host that holds certificates, so renewals, deployments, reloads, and verification run automatically under your control.

Which page do you want?

This page covers the native agent on Linux (systemd). For Windows Server, see Install an agent (Windows). If you only want to report on renewals your own scripts already perform, use Connect an external executor instead.

What gets installed

The agent is a zero-dependency Node.js daemon. It is outbound-only: it opens HTTPS connections to TokenTimer and never listens on a port, so it needs no inbound firewall rule.

PathPurpose
/opt/tokentimer-agent/appThe agent package, read-only at runtime
/opt/tokentimer-agent/stateConfig dir, mode 0700, owned by the agent user
.../state/config.jsonAgent configuration, mode 0600
.../state/credentialPer-agent credential, written by the agent at registration
.../state/bootstrap.envBootstrap token, deleted automatically after first successful registration
.../state/registration-id.jsonClient-generated registration id used to recover an interrupted registration; cleared once the credential is stored
.../state/outbox/Durable queue of completed job results and evidence not yet acknowledged by the control plane; drained on restart before new work is claimed. Retention-capped at 5000 entries / 7 days
.../state/outbox/dead-letter/Quarantined outbox entries: a delivery failure classified permanent is quarantined immediately, an unclassified (transient) one after 48 hours of retrying. Not drained automatically; retention-capped at 5000 entries / 30 days
.../state/job-journal/Side-effect journal written before the first external mutation. An unresolved entry deliberately blocks automatic re-execution of that job, so a crash mid-issuance cannot silently repeat it
.../state/acme/certbot and acme.sh config, work, and log directories
.../state/trust-receipts/Ownership receipts for distribute-trust/revoke-trust jobs, one per (store, fingerprint). Proves locally which anchors this agent installed, so a revoke-trust job can never remove a certificate it does not hold a confirmed receipt for. See Trust anchors
.../state/trust-work/Scratch directory for staging a trust-anchor PEM before it is moved into the OS store; cleaned up once the job resolves
/etc/systemd/system/tokentimer-agent.service.d/override.confGenerated ReadWritePaths allowlist
/etc/polkit-1/rules.d/50-tokentimer-agent.rulesOptional, only when you allow service reloads

The systemd unit is hardened (ProtectSystem=strict, NoNewPrivileges=true), and the agent runs as a dedicated tokentimer-agent system user rather than root.

Requirements

RequirementDetail
OSLinux with systemd. Neither installer supports macOS or systemd-less Linux (confirmed on Alpine/OpenRC: install-agent.sh fails at the /etc/systemd/system unit-install step, since that directory does not exist without systemd) - see Installing without systemd below for the verified manual-run fallback on those hosts. Verified end to end on Ubuntu 22.04, 24.04, and 26.04 LTS and AlmaLinux 9; other systemd-based Debian/Ubuntu LTS and RHEL/Rocky 9+ releases are expected to work the same way (same systemd unit model) but are not independently tested.
RuntimeNode.js 22 or later (>=22.0.0 <25.0.0)
Privilegessudo/root, to run the installer (it creates a system identity, service definition, and directories)
NetworkOutbound HTTPS from the host to TokenTimer. No inbound access needed.
PlanA plan that includes CertOps agents (Pro or Team; per-workspace agent count of 5 on Pro, 25 on Team; see Plan limits)

Installing without systemd

install-agent.sh cannot install on a host with no systemd at all (there is no unit directory to install into), and OpenRC has no bearing on this either way, since the installer never targets it. This is a hard boundary, verified directly against a fresh Alpine 3.24 (musl/OpenRC) host: the installer runs cleanly right up until it tries to cp the unit file into /etc/systemd/system/, which fails because that path does not exist there.

The agent process itself has no dependency on systemd or glibc: Node.js and every ACME client this doc covers (certbot, acme.sh) install and run natively on musl libc, and a manual run against a real Cloudflare DNS-01 zone completed a full issue-and-unattended-renew cycle on Alpine with no code changes. Run it directly, with no packaged installer:

mkdir -p /etc/tokentimer-agent/dns
chmod 700 /etc/tokentimer-agent /etc/tokentimer-agent/dns
# Hand-write config.json (see Agent configuration reference) and the DNS
# provider credentials file into /etc/tokentimer-agent, then:
TOKENTIMER_AGENT_CONFIG_DIR=/etc/tokentimer-agent \
TOKENTIMER_AGENT_BOOTSTRAP_TOKEN=<token from Step 2> \
node /path/to/agent/bin/tokentimer-agent.js

You are responsible for everything the systemd unit normally provides on the primary path: keeping the process running (there is no crash-restart policy without an init system supervising it - the process above must be kept in the foreground of a session that stays open, or wrapped in your own supervisor), the sandboxing (ProtectSystem=strict/ReadWritePaths=, which has no equivalent here), and file permissions on the config/DNS-credential/state directories (chmod/chown them yourself, matching what the installer would otherwise do). Registration, heartbeating, job execution, and the ACME/deploy/verify pipeline all behave identically to the systemd-supervised path once the process is running - none of that logic depends on systemd.

Getting the agent package

The commands below assume ./scripts/install-agent.sh is already on the host. If you do not have a tokentimer-core checkout, download the agent release tarball (contains install-agent.sh plus the agent package) as a GitHub release asset and verify its checksum before running anything from it:

CORE_VERSION="<version>" # e.g. 0.11.1 - match your workspace's supported agent version
BASE_URL="https://github.com/tokentimerch/tokentimer-core/releases/download/v${CORE_VERSION}"
TARBALL="tokentimer-agent-${CORE_VERSION}.tgz"
# -O (not -o) saves each file under the name curl reads off the URL itself,
# matching the exact filename the release's .sha256 file was written
# against (packages/agent/scripts/pack-release.js's `${digest} ${tarballName}`
# line uses npm pack's own tarball name, not a fixed string) - renaming the
# download with `-o some-other-name` makes `sha256sum -c` fail with
# "No such file or directory" even when the actual bytes are untampered,
# since it looks up the filename recorded inside the .sha256 file, not
# whatever you happened to save the tarball as.
curl -fsSL -O "${BASE_URL}/${TARBALL}"
curl -fsSL -O "${BASE_URL}/${TARBALL}.sha256"
sha256sum -c "${TARBALL}.sha256"

tar -xzf "${TARBALL}"
cd package # npm-packed tarballs extract into a "package/" directory

install-agent.sh is at ./scripts/install-agent.sh inside that extracted directory; run the commands in Step 1 from there.

Step 1 - Run the installer

Copy the agent package to the host, then run the installer from the package directory. It does not need a token yet: it pauses at a hidden interactive prompt asking for one, so the secret never reaches your shell history or the process list. Create the token in step 2 below and paste it there.

sudo ./scripts/install-agent.sh \
--api-url https://tokentimer.ch \
--workspace-id <your workspace id>

The installer prompts for the bootstrap token when neither TOKENTIMER_AGENT_BOOTSTRAP_TOKEN nor --bootstrap-token is set. For unattended installs, prefer the environment variable instead of waiting on the prompt:

sudo TOKENTIMER_AGENT_BOOTSTRAP_TOKEN=ttboot_... ./scripts/install-agent.sh \
--api-url https://tokentimer.ch \
--workspace-id <your workspace id>
Avoid --bootstrap-token on shared hosts

It works, but arguments are visible in process listings while the installer runs. The prompt and the environment variable both avoid that exposure.

Run it with --dry-run first to print every action without touching the system:

sudo ./scripts/install-agent.sh --api-url https://tokentimer.ch \
--workspace-id <id> --dry-run

Useful options

OptionUse it for
--write-path PATHAn absolute directory the agent may write certificates into. Repeatable. Never grants all of /etc.
--write-paths-file FILEOne absolute path per line, merged with --write-path
--trust-storeRequire the OS machine trust-store grant. Applied automatically on Debian/Ubuntu and RHEL/AlmaLinux, so you normally do not need to pass it
--no-trust-storeSkip the OS trust-store grant. Use on renew-only hosts that will never receive a trust anchor
--reload-service NAMEAllow systemctl reload for nginx, apache/apache2, httpd, or haproxy via a generated polkit rule
--ca-bundle PATHPEM bundle when the agent must trust an extra CA (for example a TLS-inspecting egress proxy)
--allow-insecure-local-httpNot applicable on Cloud; loopback-only development option
--uninstallStop and remove the service, app dir, unit, drop-in, and polkit rule

A typical nginx host:

sudo ./scripts/install-agent.sh \
--api-url https://tokentimer.ch \
--workspace-id <id> \
--write-path /etc/letsencrypt \
--write-path /etc/nginx/certs \
--reload-service nginx
ReadWritePaths is not the same as filesystem permissions

--write-path only lets the systemd sandbox reach a directory. The tokentimer-agent user must still be able to write there. Grant that before enabling deploy jobs, for example:

sudo setfacl -m u:tokentimer-agent:rwx /etc/nginx/certs

Without this, deploys fail with a permission error even though the path is allowlisted.

Never pass the OS trust store as --write-path

The installer takes ownership of every --write-path directory. Doing that to /etc/ssl/certs or the RHEL pki anchors breaks CA trust for the whole host. The trust-store grant is a separate mechanism (see below) that deliberately leaves those directories root-owned.

OS trust store (trust anchors)

Distributing a trust anchor writes into the host's machine trust store. Under the hardened unit, ProtectSystem=strict leaves those directories read-only and tokentimer-agent has no write ACL there by default.

install-agent.sh opens the trust store automatically when Debian/Ubuntu or RHEL/AlmaLinux trust tooling is on PATH, adding the family's directories to ReadWritePaths and granting tokentimer-agent write via setfacl (falling back to group-write when the acl package is missing). Pass --trust-store to require the grant, or --no-trust-store to skip it on renew-only hosts that will never receive a trust anchor.

For the full directory table, the AlmaLinux ACL caveat, and how to recover an already-installed agent that predates this grant, see Diagnose Linux OS trust-store failures.

--write-path is not remembered across reinstalls

Every run of install-agent.sh regenerates the systemd sandbox drop-in (tokentimer-agent.service.d/override.conf) from scratch, from only the --write-path flags given on that invocation. This is deliberate (a stale broad grant should not silently outlive an operator narrowing it), but it means re-running the installer for any reason — troubleshooting, upgrading, changing --api-url — without repeating every --write-path from the original install will silently shrink the sandbox back down to the state directory only.

The failure mode this produces is misleading: the next real deploy job fails with EROFS: read-only file system opening a temp file under your certificate directory, which looks like a filesystem or permissions problem. It is not — it is the sandbox. Confirm the current grant with:

systemctl cat tokentimer-agent | grep ReadWritePaths

If your certificate directory is missing from that line, re-run the installer with the full original set of --write-path flags (and any --reload-service), then systemctl daemon-reload.

--write-path directories must exist before install, or the service refuses to start at all

ReadWritePaths= is a systemd sandbox declaration, not a mkdir. If a --write-path directory does not exist yet when the unit starts, systemd fails mount-namespace setup entirely and the service never comes up, with systemctl status showing status=226/NAMESPACE and journalctl showing Failed to set up mount namespacing: <path>: No such file or directory. This is a hard start failure, not a deploy-time error: nothing about the job or the API is wrong, and the agent never even connects. Create the directory (and set its ownership per the ACL note above) before running install-agent.sh, for example:

sudo mkdir -p /etc/ssl/tokentimer
sudo chown tokentimer-agent:tokentimer-agent /etc/ssl/tokentimer
sudo chmod 750 /etc/ssl/tokentimer

Polkit is used rather than sudoers deliberately: the unit keeps NoNewPrivileges=true, under which sudo cannot escalate at all.

Step 2 - Create a bootstrap token

  1. Open Certificate operations at /certops/agents (the Agents tab).
  2. Click Deploy an agent to open the install wizard and create a bootstrap token.
  3. Copy it and paste it at the installer's prompt from step 1. It is a single-use ttboot_ token, shown exactly once, and it is consumed at the agent's first registration.

Step 3 - Verify registration

systemctl status tokentimer-agent
journalctl -u tokentimer-agent -f

You should see the agent register and begin heartbeating, and the Agent fleet panel on the Certificate operations page flip it to registered. The fleet panel shows status, last heartbeat, clock drift, and version compatibility.

Confirm the bootstrap token file is gone once registration succeeded:

sudo ls -l /opt/tokentimer-agent/state/

bootstrap.env should no longer be present; the agent removes it after the first successful registration and keeps only the per-agent credential. Removal is best-effort, so if the file is still there (for example because of a read-only or oddly-permissioned state dir), delete it yourself once the agent is registered.

Step 4 - Start in dry-run, then enable execution

The agent ships with execution.dryRun set to true and execution.enabled set to false, so a fresh install exercises the whole trust chain (claim, signature verification, policy evaluation, planning) and reports what it would do, with no filesystem or process side effects.

Leave it that way until you have seen a job plan correctly, then enable real execution in config.json:

{
"execution": {
"enabled": true,
"dryRun": false
}
}

Restart the agent after editing: sudo systemctl restart tokentimer-agent.

Upgrading

The agent does not self-update, which is deliberate: an execution-plane process that can update itself is an execution-plane process an attacker can update.

  1. Copy the new package to the host.
  2. Re-run install-agent.sh with the same flags. The state directory, credential, and keys are preserved, so the agent keeps its identity and does not need a new bootstrap token.
  3. Confirm the new version in the Agent fleet panel.

To force a fleet upgrade, contact support so the minimum accepted agent version can be raised for your workspace; older agents are then blocked at registration and heartbeat rather than silently continuing to claim work.

Retiring and uninstalling

Retire the agent from the Agent fleet panel first, then uninstall on the host. Retiring is not just bookkeeping: it invalidates the agent's credential (its protocol calls then return 410) and lets the agent exit cleanly on its next heartbeat instead of being killed mid-job.

Moving an agent to a different workspace is not supported

There is no in-place transfer of an agent between workspaces. To put an agent under a different workspace, retire it here first, then install a fresh agent and register it in the target workspace with a new bootstrap token.

Retiring an agent that is still working

Retirement deliberately refuses to yank work out from under a running agent. If the agent currently holds an actively leased job, a plain retire is blocked with 409 CERTOPS_AGENT_RETIRE_BLOCKED, and the response tells you how many jobs are in the way.

The right first move is usually to wait: let the job finish, then retire. If you cannot wait (the host is already gone, or the agent is misbehaving), force it. A force retire requires a reason and is rejected without one. That is enforced by the API, not just the dashboard form, because a forced retire can abandon real work and someone will need to know why later.

A forced retire immediately fences the agent's in-flight work rather than leaving it to time out, and splits it by how far it got:

Job status when forcedBecomesWhy
claimed (dispatched, no execution started)cancelledNothing was done yet, so cancelling is safe and clean
running (execution had started)orphaned_unknown_effect, flagged for reconciliationThe agent may already have ordered a certificate, written a key, deployed a file, or reloaded a service. TokenTimer will not guess

The response reports both lists, and the CERTOPS_AGENT_RETIRED audit event records the force flag, the reason, and both id lists.

Anything in the orphaned list needs a human. Work through Reconciling interrupted jobs for each one before assuming the certificate is in a known state. If an orphaned job was a renewal, it also raises a cert_renewal_failed alert, so it reaches you through normal alerting rather than sitting unnoticed.

Retiring is idempotent: retiring an already-retired agent succeeds with the original retirement timestamp unchanged, and does not fence anything a second time.

On the host, a retired agent notices at its next heartbeat (which returns 410), logs control plane retired this agent; exiting cleanly, and exits with status 86. The systemd unit sets RestartPreventExitStatus=86, so systemd deliberately does not respawn it into a 410 loop. systemctl status tokentimer-agent then shows the service inactive with exit code 86: that is a successful retirement, not a crash. Compare with a genuine failure, which shows a non-86 status and Restart= kicking in with repeated activating (auto-restart).

Uninstalling

Once retired, on the host:

sudo ./scripts/install-agent.sh --uninstall

This removes the service, app directory, unit, drop-in, and polkit rule. The state directory and system user are preserved so you cannot destroy keys by accident. Remove them deliberately once you are sure:

sudo rm -rf /opt/tokentimer-agent
sudo userdel tokentimer-agent
warning

Uninstalling the agent does not retire it in TokenTimer, and retiring it does not uninstall it. Do both. An uninstalled but un-retired agent keeps a valid credential and simply looks offline; an un-uninstalled but retired agent keeps running locally and gets 410 on every call.

Also remember that certificates that agent discovered stay pinned to it: jobs for them will sit at pending until you assign a new agent or let a new agent rediscover them. See How a job is bound to one agent.

Troubleshooting

SymptomLikely causeFix
Service in activating (auto-restart) with status=1/FAILUREStartup failure, most often registration or configRead the real reason with journalctl -u tokentimer-agent -n 50 --no-pager; the unit status alone never says why
Service inactive with exit code 86Not a failure: the agent was retired in TokenTimer and exited cleanlyExpected. Uninstall the host files if you are done, or register a new agent
Installer refuses the API URLURL is not https:Use https://tokentimer.ch
Registration fails as unauthorizedBootstrap token already used, expired, or revokedTokens are single-use; create a new one in the dashboard
Registration fails and CertOps routes 404CERTOPS_ENABLED is off platform-wide, or the API URL/route path is wrongContact support to confirm CertOps availability; double-check the API URL passed to the installer
Agent blocked at registration or heartbeatVersion outside the accepted windowUpgrade the agent to the current release
TLS handshake failure to TokenTimerEgress proxy performing TLS inspection with a CA the agent does not trustReinstall with --ca-bundle, or set caBundlePath in config.json
Deploy fails with a permission errorPath allowlisted but not writable by the agent userGrant write access (setfacl) to tokentimer-agent
Reload failsService not allowlisted at install timeReinstall with --reload-service NAME
Clock drift warnings in the fleet panelHost clock skewFix NTP; signed jobs enforce a validity window
Jobs plan but never change anythingStill in dry-runSet execution.enabled: true and execution.dryRun: false, then restart
A job stays pending and this agent never claims itThe job is pinned to a different agent (the one that discovered the certificate), or requires a target selector this agent does not declareCheck the job's assigned agent; see How a job is bound to one agent
Renew job creation is refused for a certificate you can see in inventoryThe certificate was only observed, so no agent has key custodyInstall the agent on the host that actually serves it; see which certificates are renewable
NTP badge in the fleet panel stays unknowntimedatectl is missing or not usable on the host (containers, some WSL setups, non-systemd Linux)Confirm timedatectl show -p NTPSynchronized --value runs as the tokentimer-agent user; unknown is reported as null, never guessed