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:
[server] ca_bundle_path, from theatc.tomlthe command resolved$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.
| Half | Base trust set | Source |
|---|---|---|
Control-plane HTTPS (atc, atcd’s enrollment and check-in) | the BUNDLED webpki roots | reqwest, rustls-tls |
atcd’s reverse tunnel ([daemon.tunnel]) | the BUNDLED webpki roots | rustls, 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 plane | yes | no |
atcd, on the reverse tunnel | yes | no |
atc, against the control plane | yes | yes |
atc fleet device shell, on the reverse tunnel | yes | yes |
atc oci, against a registry | no | yes |
atc, with an atc.kdl manifest | yes, as server { ca-bundle } | yes |
atc-server, against the IdP | no | yes |
Four consequences follow, and each one catches operators who expect one rule everywhere:
- atcd reads no environment variable. A stray
SSL_CERT_FILEin 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 coveratc oci. Go’scrypto/x509reads the same variable, so skopeo gets the same trust from the same value. - An
atc.kdlmanifest names the bundle in itsserverblock.server { ca-bundle "/abs/path.pem" }sets the same field as[server] ca_bundle_path. See theserverblock. Anatc.kdlwith noserverblock sets no bundle, so$SSL_CERT_FILEis then the only sourceatchas. atc-serverreads noatc.tomlat all. Its configuration is environment variables. Set$SSL_CERT_FILEfor the process when the identity provider sits behind your CA. Without it the server cannot fetch the signing keys, and then no token validates.
Related
- Onboarding walks the first update end to end.
- Verifying the binary covers a different trust
question: whether the
atcbinary itself is genuine.