03

Deploy Ubuntu and Nginx with Bounded Evidence

Carry the reusable-role receipt across SSH and sudo without mistaking source code for production proof.

System map · Day 03

Whole-system design

5 stable layers. Today's work is expanded and linked; the rest stays in context.

Intent and source

Covered — Role source

Playbook control

Source-backed today

Declares sudo, Debian-family validation, and serial one-host-at-a-time execution in the source playbook.

Selection and control

Covered — Inventory selection · Variable resolution

Compute and execution

Covered — Ansible control node

Managed-node runtime

Design target · not proved

Represents the remote Ubuntu host that must answer SSH and sudo checks; no live host result ships in the POC.

Nginx runtime

Design target · not proved

Represents the service process that should reload only after nginx -t passes; no live service result is claimed.

Storage and state

Covered — Generated local configuration · Change-triggered marker

Nginx site files

Design target · not proved

Represents role-owned site content and virtual-host files that source tasks intend to persist remotely.

Evidence and feedback

Covered — Assertions and recap

HTTP response proof

Design target · not proved

Requires an operator-supplied HTTP response and content check before availability can be discussed.

Traversed today

invokes modules · Ansible control nodeManaged-node runtimewrites site · Managed-node runtimeNginx site filesloads config · Nginx site filesNginx runtimeserves response · Nginx runtimeHTTP response proof

Move the proven contract across a real boundary

Day 02 produced the reusable-role and idempotency receipt: injected values, validation failure, recovery, a change-triggered handler, and a zero-change second run on localhost. Today you will preserve that reasoning while targeting a remote Ubuntu host over SSH, escalating through sudo, limiting rollout to one host at a time, validating Nginx, checking HTTP content, rehearsing one failure, and cleaning only role-owned paths.

The source archive contains a production-shaped example, not an observed production deployment. Its remote address is a documentation address, no reachable host or credentials are supplied, and no live output is bundled. Source-backed atlas cards therefore describe inventory and playbook seams; remote runtime, Nginx health, and HTTP response remain design targets—not proved until your own evidence record supplies them.

Bind one SSH identity to one web target

Remote automation fails dangerously when a host alias resolves ambiguously or credentials are copied into source. Inventory must bind an explicit host, login user, and key reference while keeping private key bytes outside the repository.

The POC ships this exact example inventory:

---
all:
  children:
    web:
      hosts:
        app01:
          ansible_host: 203.0.113.10
          ansible_user: ubuntu
          ansible_ssh_private_key_file: ~/.ssh/id_ed25519
      vars:
        nginx_site_app_name: learning-ansible
        nginx_site_app_environment: development
        nginx_site_app_port: 80

Copy it to the intentionally untracked working inventory, replace the documentation address with one disposable Ubuntu host you control, and keep the key file out of course source:

cp inventory/dev.yml.example inventory/dev.yml
ansible-inventory -i inventory/dev.yml --host app01
python3 - <<'PY'
import json
import os
import stat
import subprocess

record = json.loads(
    subprocess.check_output(
        ["ansible-inventory", "-i", "inventory/dev.yml", "--host", "app01"],
        text=True,
    )
)
raw_key = record.get("ansible_ssh_private_key_file", "")
key_path = os.path.expanduser(raw_key)
if not key_path or not os.path.isfile(key_path) or not os.access(key_path, os.R_OK):
    raise SystemExit("Configured SSH key is not a readable file")
mode = stat.S_IMODE(os.stat(key_path).st_mode)
if mode & 0o077:
    raise SystemExit(f"SSH key permissions are too open: {mode:04o}")
print(f"SSH key is readable with restrictive mode {mode:04o}")
PY

Expected precondition evidence is exactly one app01 target, its intended address, user ubuntu, and a key readable only by its owner or a stricter mode. The Python guard expands ~ with expanduser; it does not evaluate shell text or print key content. Abort if the alias resolves to zero or multiple targets. Do not disable SSH host-key checking; add the verified server key to known_hosts so a network responder cannot silently substitute for your intended machine.

Bound privilege and rollout in the playbook

SSH reachability does not grant permission to install packages or edit /etc, so the playbook must name its privilege boundary. Ansible's become feature uses an existing escalation system such as sudo; it does not create permission, and the login user must already be authorized, as the privilege-escalation guide explains.

The source playbook also limits execution with serial: 1 and rejects unsupported operating-system families before running the role:

---
- name: Configure a real Ubuntu Nginx web server
  hosts: web
  become: true
  gather_facts: true
  serial: 1

  pre_tasks:
    - name: Require a Debian-family managed node
      ansible.builtin.assert:
        that:
          - ansible_facts.os_family == "Debian"
        fail_msg: This example role supports Debian and Ubuntu only.

  roles:
    - role: nginx_site

serial: 1 tells Ansible to operate on one selected host at a time; the execution strategy guide distinguishes that batch limit from the default parallel strategy. It bounds simultaneous impact, but it does not create load-balancer draining, redundancy, health routing, or rollback.

Prove transport before asking for change

A playbook cannot repair a bad SSH route, unknown host key, missing Python runtime, or unavailable sudo policy. Test lower layers first so an execution failure names the boundary that is actually broken.

The POC README gives this source-backed sequence for ping, prediction, and apply:

cp inventory/dev.yml.example inventory/dev.yml
ansible -i inventory/dev.yml web -m ansible.builtin.ping
ansible-playbook -i inventory/dev.yml playbooks/ubuntu_nginx.yml --check --diff
ansible-playbook -i inventory/dev.yml playbooks/ubuntu_nginx.yml --diff

After editing inventory/dev.yml, narrow every command to the resolved host:

ansible -i inventory/dev.yml app01 -m ansible.builtin.ping
ansible -i inventory/dev.yml app01 -b -m ansible.builtin.command -a 'id -u'
ansible-playbook -i inventory/dev.yml playbooks/ubuntu_nginx.yml --check --diff --limit app01

Expected transport evidence is pong; expected sudo evidence is stdout 0; expected check evidence is a predicted change set without an applied mutation for modules that support check mode. --check remains a simulation and --diff can expose file content, so do not use diff output for secret-bearing templates. If these preconditions fail, stop before apply.

Apply only after recording target address, host-key fingerprint, inventory group, user, and timestamp:

ansible-playbook -i inventory/dev.yml playbooks/ubuntu_nginx.yml --diff --limit app01

No output is predicted here because package cache, filesystem, and service state differ by host. Your evidence record must preserve actual recap counters and failed task, if any, rather than replacing them with this course's expected happy path.

Validate configuration before service reload

A syntactically invalid Nginx file can turn a configuration change into an outage, so validation must occur before service control is accepted as healthy. The role writes the site and virtual-host files, removes the default enabled site, runs nginx -t, then requires the service to be enabled and started.

These exact source tasks persist role-owned page and virtual-host state:

- name: Deploy the home page
  ansible.builtin.template:
    src: index.html.j2
    dest: "{{ nginx_site_root }}/index.html"
    owner: www-data
    group: www-data
    mode: "0644"

- name: Render the Nginx virtual host
  ansible.builtin.template:
    src: site.conf.j2
    dest: "/etc/nginx/sites-available/{{ nginx_site_app_name }}"
    owner: root
    group: root
    mode: "0644"
  notify: Reload Nginx

This exact source excerpt places configuration validation before service state:

- name: Validate the complete Nginx configuration
  ansible.builtin.command: /usr/sbin/nginx -t
  changed_when: false

- name: Ensure Nginx is enabled and running
  ansible.builtin.service:
    name: nginx
    enabled: true
    state: started

The template task notifies this exact handler only when the virtual-host file changes:

---
- name: Reload Nginx
  ansible.builtin.service:
    name: nginx
    state: reloaded

Inspect the resulting host state through the same bounded target:

ansible -i inventory/dev.yml app01 -b -m ansible.builtin.command -a '/usr/sbin/nginx -t'
ansible -i inventory/dev.yml app01 -b -m ansible.builtin.service_facts
ansible-playbook -i inventory/dev.yml playbooks/ubuntu_nginx.yml --limit app01

Expected evidence is a successful Nginx syntax check, nginx.service reported running, and a second identical play recap of changed=0 and failed=0. Service state proves only process-manager observation; it does not prove a client can reach or use the site.

Require a response outside the control loop

Ansible recap and service state can both be green while routing, firewall, bind address, or page content is wrong. HTTP proof must therefore come from an independent request to the resolved host and assert learner-visible content.

This is an operator-supplied evidence protocol, not output bundled with the POC:

TARGET_IP="$(ansible-inventory -i inventory/dev.yml --host app01 | python3 -c 'import json, sys; print(json.load(sys.stdin)["ansible_host"])')"
test -n "$TARGET_IP" && test "$TARGET_IP" != "null"
curl --fail --silent --show-error "http://$TARGET_IP/" | tee /tmp/ansible-nginx-response.html
grep -F '<h1>learning-ansible</h1>' /tmp/ansible-nginx-response.html
grep -F '<p>Environment: development</p>' /tmp/ansible-nginx-response.html

Expected evidence is HTTP success plus both exact content lines. Record actual status, response digest, target address, timestamp, and playbook source revision. One response does not prove production availability: the example has no load balancer, TLS, authenticated boundary, multi-zone redundancy, monitoring, backup, capacity test, or sustained success measurement.

Local and remote failures also differ. Day 01 collapsed control and managed execution onto one laptop. This day separates them over SSH, but a single example host still collapses application, Nginx, filesystem, network interface, power source, and OS failure into one node. serial: 1 bounds rollout width; it does not remove that shared failure domain.

Rehearse one precondition failure and recovery

A safe deployment must stop before mutation when the target violates the role's supported platform contract. Use a disposable non-Debian test host only if you already own one; otherwise inspect the assertion as design evidence and do not fabricate a run result.

The failure seam is exact source:

- name: Require a Debian-family managed node
  ansible.builtin.assert:
    that:
      - ansible_facts.os_family == "Debian"
    fail_msg: This example role supports Debian and Ubuntu only.

Expected failure evidence on a non-Debian host is the named assertion, exact failure message, no Nginx role task execution, and non-zero exit. An Ubuntu app01 is the unaffected positive control. Recovery means correcting inventory to the intended Ubuntu host, re-proving its host key and ping, then repeating check/diff before apply; skipping back to apply loses the precondition evidence.

If your target is Ubuntu but Nginx validation fails instead, fix the source template, run --syntax-check, then repeat check/diff and apply. Never bypass nginx -t or force the reload; validation failure is the protection, not an obstacle.

Choose the one-host rollout width

Rollout reasoning becomes vague when target count and batch size both vary. The shipped inventory supplies one app01 host, so hold that selection constant; only the effect of source value serial: 1 is unknown.

Inspect both facts without contacting a remote host:

ansible-inventory -i inventory/dev.yml.example --graph
python3 - <<'PY'
from pathlib import Path

line = next(
    row.strip()
    for row in Path("playbooks/ubuntu_nginx.yml").read_text().splitlines()
    if row.strip().startswith("serial:")
)
print(line)
PY

Question: with serial: 1, what is the maximum number of hosts Ansible operates on in this play at once?

Expected answer: one host. Deterministic evidence is one app01 beneath web in the inventory graph plus printed serial: 1; no second host or live SSH session is required. If you answer that the value cannot matter with one host, you are confusing current inventory size with rollout policy: inventory selects eligible hosts, while serial caps each batch when more hosts are later added. Re-read the play header and official strategy guide, then repeat the static inspection.

Remove only role-owned remote state

Remote cleanup can damage a shared server if ownership is assumed from a path name, so validate the exact host and application name first. Run this only on the disposable host used for the exercise and preserve your evidence record before removal.

ansible-inventory -i inventory/dev.yml --host app01
ansible -i inventory/dev.yml app01 -b -m ansible.builtin.file \
  -a 'path=/etc/nginx/sites-enabled/learning-ansible state=absent'
ansible -i inventory/dev.yml app01 -b -m ansible.builtin.file \
  -a 'path=/etc/nginx/sites-available/learning-ansible state=absent'
ansible -i inventory/dev.yml app01 -b -m ansible.builtin.file \
  -a 'path=/var/www/learning-ansible state=absent'
ansible -i inventory/dev.yml app01 -b -m ansible.builtin.service \
  -a 'name=nginx state=reloaded'

Confirm the three role-owned paths are absent. Do not uninstall Nginx automatically: the package may have another owner, and this POC does not record package provenance strongly enough to prove safe removal.

Keep secrets out of inventory. Ansible Vault can encrypt variables and files in source control, but it protects data at rest only; decrypted data in use, logs, callbacks, process memory, and remote effects need separate controls such as no_log, least privilege, and an external secret manager.

Keep tool ownership clear. Terraform or OpenTofu can create VMs, networks, and managed services plus maintain infrastructure state; this Ansible example consumes a supplied address, configures an OS, writes files, and controls a service. A common boundary is provisioned host output -> reviewed inventory -> Ansible configuration, not a claim that either tool replaces the other everywhere.

Your final bounded-deployment receipt should contain target identity, host-key fingerprint, SSH user, sudo proof, syntax/check/diff/apply outcomes, serial: 1, nginx -t, service state, HTTP status and content digest, failure/recovery result if executed, cleanup result, environment, timestamp, and source/archive digest. It proves only recorded boundaries. It does not prove production availability, durability, security, capacity, rollback correctness, or multi-host health.

Course decision rules now form one system:

  • Desired state needs an authoritative source, a bounded target, a controller, and independent evidence.
  • Variables are a useful DI analogy; handlers are a useful IoC analogy; Ansible remains ordered orchestration plus declarative convergence.
  • Diagnose bottom-up: host identity and transport, privilege, predicted change, applied state, process validation, then HTTP response.
  • Treat unexecuted remote steps as design gaps, never observed outcomes.