Four packages, one playbook. This should be a boring afternoon, and it mostly is — except for one ordering problem that breaks almost every tutorial on the subject, in a way that only shows up on a genuinely fresh server.
So let me start there, because if you understand it the rest is just YAML.
The deadlock nobody warns you about
You write an nginx vhost template with TLS configured, because that is the end state you want:
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
You deploy it, and nginx refuses to start, because those files do not exist yet. Certbot then cannot complete the HTTP-01 challenge, because completing it requires a working web server to serve the challenge file. No nginx, no certificate. No certificate, no nginx.
The reason this trap is so widespread is that it is invisible on the second run. Once a certificate exists on the box, the playbook works perfectly forever — so anyone testing against a server they already provisioned by hand will never see it. It only bites on a clean machine, which is to say, exactly when you need the automation to work.
The fix is to make TLS conditional on the certificate actually existing. Deploy an HTTP-only vhost that can serve the ACME challenge, obtain the certificate, then re-render the vhost with TLS turned on. Two phases, one template, driven by a stat check.
Layout
ansible/
├── ansible.cfg
├── inventory/
│ └── production.yml
├── group_vars/
│ └── web/
│ ├── main.yml
│ └── vault.yml # ansible-vault encrypted
├── site.yml
└── roles/
├── common/
├── mysql/
├── php/
├── nginx/
└── letsencrypt/
Five roles, in dependency order. site.yml is thin:
- name: Provision web servers
hosts: web
become: true
roles:
- common
- mysql
- php
- nginx
- letsencrypt
Variables in group_vars/web/main.yml:
php_version: "8.3"
app_domain: example.com
app_root: /var/www/example.com
app_db_name: exampledb
app_db_user: exampleuser
letsencrypt_email: admin@example.com
letsencrypt_webroot: /var/www/letsencrypt
Passwords go in vault.yml, encrypted, never in the file above:
ansible-vault create group_vars/web/vault.yml
Prefix vaulted variables consistently — vault_app_db_password — so it is obvious at every use site that the value is encrypted and where it lives.
MySQL
Two things bite here. Ansible's MySQL modules need a Python driver on the target host, and MySQL 8 on Ubuntu authenticates root through the auth_socket plugin rather than a password — so every module call needs login_unix_socket instead of credentials.
- name: Install MySQL server and Python bindings
ansible.builtin.apt:
name:
- mysql-server
- python3-pymysql
state: present
update_cache: true
- name: Ensure MySQL is running
ansible.builtin.service:
name: mysql
state: started
enabled: true
- name: Remove anonymous users
community.mysql.mysql_user:
name: ''
host_all: true
state: absent
login_unix_socket: /run/mysqld/mysqld.sock
- name: Remove the test database
community.mysql.mysql_db:
name: test
state: absent
login_unix_socket: /run/mysqld/mysqld.sock
- name: Create the application database
community.mysql.mysql_db:
name: "{{ app_db_name }}"
encoding: utf8mb4
collation: utf8mb4_unicode_ci
state: present
login_unix_socket: /run/mysqld/mysqld.sock
- name: Create the application user
community.mysql.mysql_user:
name: "{{ app_db_user }}"
password: "{{ vault_app_db_password }}"
priv: "{{ app_db_name }}.*:ALL"
host: localhost
state: present
login_unix_socket: /run/mysqld/mysqld.sock
no_log: true
- name: Bind MySQL to loopback only
ansible.builtin.lineinfile:
path: /etc/mysql/mysql.conf.d/mysqld.cnf
regexp: '^bind-address'
line: 'bind-address = 127.0.0.1'
notify: Restart mysql
no_log: true on the user task is not optional. Without it, the password appears in plaintext in your playbook output and in whatever CI system is capturing it — which defeats the entire point of having encrypted it.
Use utf8mb4, not utf8. MySQL's utf8 is a three-byte subset that cannot store emoji or a large chunk of CJK, and discovering this after you have production data in the table is a genuinely tedious afternoon.
PHP-FPM
Ubuntu 24.04 ships PHP 8.3 in the default repositories, which is recent enough that you probably do not need a third-party PPA. Keeping the version in a variable means the socket path in the nginx template stays in sync automatically.
- name: Install PHP-FPM and extensions
ansible.builtin.apt:
name:
- "php{{ php_version }}-fpm"
- "php{{ php_version }}-mysql"
- "php{{ php_version }}-mbstring"
- "php{{ php_version }}-xml"
- "php{{ php_version }}-curl"
- "php{{ php_version }}-gd"
- "php{{ php_version }}-intl"
- "php{{ php_version }}-zip"
state: present
update_cache: true
- name: Apply PHP configuration overrides
ansible.builtin.template:
src: 99-app.ini.j2
dest: "/etc/php/{{ php_version }}/fpm/conf.d/99-app.ini"
owner: root
group: root
mode: '0644'
notify: Restart php-fpm
- name: Ensure PHP-FPM is running
ansible.builtin.service:
name: "php{{ php_version }}-fpm"
state: started
enabled: true
Drop your overrides into conf.d/ rather than editing php.ini. The distribution owns php.ini and will overwrite it on upgrade; a numbered file in conf.d survives, and it makes your deliberate changes visible in one short file instead of buried in two thousand lines of defaults.
nginx, in two phases
The vhost template carries the conditional. Everything that must work before a certificate exists sits outside the if; everything TLS sits inside it.
server {
listen 80;
server_name {{ app_domain }};
location /.well-known/acme-challenge/ {
root {{ letsencrypt_webroot }};
}
{% if tls_enabled | default(false) %}
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl http2;
server_name {{ app_domain }};
ssl_certificate /etc/letsencrypt/live/{{ app_domain }}/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/{{ app_domain }}/privkey.pem;
{% endif %}
root {{ app_root }}/public;
index index.php;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \.php$ {
include snippets/fastcgi-php.conf;
fastcgi_pass unix:/run/php/php{{ php_version }}-fpm.sock;
}
}
On nginx 1.25.1 and later, listen 443 ssl http2; is deprecated in favour of a separate http2 on; directive. Ubuntu 24.04 ships 1.24, so the combined form above is correct there — check nginx -v if you are on something newer.
The role itself, with the vhost work split into vhost.yml so the letsencrypt role can call it again later:
# roles/nginx/tasks/vhost.yml
- name: Check for an existing certificate
ansible.builtin.stat:
path: "/etc/letsencrypt/live/{{ app_domain }}/fullchain.pem"
register: cert
- name: Deploy the vhost
ansible.builtin.template:
src: vhost.conf.j2
dest: "/etc/nginx/sites-available/{{ app_domain }}.conf"
owner: root
group: root
mode: '0644'
vars:
tls_enabled: "{{ cert.stat.exists }}"
notify: Reload nginx
- name: Enable the vhost
ansible.builtin.file:
src: "/etc/nginx/sites-available/{{ app_domain }}.conf"
dest: "/etc/nginx/sites-enabled/{{ app_domain }}.conf"
state: link
notify: Reload nginx
- name: Remove the default site
ansible.builtin.file:
path: /etc/nginx/sites-enabled/default
state: absent
notify: Reload nginx
Validate before reloading, always. A broken config that gets reloaded takes the site down; a broken config caught by nginx -t just fails the playbook:
# roles/nginx/handlers/main.yml
- name: Reload nginx
ansible.builtin.service:
name: nginx
state: reloaded
listen: Reload nginx
with a validation task run before handlers flush, or --check style gating if you prefer. Reload rather than restart: nginx reload is zero-downtime, restart is not.
Let's Encrypt
- name: Install certbot
ansible.builtin.apt:
name: certbot
state: present
- name: Create the ACME challenge webroot
ansible.builtin.file:
path: "{{ letsencrypt_webroot }}/.well-known/acme-challenge"
state: directory
owner: www-data
group: www-data
mode: '0755'
- name: Obtain the certificate
ansible.builtin.command:
cmd: >
certbot certonly
--webroot
--webroot-path {{ letsencrypt_webroot }}
--domains {{ app_domain }}
--email {{ letsencrypt_email }}
--agree-tos
--non-interactive
creates: "/etc/letsencrypt/live/{{ app_domain }}/fullchain.pem"
register: certbot_result
- name: Re-render the vhost now that TLS is available
ansible.builtin.include_role:
name: nginx
tasks_from: vhost
when: certbot_result is changed
Three details in there are doing real work.
--webroot, not --nginx. Certbot's nginx plugin edits your nginx configuration for you. That is convenient in a hand-managed setup and actively harmful in an Ansible-managed one, because now two systems own the same file: certbot rewrites it, Ansible reverts it on the next run, and you get config drift on a loop. Webroot mode touches nothing but the challenge directory.
creates: is your rate-limit insurance. Let's Encrypt allows 50 certificates per registered domain per week, and only 5 identical certificates in that window. A non-idempotent playbook run a few times while you debug something else will exhaust that faster than you would think, and then you are locked out for days. The creates argument means the command simply does not run once the file exists.
Test against staging first. Add --staging to that command while you are getting the playbook working. Staging certificates are untrusted by browsers but they do not count against your limits. Remove the flag, delete /etc/letsencrypt, and run once more for the real thing.
Renewal is already handled — except for the reload
The Debian and Ubuntu certbot package installs a systemd timer that attempts renewal twice a day. Do not add a cron job; you will just be running it twice.
What the package does not do is tell nginx about the new certificate. Renewal succeeds, the files on disk are updated, and nginx keeps serving the old certificate out of memory until something reloads it — so the site quietly expires roughly ninety days later. The fix is a deploy hook:
- name: Install the renewal deploy hook
ansible.builtin.copy:
dest: /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
mode: '0755'
content: |
#!/bin/sh
systemctl reload nginx
Scripts in renewal-hooks/deploy/ run only when a certificate has actually been renewed, so this reloads nginx a handful of times a year rather than twice a day.
Running it
# Syntax and connectivity
ansible-playbook site.yml --syntax-check
ansible -m ping web
# Dry run — see what would change
ansible-playbook site.yml --check --diff --ask-vault-pass
# Real run
ansible-playbook site.yml --ask-vault-pass
One honest caveat about --check on a first run: it will report failures for anything downstream of a package that has not been installed yet. That is expected, not a bug in your playbook. Check mode is genuinely useful for the second run onward, where it becomes a drift report.
The real test of whether any of this works is destroying the server and running the playbook again from nothing. If it completes on a fresh box with a working TLS site at the end, the ordering is right. If it only works the second time, you still have the deadlock — and now you know exactly where to look.