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.