Master Ansible: Deploy 50 Nginx Servers in 10 Minutes with Real‑World Playbooks
This step‑by‑step guide shows how to set up Ansible, configure password‑less SSH, create inventory and playbooks, and automate the bulk deployment, configuration, rolling updates, and monitoring of Nginx web servers across dozens of machines while covering advanced techniques such as Vault, dynamic inventory, CI/CD integration, and rollback strategies.
Why Every Ops Engineer Should Know Ansible
Manual SSH updates on dozens of servers are error‑prone and time‑consuming; Ansible provides an agentless, declarative, idempotent way to manage configurations and applications at scale.
Traditional Ops Pain Points
Configuration drift across hundreds of machines.
Scaling deployments from 10 to 100+ servers dramatically increases time and error rates.
Knowledge transfer is difficult when senior staff leave, leaving only scattered shell scripts.
Ansible Advantages
Agentless : Uses SSH only.
Declarative : Describe the desired state.
Idempotent : Re‑running yields the same result.
Easy to learn : Simple YAML syntax.
Rich module library : Over 3000 built‑in modules.
Quick Start: Build an Ansible Environment (≈15 minutes)
1. Prepare the lab
Create one control node and three managed nodes (e.g., 192.168.1.10‑13).
# inventory.ini (example)
[webservers]
web-01 ansible_host=192.168.1.11
web-02 ansible_host=192.168.1.12
web-03 ansible_host=192.168.1.13
[webservers:vars]
ansible_user=root
ansible_python_interpreter=/usr/bin/python3
[all:vars]
ansible_connection=ssh2. Install Ansible
# CentOS/RHEL
sudo yum install -y epel-release
sudo yum install -y ansible
# Ubuntu/Debian
sudo apt update
sudo apt install -y ansible
# Or via pip (recommended for latest version)
sudo pip3 install ansible
# Verify
ansible --version3. Set up password‑less SSH
# Generate key (if needed)
ssh-keygen -t rsa -b 2048
# Copy to each managed host
for ip in 192.168.1.11 192.168.1.12 192.168.1.13; do
ssh-copy-id -i ~/.ssh/id_rsa.pub root@$ip
done
# Test
ssh [email protected] 'hostname'Real‑World Project: Bulk Deploy Nginx
Project Layout
nginx-deployment/
├── inventory.ini # hosts list
├── ansible.cfg # optional config
├── site.yml # main playbook
├── roles/
│ └── nginx/
│ ├── tasks/main.yml
│ ├── templates/nginx.conf.j2
│ ├── templates/index.html.j2
│ ├── handlers/main.yml
│ └── vars/main.yml
└── group_vars/webservers.ymlPlaybook (site.yml)
- name: Deploy Nginx Web Servers
hosts: webservers
become: yes
gather_facts: yes
vars:
nginx_port: 80
nginx_worker_processes: "{{ ansible_processor_vcpus }}"
nginx_worker_connections: 1024
website_title: "Ansible Automated Deployment Demo"
tasks:
- name: Update package cache (Debian)
apt:
update_cache: yes
when: ansible_os_family == "Debian"
- name: Install Nginx
package:
name: nginx
state: present
- name: Create website directory
file:
path: /var/www/html
state: directory
mode: '0755'
- name: Deploy Nginx config
template:
src: nginx.conf.j2
dest: /etc/nginx/nginx.conf
backup: yes
notify: restart nginx
- name: Deploy website index
template:
src: index.html.j2
dest: /var/www/html/index.html
mode: '0644'
- name: Ensure Nginx is running
service:
name: nginx
state: started
enabled: yes
- name: Wait for port to be ready
wait_for:
port: "{{ nginx_port }}"
host: "{{ ansible_default_ipv4.address }}"
delay: 5
timeout: 30
handlers:
- name: restart nginx
service:
name: nginx
state: restartedTemplate: nginx.conf.j2 (excerpt)
user www-data;
worker_processes {{ nginx_worker_processes }};
pid /run/nginx.pid;
events {
worker_connections {{ nginx_worker_connections }};
multi_accept on;
use epoll;
}
http {
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
access_log /var/log/nginx/access.log;
error_log /var/log/nginx/error.log;
gzip on;
server {
listen {{ nginx_port }} default_server;
root /var/www/html;
index index.html index.htm;
server_name {{ ansible_hostname }}.example.com;
location / {
try_files $uri $uri/ =404;
}
location /health {
access_log off;
return 200 "healthy
";
add_header Content-Type text/plain;
}
}
}Deploy Commands
# Syntax check
ansible-playbook -i inventory.ini site.yml --syntax-check
# Dry run
ansible-playbook -i inventory.ini site.yml --check
# Real deployment
ansible-playbook -i inventory.ini site.yml
# Verbose output
ansible-playbook -i inventory.ini site.yml -vvvAdvanced Techniques
Rolling Update Strategy
- name: Rolling update Web servers
hosts: webservers
become: yes
serial: 1 # update one host at a time
max_fail_percentage: 30
pre_tasks:
- name: Remove from load balancer
uri:
url: "http://lb.example.com/api/remove"
method: POST
body_format: json
body:
server: "{{ ansible_hostname }}"
delegate_to: localhost
tasks:
- name: Update application code
git:
repo: https://github.com/yourapp/webapp.git
dest: /var/www/html
version: "{{ app_version | default('master') }}"
- name: Restart service
service:
name: nginx
state: restarted
post_tasks:
- name: Health check
uri:
url: "http://{{ ansible_default_ipv4.address }}/health"
status_code: 200
retries: 5
delay: 10
- name: Re‑add to load balancer
uri:
url: "http://lb.example.com/api/add"
method: POST
body_format: json
body:
server: "{{ ansible_hostname }}"
delegate_to: localhostProtect Secrets with Ansible Vault
# Create encrypted file
ansible-vault create secrets.yml
# Edit encrypted file
ansible-vault edit secrets.yml
# Example content of secrets.yml
db_password: "SuperSecret123!"
api_key: "sk-1234567890abcdef"
# Run playbook using vault password prompt
ansible-playbook -i inventory.ini site.yml --ask-vault-passDynamic Inventory (Python example)
#!/usr/bin/env python3
import json, boto3
def get_inventory():
ec2 = boto3.client('ec2', region_name='us-west-2')
response = ec2.describe_instances(Filters=[
{'Name': 'tag:Environment', 'Values': ['production']},
{'Name': 'instance-state-name', 'Values': ['running']}
])
inventory = {'webservers': {'hosts': [], 'vars': {
'ansible_user': 'ubuntu',
'ansible_ssh_private_key_file': '~/.ssh/aws-key.pem'
}}}
for reservation in response['Reservations']:
for instance in reservation['Instances']:
inventory['webservers']['hosts'].append(instance['PublicIpAddress'])
return inventory
if __name__ == '__main__':
print(json.dumps(get_inventory()))Performance Tuning (ansible.cfg)
[defaults]
host_key_checking = False
gathering = smart
fact_caching = jsonfile
fact_caching_connection = /tmp/ansible_cache
fact_caching_timeout = 86400
pipelining = True
forks = 50
[ssh_connection]
ssh_args = -o ControlMaster=auto -o ControlPersist=60s
control_path = /tmp/ansible-%%h-%%p-%%rCI/CD Pipeline Example (deploy_pipeline.yml)
- name: Full deployment pipeline
hosts: webservers
become: yes
vars:
app_name: mywebapp
app_version: "{{ lookup('env','BUILD_NUMBER') | default('latest') }}"
deploy_user: webapp
deploy_dir: /opt/{{ app_name }}
backup_dir: /opt/backups/{{ app_name }}
tasks:
- name: Create deployment user
user:
name: "{{ deploy_user }}"
shell: /bin/bash
groups: www-data
append: yes
- name: Create required directories
file:
path: "{{ item }}"
state: directory
owner: "{{ deploy_user }}"
group: "{{ deploy_user }}"
mode: '0755'
loop:
- "{{ deploy_dir }}"
- "{{ backup_dir }}"
- /var/log/{{ app_name }}
- name: Backup current version
archive:
path: "{{ deploy_dir }}"
dest: "{{ backup_dir }}/backup-{{ ansible_date_time.epoch }}.tar.gz"
when: deploy_dir is directory
- name: Pull latest code
git:
repo: "https://github.com/company/{{ app_name }}.git"
dest: "{{ deploy_dir }}"
version: "{{ app_version }}"
force: yes
become_user: "{{ deploy_user }}"
- name: Install dependencies
pip:
requirements: "{{ deploy_dir }}/requirements.txt"
virtualenv: "{{ deploy_dir }}/venv"
virtualenv_python: python3
become_user: "{{ deploy_user }}"
- name: Run DB migrations
command: "{{ deploy_dir }}/venv/bin/python manage.py migrate"
args:
chdir: "{{ deploy_dir }}"
become_user: "{{ deploy_user }}"
run_once: true
- name: Collect static files
command: "{{ deploy_dir }}/venv/bin/python manage.py collectstatic --noinput"
args:
chdir: "{{ deploy_dir }}"
become_user: "{{ deploy_user }}"
- name: Deploy Systemd service
template:
src: app.service.j2
dest: /etc/systemd/system/{{ app_name }}.service
notify:
- reload systemd
- restart app
- name: Deploy Nginx reverse‑proxy config
template:
src: nginx_app.conf.j2
dest: /etc/nginx/sites-available/{{ app_name }}
notify: reload nginx
- name: Enable site
file:
src: /etc/nginx/sites-available/{{ app_name }}
dest: /etc/nginx/sites-enabled/{{ app_name }}
state: link
notify: reload nginx
- name: Smoke test
uri:
url: "http://localhost/api/health"
status_code: 200
retries: 5
delay: 10
handlers:
- name: reload systemd
systemd:
daemon_reload: yes
- name: restart app
systemd:
name: "{{ app_name }}"
state: restarted
enabled: yes
- name: reload nginx
service:
name: nginx
state: reloadedMonitoring & Logging (monitoring.yml)
- name: Configure monitoring and log collection
hosts: webservers
become: yes
tasks:
- name: Install monitoring agents
package:
name:
- prometheus-node-exporter
- filebeat
state: present
- name: Configure Prometheus Node Exporter
lineinfile:
path: /etc/default/prometheus-node-exporter
regexp: '^ARGS='
line: 'ARGS="--collector.filesystem.ignored-mount-points=^/(sys|proc|dev|run)($|/)"'
notify: restart node-exporter
- name: Deploy Filebeat config
template:
src: filebeat.yml.j2
dest: /etc/filebeat/filebeat.yml
mode: '0600'
notify: restart filebeat
- name: Deploy custom metric script
copy:
content: |
#!/bin/bash
echo "app_requests_total $(curl -s localhost/metrics | grep requests_total | awk '{print $2}')"
echo "app_errors_total $(grep ERROR /var/log/{{ app_name }}/app.log | wc -l)"
echo "app_response_time_seconds $(tail -n 100 /var/log/nginx/access.log | awk '{sum+=$10} END {print sum/NR}')"
dest: /usr/local/bin/collect_metrics.sh
mode: '0755'
- name: Schedule metric collection every 5 minutes
cron:
name: "Collect app metrics"
minute: "*/5"
job: "/usr/local/bin/collect_metrics.sh > /var/lib/node_exporter/textfile_collector/app_metrics.prom"
handlers:
- name: restart node-exporter
service:
name: prometheus-node-exporter
state: restarted
- name: restart filebeat
service:
name: filebeat
state: restartedRollback Procedure (rollback.yml)
- name: Emergency rollback
hosts: webservers
become: yes
serial: 1
vars_prompt:
- name: confirm_rollback
prompt: "Confirm rollback to previous version? (yes/no)"
private: no
tasks:
- name: Abort if not confirmed
fail:
msg: "Rollback cancelled"
when: confirm_rollback != "yes"
- name: Find latest backup
find:
paths: "{{ backup_dir }}"
patterns: "backup-*.tar.gz"
register: backup_files
- name: Ensure a backup exists
fail:
msg: "No backup files found"
when: backup_files.files | length == 0
- name: Set latest backup path
set_fact:
latest_backup: "{{ (backup_files.files | sort(attribute='mtime') | last).path }}"
- name: Stop application service
systemd:
name: "{{ app_name }}"
state: stopped
- name: Remove current deployment directory
file:
path: "{{ deploy_dir }}"
state: absent
- name: Restore backup
unarchive:
src: "{{ latest_backup }}"
dest: /opt/
remote_src: yes
- name: Start application service
systemd:
name: "{{ app_name }}"
state: started
- name: Verify service health
uri:
url: "http://localhost/api/health"
status_code: 200
retries: 3
delay: 5
- name: Send rollback notification
mail:
to: [email protected]
subject: "Emergency rollback completed - {{ ansible_hostname }}"
body: "Server {{ ansible_hostname }} rolled back to {{ latest_backup }}"
delegate_to: localhostConclusion
By following this guide you have transformed a manual, error‑prone deployment process into a fast, repeatable, and observable automation workflow. You now benefit from speed, consistency, traceability, knowledge‑capture, and reduced risk, while having a foundation to explore Ansible Tower/AWX, Galaxy roles, Kubernetes integration, and broader infrastructure automation.
Raymond Ops
Linux ops automation, cloud-native, Kubernetes, SRE, DevOps, Python, Golang and related tech discussions.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
