A certificate has four failure modes and automation addresses them in
descending order of how often they are handled:
It is missing or wrong — handled by everyone.
Its private key is readable by users who should not read it —
handled by some.
The service is serving the old one because nothing reloaded — handled
by fewer.
It expires on a Saturday — handled by almost nobody until it happens
once.
This lesson is the sequence that addresses all four.
community.crypto is a collection, not part of ansible-core. It has
to be installed before any of this runs:
Read-only / Safethe collection has to be present— Read-only in the sense that it touches only the controller collection path. Pin the version in requirements.yml; Part XXVII covers why.
ansible-galaxy collection list community.crypto
ansible-galaxy collection install community.crypto
The sequence
Three modules in order, each producing the input to the next. The order
is not negotiable — a CSR needs a key, a certificate needs a CSR.
Configuration changekey, CSR, certificate— Parameter names verified against the module documentation for the latest collection release. The mode on the private key is the security-relevant line.
provider is required and takes acme, ownca or selfsigned. For a
public certificate the shape is the same with provider: acme, preceded
by acme_account and acme_certificate to complete the challenge; the
deployment, permissions and reload half of this lesson is identical
either way.
With subject_alt_name, values carry a prefix — DNS:, IP:, URI::
A validation assertion, so the mode is checked rather than assumed:
Read-only / Safeassert the permissions, do not assume them— stat is read-only. This belongs in the validation play, because a mode set correctly in March can be changed by anything in the intervening months.
- name: Read the private key metadata
ansible.builtin.stat:
path: /etc/ssl/private/{{ cert_common_name }}.key
register: key_stat
check_mode: false
- name: The private key is not readable beyond its group
ansible.builtin.assert:
that:
- key_stat.stat.exists
- key_stat.stat.pw_name == 'root'
- key_stat.stat.mode is match('06[04]0')
fail_msg: >-
Private key on {{ inventory_hostname }} is
{{ key_stat.stat.mode | default('missing') }} owned by
{{ key_stat.stat.pw_name | default('nobody') }}
quiet: true
Reload, not restart
The handler reloads. It matters, and the reason generalises.
Service impact possiblehandlers/main.yml— A reload re-reads configuration without dropping established connections. A restart drops them.
- name: reload web server
ansible.builtin.systemd_service:
name: nginx
state: reloaded
A restart terminates the process and every connection it holds. Across a
fleet of 40 web servers on a certificate renewal, that is 40 simultaneous
connection drops for a change that did not require any of them.
Two caveats keep this from being a rule you apply blindly.
Not every service picks up a certificate on reload. Some read
certificate material only at startup. Confirm it for the specific
service rather than assuming; the validation step below is what tells
you, because it checks the certificate the service is actually serving.
A reload does not survive a bad certificate. If the new file is
malformed the reload fails and the service continues with the previous
one — which is the safe outcome, and it means the play must notice the
failed reload rather than treating it as a no-op.
Expiry is a fact, not a calendar reminder
The fourth failure mode. x509_certificate_info reads a certificate and
returns not_after as an ASN.1 time, expired as a boolean, and — the
useful one — valid_at, which the documentation describes as returning,
for each named time specification, “a boolean whether the certificate
is valid at that point in time or not”.
Read-only / Saferenewal scheduled from the certificate itself— valid_at asks the question directly: will this still be valid in 30 days? The answer is a boolean, not a date to subtract.
- name: Read the deployed certificate
community.crypto.x509_certificate_info:
path: /etc/ssl/certs/{{ cert_common_name }}.crt
valid_at:
in_two_weeks: "+14d"
in_a_month: "+30d"
register: cert_info
check_mode: false
- name: The certificate is valid now
ansible.builtin.assert:
that:
- not cert_info.expired
fail_msg: >-
EXPIRED certificate on {{ inventory_hostname }}:
not_after {{ cert_info.not_after }}
quiet: true
- name: The certificate is not inside the renewal window
ansible.builtin.assert:
that:
- cert_info.valid_at.in_a_month
fail_msg: >-
{{ inventory_hostname }} certificate expires within 30 days
(not_after {{ cert_info.not_after }}) - schedule renewal
quiet: true
The two assertions are deliberately different findings. The first is an
incident. The second is a work item with a month of lead time, and it is
the one that prevents the first.
Knowledge check
Knowledge check · 4 questions
Q1. A scheduled play regenerates certificates using the community.crypto sequence. A certificate expires in four days and the play reports ok on every task, changing nothing. Why?
Q2. A renewal play writes the new certificate and notifies a reload handler. The service continues serving the old certificate. Which are plausible causes? Select all that apply.
Q3. Asserting on the certificate file with x509_certificate_info is necessary but not sufficient - proving the service serves a valid certificate requires connecting to it.
Q4. Which permission arrangement is correct for a TLS private key read by a service that does not run as root?
Passing score: 75%. Answers are checked in this browser.