Ansible Deep Dive Part 9 Lab: Zero to Production — a Three-Tier Web App From Bare Metal

Eight parts of individual concepts. Time to put them all to work at once. This lab takes five bare Ubuntu 22.04 hosts — a load balancer, two application servers, and a PostgreSQL database — and turns them into a running three-tier stack with one command, using nothing that wasn’t covered in Parts 1 through 8.

The topology and inventory

# inventory/hosts.yml
all:
  children:
    lb:
      hosts:
        lb1.lab.local:
    appservers:
      hosts:
        app1.lab.local:
        app2.lab.local:
    dbservers:
      hosts:
        db1.lab.local:
    webstack:
      children:
        lb:
        appservers:
        dbservers:

webstack is a parent group (Part 2) so a single playbook run can target the whole stack, while individual plays still scope down to lb, appservers, or dbservers where the configuration genuinely differs.

Project layout

webstack/
  inventory/hosts.yml
  group_vars/
    all/vars.yml
    all/vault.yml       # ansible-vault encrypted — DB password, TLS key
    dbservers.yml
  roles/
    common/
    postgresql/
    appserver/
    haproxy/
  site.yml
  requirements.yml
# requirements.yml
collections:
  - name: community.general
    version: ">=8.0.0"
  - name: community.postgresql
    version: ">=3.0.0"

group_vars: shared config and the vault-protected secrets

# group_vars/all/vars.yml
app_port: 8080
db_name: webapp
db_user: webapp_svc
db_password: "{{ vault_db_password }}"
ansible-vault create group_vars/all/vault.yml
# group_vars/all/vault.yml (plaintext shown, AES256-encrypted on disk)
vault_db_password: "Tr0ub4dor&3-generated-not-typed"

Exactly the pattern from Part 6 — the encrypted file holds only the raw secret under a vault_-prefixed name; everything else, including which variable actually gets used where, stays in plaintext and diffable.

The common role: baseline every host gets

# roles/common/tasks/main.yml
---
- name: Update apt cache
  ansible.builtin.apt:
    update_cache: true
    cache_valid_time: 3600

- name: Install baseline packages
  ansible.builtin.package:
    name: [curl, vim, ufw, unattended-upgrades]
    state: present

- name: Ensure UFW allows SSH
  community.general.ufw:
    rule: allow
    port: '22'
    proto: tcp

- name: Enable UFW
  community.general.ufw:
    state: enabled

The postgresql role: the database tier

# roles/postgresql/tasks/main.yml
---
- name: Install PostgreSQL
  ansible.builtin.package:
    name: [postgresql, python3-psycopg2]
    state: present

- name: Ensure PostgreSQL is running
  ansible.builtin.service:
    name: postgresql
    state: started
    enabled: true

- name: Create application database
  community.postgresql.postgresql_db:
    name: "{{ db_name }}"
  become_user: postgres

- name: Create application database user
  community.postgresql.postgresql_user:
    db: "{{ db_name }}"
    name: "{{ db_user }}"
    password: "{{ db_password }}"
    priv: ALL
  become_user: postgres

- name: Allow app servers to connect
  community.postgresql.postgresql_pg_hba:
    dest: /etc/postgresql/14/main/pg_hba.conf
    contype: host
    users: "{{ db_user }}"
    databases: "{{ db_name }}"
    source: "{{ item }}"
    method: scram-sha-256
  loop: "{{ groups['appservers'] | map('extract', hostvars, 'ansible_host') | list }}"
  notify: Reload postgresql

# roles/postgresql/handlers/main.yml
- name: Reload postgresql
  ansible.builtin.service:
    name: postgresql
    state: reloaded

Note the pg_hba.conf task looping over groups['appservers'] (Part 4’s map/extract filter combination) rather than a hardcoded IP list — add a third app server to inventory and this task grants it database access automatically on the next run, with zero edits to this role.

The appserver role: templated config, notify chain

# roles/appserver/templates/app.env.j2
DATABASE_URL=postgresql://{{ db_user }}:{{ db_password }}@{{ hostvars[groups['dbservers'][0]]['ansible_host'] }}:5432/{{ db_name }}
APP_PORT={{ app_port }}
# roles/appserver/tasks/main.yml
---
- name: Deploy application code
  ansible.builtin.git:
    repo: "{{ app_repo }}"
    dest: /opt/webapp
    version: "{{ app_version }}"
  notify: Restart webapp

- name: Deploy environment configuration
  ansible.builtin.template:
    src: app.env.j2
    dest: /opt/webapp/.env
    mode: '0600'
  notify: Restart webapp

- name: Install systemd unit
  ansible.builtin.template:
    src: webapp.service.j2
    dest: /etc/systemd/system/webapp.service
  notify: [Reload systemd, Restart webapp]

- name: Ensure webapp is running
  ansible.builtin.service:
    name: webapp
    state: started
    enabled: true

# roles/appserver/handlers/main.yml
- name: Reload systemd
  ansible.builtin.systemd:
    daemon_reload: true

- name: Restart webapp
  ansible.builtin.service:
    name: webapp
    state: restarted

hostvars[groups['dbservers'][0]]['ansible_host'] reaches across the inventory from an app server’s own template render to pull the database host’s address — cross-host variable access is one of the more powerful, and more commonly under-used, features once you’re generating config that needs to reference other machines in the same run.

The haproxy role: load balancer, built from the app tier’s own inventory

# roles/haproxy/templates/haproxy.cfg.j2
frontend webapp_front
    bind *:80
    default_backend webapp_back

backend webapp_back
    balance roundrobin
    {% for host in groups['appservers'] %}
    server {{ host }} {{ hostvars[host]['ansible_host'] }}:{{ app_port }} check
    {% endfor %}
# roles/haproxy/tasks/main.yml
---
- name: Install HAProxy
  ansible.builtin.package:
    name: haproxy
    state: present

- name: Deploy HAProxy configuration
  ansible.builtin.template:
    src: haproxy.cfg.j2
    dest: /etc/haproxy/haproxy.cfg
  notify: Restart haproxy

- name: Ensure HAProxy is running
  ansible.builtin.service:
    name: haproxy
    state: started
    enabled: true

# roles/haproxy/handlers/main.yml
- name: Restart haproxy
  ansible.builtin.service:
    name: haproxy
    state: restarted

The {% for host in groups['appservers'] %} loop means the load balancer’s backend pool is generated directly from inventory — add app3.lab.local to the appservers group and re-run, and HAProxy picks it up with no manual config edit, same principle as the pg_hba.conf loop above.

The top-level playbook, tying every role to its tier

# site.yml
---
- name: Baseline every host
  hosts: webstack
  become: true
  roles: [common]

- name: Configure database
  hosts: dbservers
  become: true
  roles: [postgresql]

- name: Configure application servers
  hosts: appservers
  become: true
  roles: [appserver]
  serial: 1

- name: Configure load balancer
  hosts: lb
  become: true
  roles: [haproxy]

serial: 1 on the app server play is the rolling-deploy piece — instead of restarting both app servers simultaneously (a brief but real outage), Ansible completes the entire play against app1 before starting on app2. Against a two-node pool that’s the difference between zero downtime and a short blip; at larger scale, serial: "25%" gives you a rolling batch size instead of one host at a time.

Running it

ansible-galaxy install -r requirements.yml
ansible-playbook -i inventory/hosts.yml site.yml --check --diff   # dry run first
ansible-playbook -i inventory/hosts.yml site.yml --ask-vault-pass

That’s five hosts across three tiers, a vault-protected database credential, cross-host templating, a notify chain that only restarts what actually changed, and a rolling application deploy — all built from the exact same primitives covered individually across Parts 1 through 8. Nothing here is a new concept; it’s the previous eight posts applied at once, which is really the point of the lab.

Part 10 takes the same discipline into territory Ansible was arguably built for in the first place: a mixed fleet of Cisco IOS routers and FortiGate firewalls, configured with network-specific modules rather than package/service/template.