Provisioning a PHP web server with Ansible: MySQL, PHP-FPM, nginx and Let's Encrypt

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.

Add new comment

Restricted HTML

  • Allowed HTML tags: <a href hreflang> <em> <strong> <cite> <blockquote cite> <code> <ul type> <ol start type> <li> <dl> <dt> <dd> <h2 id> <h3 id> <h4 id> <h5 id> <h6 id>
  • Lines and paragraphs break automatically.
  • Web page addresses and email addresses turn into links automatically.
Please share this article on your favorite website or platform.