Modeling an existing nginx site safely with Ansible
A staged workflow for bringing an already-running nginx site under Ansible without overwriting unreviewed drift or reloading an invalid configuration.
Provisioning a new nginx site and adopting an existing one are different operations. During adoption, the running host is the source of truth until its configuration has been captured and reviewed. The first Ansible run should reproduce that state, not redesign it.
A safe takeover has four properties:
- it refuses to overwrite a configuration that changed after discovery;
- it preserves the existing file content, ownership, mode, and enablement topology;
- it validates the complete nginx configuration before reloading;
- it restores the previous file if validation or reload fails.
The paths and service names below are examples. Use only values observed on the target host. In particular, do not assume a Debian-style sites-available layout, /usr/sbin/nginx, or a service named nginx.
Discover the effective configuration
Start with read-only inspection. Record the nginx binary, main configuration file, include tree, service name, site file, ownership, mode, and any enablement symlink.
console sudo /path/to/nginx -t umask 077 sudo /path/to/nginx -T > ./nginx-effective.conf sudo stat -c '%U %G %a %n' /path/to/site.conf sudo sha256sum /path/to/site.conf
If the site is enabled through a symbolic link, record both the link and its resolved target:
console sudo readlink /path/to/enabled-site.conf sudo readlink -f /path/to/enabled-site.conf
nginx -T tests the configuration and writes the complete configuration, including included files, to standard output. Review that output before storing or sharing it because configuration files can contain sensitive values.
Discovery must also cover referenced objects that are not part of the site file itself, such as TLS certificate and key paths, authentication files, log directories, upstream Unix sockets, and additional includes. The first takeover should not rename or relocate any of them.
Capture an exact baseline
Copy the reviewed site file into the role without introducing Jinja expressions or formatting changes:
text roles/nginx_site/ ├── files/ │ └── example.conf └── tasks/ └── main.yml
Use ansible.builtin.copy for this first stage. A static file makes the intended baseline explicit and keeps the first run easy to compare with the host. Do not parameterize hostnames, ports, paths, or TLS settings yet. First establish that Ansible can manage the existing file without changing behavior.
Define takeover variables from the discovered state. The following values are examples and must be replaced:
yaml nginx_binary: /usr/sbin/nginx nginx_service_name: nginx nginx_site_path: /etc/nginx/sites-available/example.conf nginx_site_owner: root nginx_site_group: root nginx_site_mode: "0644" nginx_site_expected_sha256: "<sha256-from-the-reviewed-host>" nginx_site_takeover_guard: true
The expected checksum is an optimistic lock. The takeover tasks compare it with both the installed file and the captured repository baseline, preventing an old or incorrectly copied snapshot from replacing a site that was edited after discovery.
Refuse an unsafe takeover
Validate the currently installed configuration before changing anything, then verify that the target is the reviewed regular file:
`yaml
- name: Validate the current nginx configuration before takeover ansible.builtin.command: argv:
- "{{ nginx_binary }}"
- -t changed_when: false check_mode: false
- name: Inspect the current nginx site file ansible.builtin.stat: path: "{{ nginx_site_path }}" checksum_algorithm: sha256 register: nginx_site_before
- name: Require the expected regular file ansible.builtin.assert: that:
- nginx_site_before.stat.exists
- nginx_site_before.stat.isreg | default(false) fail_msg: "Refusing takeover because the expected nginx site file is absent or is not a regular file."
- name: Inspect the captured baseline on the controller ansible.builtin.stat: path: "{{ role_path }}/files/example.conf" checksum_algorithm: sha256 delegate_to: localhost become: false register: nginx_site_source when: nginx_site_takeover_guard | bool
- name: Refuse unreviewed drift during takeover ansible.builtin.assert: that:
- (nginx_site_before.stat.checksum | default('')) == nginx_site_expected_sha256
- (nginx_site_source.stat.checksum | default('')) == nginx_site_expected_sha256
- (nginx_site_before.stat.pw_name | default('')) == nginx_site_owner
- (nginx_site_before.stat.gr_name | default('')) == nginx_site_group
- (nginx_site_before.stat.mode | default('')) == nginx_site_mode fail_msg: >- Refusing takeover because the installed file, captured baseline, or file metadata no longer matches the reviewed state. Capture and review the current state before continuing. when: nginx_site_takeover_guard | bool
`
Keep the takeover guard enabled through the first live run and the following idempotence run. Disable it only in a reviewed commit before making intentional later changes, or replace it with a versioned precondition maintained for every rollout.
If the existing layout uses an enablement symlink, assert its current target before managing it:
`yaml
- name: Inspect the existing nginx enablement link ansible.builtin.stat: path: "{{ nginx_site_enabled_path }}" follow: false get_checksum: false register: nginx_site_link_before
- name: Require the reviewed enablement link ansible.builtin.assert: that:
- nginx_site_link_before.stat.exists
- nginx_site_link_before.stat.islnk | default(false)
- (nginx_site_link_before.stat.lnk_source | default('')) == nginx_site_path fail_msg: "Refusing takeover because the nginx enablement link differs from the reviewed state."
`
This check is preferable to creating or retargeting the link during a no-change takeover. Manage the link with ansible.builtin.file only after its intended state is explicit.
Install, validate, and roll back as one operation
An nginx site file is usually an included fragment, so it often cannot be validated in isolation. Ansible's file modules can validate a temporary file before installation, but that mechanism is insufficient when the validator needs the complete include tree or the candidate at its final path.
For that case, create a backup while installing the candidate, run nginx -t against the complete on-disk configuration, reload only after success, and restore the backup on failure:
`yaml
- name: Install and verify the reviewed nginx site block:
- name: Copy the reviewed nginx site ansible.builtin.copy: src: example.conf dest: "{{ nginx_site_path }}" owner: "{{ nginx_site_owner }}" group: "{{ nginx_site_group }}" mode: "{{ nginx_site_mode }}" backup: true register: nginx_site_update
- name: Validate the complete nginx configuration ansible.builtin.command: argv:
- "{{ nginx_binary }}"
- -t changed_when: false when:
- nginx_site_update is changed
- not ansible_check_mode
- name: Reload nginx after successful validation ansible.builtin.service: name: "{{ nginx_service_name }}" state: reloaded when:
- nginx_site_update is changed
- not ansible_check_mode
rescue:
- name: Restore the previous nginx site file ansible.builtin.copy: src: "{{ nginx_site_update.backup_file }}" dest: "{{ nginx_site_path }}" remote_src: true owner: "{{ nginx_site_before.stat.uid }}" group: "{{ nginx_site_before.stat.gid }}" mode: "{{ nginx_site_before.stat.mode }}" when:
- nginx_site_update is defined
- nginx_site_update.backup_file is defined
- name: Validate the restored nginx configuration ansible.builtin.command: argv:
- "{{ nginx_binary }}"
- -t changed_when: false when:
- nginx_site_update is defined
- nginx_site_update.backup_file is defined
- name: Reload nginx with the restored configuration ansible.builtin.service: name: "{{ nginx_service_name }}" state: reloaded when:
- nginx_site_update is defined
- nginx_site_update.backup_file is defined
- name: Stop after the failed deployment ansible.builtin.fail: msg: >- The nginx site deployment failed. Review the preceding validation, reload, and rollback results before continuing.
`
nginx itself checks a new configuration during reload and continues with the old configuration if it cannot apply the new one. That protects the running workers, but it does not restore an invalid file on disk. Explicit file rollback is still required so a later reload or restart does not encounter the rejected configuration again.
Retain or remove Ansible's timestamped backup according to the host's retention policy after the rollout is verified.
Prove the first run is a no-op
Start with one reviewed host:
console ansible-playbook -i inventory playbooks/nginx-site.yml \ --check --diff --limit web-01
Check mode is a simulation, not proof of a successful deployment. Tasks that depend on registered results may not execute exactly as they do in a live run. Diff output can also expose sensitive configuration values, so handle it as operational data.
If the diff contains any unplanned content or metadata change, stop and reconcile the repository baseline with the host. Then run the play normally on the same host and immediately run it a second time:
console ansible-playbook -i inventory playbooks/nginx-site.yml --limit web-01 ansible-playbook -i inventory playbooks/nginx-site.yml --limit web-01
For an exact takeover, the first live run should report no change to the site file. The second run must be idempotent. After each run, perform the environment-specific HTTP, TLS, and upstream smoke tests used for that service.
For multiple equivalent hosts, roll out in controlled batches rather than changing the whole group concurrently:
`yaml
- name: Adopt the existing nginx site hosts: nginx become: true serial: 1
roles:
- nginx_site
`
A checksum mismatch on any host is a discovery result, not an error to suppress. Review that host separately before adding it to the same model.
Introduce templating only after adoption
Once the static baseline has completed a no-change rollout, convert files/example.conf to templates/example.conf.j2 and replace only values that truly vary between managed hosts or environments.
Make one class of change at a time. For each step:
- compare the rendered file with the accepted baseline;
- run the play in check and diff mode on one host;
- validate the complete configuration with
nginx -t; - apply to one host and run service-specific smoke tests;
- confirm a second run reports no changes.
Keep the same backup, full-configuration validation, reload, and rescue structure when changing from copy to template. The deployment mechanism changes; the safety properties do not.
Acceptance criteria
The site is safely modeled when all of the following are true:
- the pre-change
nginx -tsucceeds; - the repository contains the reviewed current site, without embedded secrets;
- the takeover checksum matches both the target and the captured repository file immediately before the first run;
- the file path, ownership, mode, and enablement topology match the reviewed state;
- no reload occurs unless the managed file changed and the complete configuration passed validation;
- a failed validation restores and reloads the previous configuration;
- the canary host passes service-specific smoke tests;
- the next Ansible run is idempotent.
The important boundary is simple: adoption records existing behavior; improvement changes it. Keep those as separate, reviewable rollouts.