Ansible Playbook for a Web Server
The third time I set up the same nginx server by hand, I noticed that each one was slightly different: a directive here, a firewall rule there. Ansible fixes that by describing the desired end state in YAML and applying it over SSH, with nothing to install on the managed hosts beyond Python. The same playbook that builds server one builds server ten identically, and running it again against an existing host changes only what has drifted.
This playbook takes a fresh Ubuntu 20.04 host and turns it into an nginx web server with a firewall, a templated site config and a deploy user. It's written for Ansible 2.9, which is what Ubuntu 20.04 packages, so nothing here needs the newer collections layout. I install it from the distribution (sudo apt install ansible) or with pip3 install --user 'ansible<2.10'. Targets need SSH and a user that can sudo; Python 3 is already on 20.04 and that's all Ansible needs.
Project layout
mkdir -p ~/ansible/webserver/{templates,files,group_vars}
cd ~/ansible/webserver
An inventory file listing the hosts, inventory.ini:
[web]
web01 ansible_host=203.0.113.71
web02 ansible_host=203.0.113.72
[web:vars]
ansible_user=deploy
ansible_become=true
ansible_python_interpreter=/usr/bin/python3
And ansible.cfg in the same directory so you do not have to pass the inventory every time:
[defaults]
inventory = inventory.ini
host_key_checking = False
retry_files_enabled = False
Confirm connectivity:
ansible web -m ping
Each host should answer "ping": "pong".
Variables
group_vars/web.yml holds the values the playbook and templates use, so the same play can serve different sites:
site_domain: example.com
site_root: /var/www/example.com/public_html
deploy_user: deploy
nginx_worker_connections: 1024
The nginx template
Templates use Jinja2. templates/site.conf.j2:
server {
listen 80;
listen [::]:80;
server_name {{ site_domain }} www.{{ site_domain }};
root {{ site_root }};
index index.html;
access_log /var/log/nginx/{{ site_domain }}-access.log;
error_log /var/log/nginx/{{ site_domain }}-error.log;
location / {
try_files $uri $uri/ =404;
}
}
The playbook
site.yml:
---
- name: Configure nginx web servers
hosts: web
become: true
handlers:
- name: reload nginx
service:
name: nginx
state: reloaded
tasks:
- name: Install packages
apt:
name:
- nginx
- ufw
- unattended-upgrades
state: present
update_cache: true
cache_valid_time: 3600
- name: Ensure deploy user exists
user:
name: "{{ deploy_user }}"
groups: sudo
append: true
shell: /bin/bash
- name: Create web root
file:
path: "{{ site_root }}"
state: directory
owner: "{{ deploy_user }}"
group: www-data
mode: "0755"
- name: Deploy site configuration
template:
src: site.conf.j2
dest: "/etc/nginx/sites-available/{{ site_domain }}"
owner: root
group: root
mode: "0644"
validate: "nginx -t -c /etc/nginx/nginx.conf"
notify: reload nginx
- name: Enable site
file:
src: "/etc/nginx/sites-available/{{ site_domain }}"
dest: "/etc/nginx/sites-enabled/{{ site_domain }}"
state: link
notify: reload nginx
- name: Disable default site
file:
path: /etc/nginx/sites-enabled/default
state: absent
notify: reload nginx
- name: Allow SSH and HTTP through ufw
ufw:
rule: allow
name: "{{ item }}"
loop:
- OpenSSH
- "Nginx Full"
- name: Enable ufw
ufw:
state: enabled
policy: deny
- name: Ensure nginx is running and enabled
service:
name: nginx
state: started
enabled: true
Two things I like about this. The validate option on template runs nginx -t against the rendered file before it replaces the old one, so a broken template never reaches a live server. Handlers run once at the end of the play regardless of how many tasks notified them, which is why the reload is a handler rather than a task.
Dry run first, always
I never point a new playbook at a live host without --check --diff.
ansible-playbook site.yml --syntax-check
ansible-playbook site.yml --check --diff
ansible-playbook site.yml
--check is a dry run, and --diff shows the changes it would make to files. The real run prints a recap; on the first run every task should be changed, on the second run everything should be ok and changed=0.
Idempotence check
ansible web -m shell -a "systemctl is-active nginx && ufw status | head -1"
curl -H 'Host: example.com' http://203.0.113.71/
Run the playbook a third time to confirm idempotence: the recap must show changed=0 for every host. Then break something on purpose, for example sudo rm /etc/nginx/sites-enabled/example.com on web01, rerun, and watch Ansible put it back and reload nginx.
Errors I've seen
Failed to connect to the host via ssh: Permission denied (publickey). Ansible is using the wrong user or key. Setansible_userin the inventory and, if needed,ansible_ssh_private_key_file.ansible web -m ping -vvvshows the exact ssh command.Missing sudo password. The deploy user needs a password for sudo. Pass-K(--ask-become-pass) or configureNOPASSWDfor that user in/etc/sudoers.d/./usr/bin/python: not found. Ansible defaulted to Python 2 on a host that only has Python 3. Theansible_python_interpreterline in the inventory prevents it.failed to validate: rc:1 error: nginx: [emerg] .... The template rendered an invalid config andvalidatecaught it before deployment, which is the point. Fix the template and rerun.
Roles and ansible-vault are the next things I'd add. A flat playbook is fine for now, since a replacement web server is one command away.