Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Private CA trust

By default the control-plane HTTPS of atcd and atc trusts the public web PKI roots only. A self-hosted, air-gapped, or lab control plane usually has a certificate that a private CA signed. Name that CA, or the device cannot enroll.

The reverse tunnel of atcd reads the same field and starts from the same base trust set. It reads no system certificate store. See Which roots each half adds to.

The symptom

The device never enrolls, and the atcd unit stays green. Read the journal on the device:

journalctl -u atcd -n 50

A device that is not enrolled yet writes one line per poll cycle:

enrollment check failed: send enroll request. TLS or connect failure: check [server] ca_bundle_path

An enrolled device writes the check-in form of the same failure:

check-in errored: server: checkin send: error sending request for url (...). TLS or connect failure: check [server] ca_bundle_path

The atc CLI reports the same class of failure, and names both of its sources:

. TLS or connect failure: check [server] ca_bundle_path or SSL_CERT_FILE

The hint fires on every connect failure. A refused port and a wrong port get it too. A request that reached the server never gets it, so a 404 or a 500 is a different problem.

Install a device with the CA

atc bootstrap atcd install uploads the bundle and writes the config field:

atc bootstrap atcd install deploy@device \
  --server https://atc.example.com \
  --enrollment-token-file ./enroll.token \
  --ca-bundle ./ca.pem

--ca-bundle names a PEM file on your controller, not on the device. atc reads and parses that file before it opens the SSH connection. So a bundle atc cannot use fails before the device is touched:

error: ATC-E0419: bootstrap preflight on deploy@device failed: --ca-bundle ./ca.pem: No such file or directory (os error 2)

A file that exists but holds no certificate takes the same refusal, and the message then reads --ca-bundle the trust anchor bundle ./ca.pem holds no certificates.

The device copy lands at /etc/atc/ca.pem, owned by root, mode 0644. A certificate is public, and every account that verifies the control plane must read it. Bootstrap then writes these settings to atcd’s store. The device keeps no config file for them:

server.url = https://atc.example.com
server.control_mode = server_only
server.enrollment_token_path = /etc/atc/enroll.token
server.ca_bundle_path = /etc/atc/ca.pem

Read them on the device with atcd settings ls, as the account that runs atcd.

--ca-bundle needs --server. A trust anchor with no control plane is inert, so atc refuses the flag alone.

Add the CA to a device that is already installed

Copy the PEM to the device yourself, then set the path in atcd’s store. Run this on the device as the account that runs atcd (atc by default):

sudo -u atc atcd settings set server.ca_bundle_path=/etc/atc/ca.pem

An enrolled device refuses atc ota settings set from a laptop, because it accepts changes only from its server. atcd reads its settings at start, so restart the unit to apply the new value:

systemctl restart atcd

Give an absolute path. atcd settings set refuses a relative one and changes nothing.

A bundle atcd cannot use stops the pull cycle. Unreadable, unparseable, and holding no certificate all count. There is no fall back to the public roots. A silent fall back would turn a typo into a TLS failure much later:

certificate trust setup failed ([server] ca_bundle_path): the trust anchor bundle /etc/atc/ca.pem holds no certificates. The pull cycle stops until the bundle is fixed

atcd re-reads the file once per poll cycle, so this state recovers with no restart. Replace the bundle with write-then-rename. A partial file aborts one cycle, and the next cycle self-heals.

The atc CLI

atc composes two sources, in this order, on top of the public roots:

  1. [server] ca_bundle_path, from the atc.toml the command resolved
  2. $SSL_CERT_FILE

Both sources add anchors. Neither removes a public root, and neither replaces the other. A source you state but atc cannot use is an error, and the message names which of the two to fix:

error: control plane setting: [server] ca_bundle_path: cannot read the trust anchor bundle /home/you/ca.pem

Which roots each half adds to

[server] ca_bundle_path is additive everywhere. It never removes a root. Both halves of atcd add it to the same base set.

HalfBase trust setSource
Control-plane HTTPS (atc, atcd’s enrollment and check-in)the BUNDLED webpki rootsreqwest, rustls-tls
atcd’s reverse tunnel ([daemon.tunnel])the BUNDLED webpki rootsrustls, webpki_roots::TLS_SERVER_ROOTS

atc oci is a third case and is not in that table. Its base is the bundled webpki roots too, but the field does not reach it. The registry client reads $SSL_CERT_FILE alone. The component table below records that.

Two consequences follow.

  • A CA in the device image does nothing for the tunnel. The tunnel reads no system store. Name the CA of a private rendezvous in [server] ca_bundle_path. Before August 2026 the tunnel read the system store instead. A device on an older build still behaves the old way.
  • Name your own CA, and nothing else. An anchor is an issuer. Any CA in the bundle can sign a certificate for the rendezvous host name, and the device accepts it. A bundle holding a copy of a whole system store hands that power to every CA in it.

What each component reads

Component[server] ca_bundle_path$SSL_CERT_FILE
atcd, against the control planeyesno
atcd, on the reverse tunnelyesno
atc, against the control planeyesyes
atc fleet device shell, on the reverse tunnelyesyes
atc oci, against a registrynoyes
atc, with an atc.kdl manifestyes, as server { ca-bundle }yes
atc-server, against the IdPnoyes

Four consequences follow, and each one catches operators who expect one rule everywhere:

  • atcd reads no environment variable. A stray SSL_CERT_FILE in a systemd unit must not change the trust set of a whole fleet. That is a failure nobody configured. The device honors the config field alone.
  • A private registry behind the same CA needs $SSL_CERT_FILE. The registry client reads that variable alone, so a configured control plane does not cover atc oci. Go’s crypto/x509 reads the same variable, so skopeo gets the same trust from the same value.
  • An atc.kdl manifest names the bundle in its server block. server { ca-bundle "/abs/path.pem" } sets the same field as [server] ca_bundle_path. See the server block. An atc.kdl with no server block sets no bundle, so $SSL_CERT_FILE is then the only source atc has.
  • atc-server reads no atc.toml at all. Its configuration is environment variables. Set $SSL_CERT_FILE for the process when the identity provider sits behind your CA. Without it the server cannot fetch the signing keys, and then no token validates.