Operations 24 min read

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.

Raymond Ops
Raymond Ops
Raymond Ops
Master Ansible: Deploy 50 Nginx Servers in 10 Minutes with Real‑World Playbooks

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=ssh

2. 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 --version

3. 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.yml

Playbook (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: restarted

Template: 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 -vvv

Advanced 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: localhost

Protect 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-pass

Dynamic 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-%%r

CI/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: reloaded

Monitoring & 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: restarted

Rollback 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: localhost

Conclusion

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.

DevOpsAnsibleWeb Server Deployment
Raymond Ops
Written by

Raymond Ops

Linux ops automation, cloud-native, Kubernetes, SRE, DevOps, Python, Golang and related tech discussions.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.