Ansible role: add CA certificates to the Debian/Ubuntu system trust store
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Paul Provost e8d4211c0c README: document checking, troubleshooting and rotation
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 21:42:15 -04:00
defaults Initial ca-trust role 2026-10-17 21:25:52 -04:00
handlers Initial ca-trust role 2026-10-17 21:25:52 -04:00
meta Initial ca-trust role 2026-10-17 21:25:52 -04:00
tasks Initial ca-trust role 2026-10-17 21:25:52 -04:00
README.md README: document checking, troubleshooting and rotation 2026-10-17 21:42:15 -04:00

ansible-role-ca-trust

Adds CA certificates to the Debian/Ubuntu system trust store, so tools that use /etc/ssl/certs/ca-certificates.crt (system curl, openssl, git, python3, apt...) trust them. Typical use: a private CA, such as Caddy's local CA for LAN-only sites.

What it does

  1. Fails unless every ca_trust_certs entry has a name and a src, and each src is a single PEM certificate.
  2. Installs ca-certificates.
  3. Copies each certificate to /usr/local/share/ca-certificates/<name>.crt.
  4. Runs update-ca-certificates when any of them changed. That links the certificate as /etc/ssl/certs/<name>.pem and appends it to the combined bundle /etc/ssl/certs/ca-certificates.crt.

Variables

Variable Default Description
ca_trust_certs [] {name, src}: src is a PEM file on the control machine

name becomes the file name, so keep it a plain slug (no spaces or slashes). The .crt extension is added by the role: update-ca-certificates ignores files without it.

Example playbook

- hosts: all
  vars:
    ca_trust_certs:
      - name: caddy-local-root
        src: "{{ playbook_dir }}/files/caddy-local-root.crt"
  roles:
    - ca-trust

Record the certificate's SHA-256 fingerprint next to the variable (see below for how to get it), so a reader can tell which CA it is.

Checking that it worked

In the commands below, <name> is the entry's name, and <site> is a host name served with a certificate from that CA.

1. The certificate is installed

ls -l /etc/ssl/certs/<name>.pem

Expect a symlink to /usr/local/share/ca-certificates/<name>.crt, created by update-ca-certificates.

openssl x509 -in /usr/local/share/ca-certificates/<name>.crt -noout -subject -enddate -fingerprint -sha256

Compare the fingerprint with the one you recorded (or with the CA's own: openssl x509 -in <the CA's root.crt> -noout -fingerprint -sha256). It proves the right certificate was installed. notAfter is when the root expires.

awk -v cmd='openssl x509 -noout -subject' '/BEGIN CERT/{close(cmd)} {print | cmd}' \
  /etc/ssl/certs/ca-certificates.crt | grep -i '<part of the subject>'

This lists the subject of every certificate in the combined bundle (grep -c on the file itself can't work, it's base64). Expect the root's subject. This is the file most tools read.

2. Tools trust it

System curl (also what git uses over HTTPS):

/usr/bin/curl -sv -o /dev/null https://<site>/ 2>&1 | grep -E 'issuer:|verify ok'

Expect the CA's name as issuer: (often an intermediate) and SSL certificate verify ok.

The full chain, as OpenSSL sees it:

echo | /usr/bin/openssl s_client -connect <site>:443 -servername <site> -verify_return_error 2>&1 \
  | grep -E 'Verify return code|^ *[0-9] s:|i:'

Expect the chain to end at the root (for example leaf, issued by the intermediate, issued by the root) and Verify return code: 0 (ok). -servername matters: without it, the server may not know which certificate to send.

System Python:

/usr/bin/python3 -c "import urllib.request; print(urllib.request.urlopen('https://<site>/').status)"

Expect an HTTP status (redirects are followed, so usually 200) rather than CERTIFICATE_VERIFY_FAILED.

3. Tools with their own trust store

These don't read the system store, so the role alone doesn't make them trust the CA:

Tool Reads Fix
Homebrew openssl, curl, python3 (ssl, urllib) Homebrew's bundle, $(brew --prefix)/etc/ca-certificates/cert.pem SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt
Python requests, pip certifi's bundle REQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt
Chrome/Chromium, Firefox NSS databases (~/.pki/nssdb, the Firefox profile) certutil from libnss3-tools, or the browser's settings
Docker containers the image's own store install the certificate in the image, or mount the bundle

Set the environment variables in the shell's startup files rather than editing Homebrew's cert.pem: the ca-certificates formula owns that file and an upgrade can replace it. The system bundle includes the public roots as well, so nothing else stops trusting anything. The variables only reach programs started from that shell; cron jobs and systemd services don't see them.

To check Homebrew's tools, run in a new shell:

echo $SSL_CERT_FILE
echo | openssl s_client -connect <site>:443 -servername <site> -verify_return_error 2>&1 | grep 'Verify return code'

Expect /etc/ssl/certs/ca-certificates.crt and 0 (ok). To confirm it's the variable doing the work, run the same check without it:

echo | env -u SSL_CERT_FILE openssl s_client -connect <site>:443 -servername <site> 2>&1 | grep 'Verify return code'

Expect 20 (unable to get local issuer certificate), since Homebrew's own bundle doesn't have the CA.

Troubleshooting

  • Step 1 fails (no symlink, or the subject isn't in the bundle): re-run the playbook. If the file is in /usr/local/share/ca-certificates/ but not linked, run sudo update-ca-certificates and check that it reports 1 added (a name not ending in .crt is skipped silently).
  • Wrong fingerprint: src points at an old or different certificate. Fix src and re-run; the file is replaced and the bundle rebuilt.
  • Step 1 passes but a tool still fails: the tool has its own store (see the table), or a variable isn't set in that shell. Check it with echo $SSL_CERT_FILE; a shell started before the variable was added won't have it.
  • Verify code 20 even from system tools: the server isn't sending its intermediate, or the certificate isn't from this CA at all. Look at the s:/i: lines of the chain check: the last i: must match the root's subject.
  • Verify code 10 (certificate has expired): check notAfter from step 1, then the site's certificate. A private CA's leaf certificates are usually short-lived and renewed by the server.

Rotating or removing a certificate

  • New root: replace the file at src (same name) and re-run. Update the recorded fingerprint.

  • Removing: deleting the entry doesn't uninstall it. Remove it by hand:

    sudo rm /usr/local/share/ca-certificates/<name>.crt
    sudo update-ca-certificates --fresh
    

    --fresh rebuilds /etc/ssl/certs from scratch, so the stale symlink goes too.