DEV Community

Cover image for Ansible for DevOps: Automate Server Configuration in 30 Minutes (Not 30 Days)
S, Sanjay
S, Sanjay

Posted on

Ansible for DevOps: Automate Server Configuration in 30 Minutes (Not 30 Days)

You have 15 servers. Each one needs the same packages, the same users, the same firewall rules, the same monitoring agent, and the same application configuration.

You can SSH into each one and run the same commands 15 times. Or you can write an Ansible playbook once and apply it to all 15 in parallel.

That's Ansible in one sentence: define what your servers should look like, and Ansible makes them look like that.


Why Ansible Over Shell Scripts

Shell scripts work. Until they don't.

# This shell script installs nginx... maybe
apt-get update
apt-get install -y nginx
systemctl start nginx
systemctl enable nginx
Enter fullscreen mode Exit fullscreen mode

Problems:

  1. Not idempotent. Run it twice and apt-get install shows warnings. Run it after a partial failure and you might be in an unknown state.
  2. No error handling. If apt-get update fails, the script continues and tries to install from stale package lists.
  3. OS-specific. This script only works on Debian/Ubuntu. CentOS uses yum. Alpine uses apk.
  4. No inventory. Which servers to run this on? Hard-coded IPs? SSH in a loop?

Ansible solves all four:

# This Ansible task installs nginx — correctly, every time
- name: Install and start nginx
  hosts: webservers
  become: true
  tasks:
    - name: Install nginx
      ansible.builtin.package:    # Works on apt, yum, apk, etc.
        name: nginx
        state: present

    - name: Start and enable nginx
      ansible.builtin.service:
        name: nginx
        state: started
        enabled: true
Enter fullscreen mode Exit fullscreen mode
  • Idempotent: Run it 100 times — if nginx is already installed and running, Ansible reports "OK" and changes nothing.
  • Cross-platform: ansible.builtin.package detects the OS and uses the right package manager.
  • Inventory-driven: hosts: webservers pulls from your inventory file — no hard-coded IPs.

Getting Started: 5 Minutes

Install Ansible (on your control machine — not the targets)

# macOS
brew install ansible

# Ubuntu/Debian
sudo apt-get install ansible

# pip (any OS)
pip install ansible
Enter fullscreen mode Exit fullscreen mode

Create an inventory file

# inventory.ini
[webservers]
web-1 ansible_host=10.0.1.10
web-2 ansible_host=10.0.1.11
web-3 ansible_host=10.0.1.12

[databases]
db-1 ansible_host=10.0.2.10
db-2 ansible_host=10.0.2.11

[all:vars]
ansible_user=deploy
ansible_ssh_private_key_file=~/.ssh/deploy_key
Enter fullscreen mode Exit fullscreen mode

Test connectivity

# Ping all hosts
ansible all -i inventory.ini -m ping

# Output:
# web-1 | SUCCESS => {"ping": "pong"}
# web-2 | SUCCESS => {"ping": "pong"}
# ...
Enter fullscreen mode Exit fullscreen mode

Run an ad-hoc command

# Check uptime on all webservers
ansible webservers -i inventory.ini -m command -a "uptime"

# Check disk space on databases
ansible databases -i inventory.ini -m command -a "df -h /"

# Install a package across all servers
ansible all -i inventory.ini -m package -a "name=htop state=present" --become
Enter fullscreen mode Exit fullscreen mode

Playbooks: Your Configuration as Code

A playbook is a YAML file describing the desired state of your servers.

Full server setup playbook:

# playbooks/setup-server.yml
---
- name: Base Server Configuration
  hosts: all
  become: true
  vars:
    admin_users:
      - name: deploy
        ssh_key: "ssh-rsa AAAA..."
      - name: sanjay
        ssh_key: "ssh-rsa BBBB..."

    required_packages:
      - curl
      - wget
      - git
      - htop
      - jq
      - unzip
      - net-tools
      - vim

  tasks:
    # System updates
    - name: Update apt cache
      ansible.builtin.apt:
        update_cache: true
        cache_valid_time: 3600    # Don't update if cached within 1 hour
      when: ansible_os_family == "Debian"

    - name: Install required packages
      ansible.builtin.package:
        name: "{{ required_packages }}"
        state: present

    # User management
    - name: Create admin users
      ansible.builtin.user:
        name: "{{ item.name }}"
        groups: sudo
        shell: /bin/bash
        create_home: true
      loop: "{{ admin_users }}"

    - name: Add SSH keys for admin users
      ansible.posix.authorized_key:
        user: "{{ item.name }}"
        key: "{{ item.ssh_key }}"
        state: present
      loop: "{{ admin_users }}"

    # Security hardening
    - name: Disable root SSH login
      ansible.builtin.lineinfile:
        path: /etc/ssh/sshd_config
        regexp: '^PermitRootLogin'
        line: 'PermitRootLogin no'
      notify: Restart SSH

    - name: Disable password authentication
      ansible.builtin.lineinfile:
        path: /etc/ssh/sshd_config
        regexp: '^PasswordAuthentication'
        line: 'PasswordAuthentication no'
      notify: Restart SSH

    # Firewall
    - name: Install UFW
      ansible.builtin.apt:
        name: ufw
        state: present
      when: ansible_os_family == "Debian"

    - name: Allow SSH
      community.general.ufw:
        rule: allow
        port: "22"
        proto: tcp

    - name: Allow HTTP/HTTPS
      community.general.ufw:
        rule: allow
        port: "{{ item }}"
        proto: tcp
      loop: ["80", "443"]
      when: "'webservers' in group_names"

    - name: Enable UFW with default deny
      community.general.ufw:
        state: enabled
        default: deny
        direction: incoming

    # Time synchronization
    - name: Install chrony for NTP
      ansible.builtin.package:
        name: chrony
        state: present

    - name: Enable chrony
      ansible.builtin.service:
        name: chronyd
        state: started
        enabled: true

  handlers:
    - name: Restart SSH
      ansible.builtin.service:
        name: sshd
        state: restarted
Enter fullscreen mode Exit fullscreen mode

Run it:

# Dry run (check mode) — shows what WOULD change
ansible-playbook -i inventory.ini playbooks/setup-server.yml --check --diff

# Apply
ansible-playbook -i inventory.ini playbooks/setup-server.yml

# Apply to specific hosts only
ansible-playbook -i inventory.ini playbooks/setup-server.yml --limit web-1,web-2
Enter fullscreen mode Exit fullscreen mode

Roles: Reusable Modules

When your playbook grows beyond 100 lines, break it into roles. A role is a self-contained unit of configuration.

roles/
├── common/                  # Base server config (every server)
│   ├── tasks/main.yml
│   ├── handlers/main.yml
│   ├── templates/
│   ├── files/
│   └── defaults/main.yml   # Default variables (overridable)
├── nginx/                   # Web server config
│   ├── tasks/main.yml
│   ├── handlers/main.yml
│   ├── templates/
│   │   └── nginx.conf.j2
│   └── defaults/main.yml
├── postgresql/              # Database config
│   ├── tasks/main.yml
│   ├── handlers/main.yml
│   ├── templates/
│   │   └── postgresql.conf.j2
│   └── defaults/main.yml
└── monitoring/              # Node exporter + Promtail
    ├── tasks/main.yml
    └── defaults/main.yml
Enter fullscreen mode Exit fullscreen mode

Example role: nginx

# roles/nginx/defaults/main.yml
nginx_worker_processes: auto
nginx_worker_connections: 1024
nginx_server_name: "_"
nginx_root: /var/www/html
nginx_ssl_enabled: false
Enter fullscreen mode Exit fullscreen mode
# roles/nginx/tasks/main.yml
---
- name: Install nginx
  ansible.builtin.package:
    name: nginx
    state: present

- name: Deploy nginx configuration
  ansible.builtin.template:
    src: nginx.conf.j2
    dest: /etc/nginx/nginx.conf
    owner: root
    group: root
    mode: '0644'
    validate: nginx -t -c %s      # Validate before applying
  notify: Reload nginx

- name: Deploy site configuration
  ansible.builtin.template:
    src: site.conf.j2
    dest: /etc/nginx/sites-available/default
    owner: root
    group: root
    mode: '0644'
  notify: Reload nginx

- name: Start and enable nginx
  ansible.builtin.service:
    name: nginx
    state: started
    enabled: true
Enter fullscreen mode Exit fullscreen mode
# roles/nginx/templates/nginx.conf.j2
worker_processes {{ nginx_worker_processes }};

events {
    worker_connections {{ nginx_worker_connections }};
}

http {
    include       /etc/nginx/mime.types;
    default_type  application/octet-stream;

    log_format main '$remote_addr - $remote_user [$time_local] '
                    '"$request" $status $body_bytes_sent '
                    '"$http_referer" "$http_user_agent"';

    access_log /var/log/nginx/access.log main;
    sendfile on;
    keepalive_timeout 65;

    include /etc/nginx/sites-available/*;
}
Enter fullscreen mode Exit fullscreen mode
# roles/nginx/handlers/main.yml
---
- name: Reload nginx
  ansible.builtin.service:
    name: nginx
    state: reloaded
Enter fullscreen mode Exit fullscreen mode

Use roles in a playbook:

# playbooks/webservers.yml
---
- name: Configure Web Servers
  hosts: webservers
  become: true
  roles:
    - common
    - role: nginx
      vars:
        nginx_worker_connections: 4096
        nginx_ssl_enabled: true
    - monitoring
Enter fullscreen mode Exit fullscreen mode

Ansible Vault: Managing Secrets

Never put passwords or API keys in plain text YAML:

# Create an encrypted variables file
ansible-vault create group_vars/all/vault.yml

# Edit an existing encrypted file
ansible-vault edit group_vars/all/vault.yml

# Run a playbook with vault (prompts for password)
ansible-playbook -i inventory.ini playbooks/deploy.yml --ask-vault-pass

# Or use a password file (for CI/CD)
ansible-playbook -i inventory.ini playbooks/deploy.yml --vault-password-file ~/.vault_pass
Enter fullscreen mode Exit fullscreen mode
# group_vars/all/vault.yml (encrypted)
vault_db_password: "super-secret-password"
vault_api_key: "sk-1234567890"
vault_ssl_cert: |
  -----BEGIN CERTIFICATE-----
  ...
  -----END CERTIFICATE-----
Enter fullscreen mode Exit fullscreen mode
# Reference in playbooks (Ansible decrypts automatically)
- name: Configure database connection
  ansible.builtin.template:
    src: db-config.j2
    dest: /etc/app/database.yml
  vars:
    db_password: "{{ vault_db_password }}"
Enter fullscreen mode Exit fullscreen mode

Dynamic Inventory (Cloud Environments)

Hard-coded IPs don't work in cloud environments where VMs come and go. Use dynamic inventory to query your cloud provider:

# Azure dynamic inventory
pip install azure-mgmt-compute azure-identity

# inventory_azure.yml
plugin: azure.azcollection.azure_rm
auth_source: auto
include_vm_resource_groups:
  - rg-production
  - rg-staging
keyed_groups:
  - prefix: tag
    key: tags.role    # Group VMs by the 'role' tag
Enter fullscreen mode Exit fullscreen mode
# Now Ansible groups VMs by their Azure tags
ansible tag_webserver -i inventory_azure.yml -m ping
ansible tag_database -i inventory_azure.yml -m ping
Enter fullscreen mode Exit fullscreen mode

Key Takeaways

1. Start with ad-hoc commands, then graduate to playbooks, then roles. Don't over-engineer from day one.

2. Always use --check --diff first. See what would change before applying. This builds confidence and catches mistakes.

3. Keep playbooks idempotent. Every task should be safe to run multiple times. Use state: present instead of install commands.

4. Group variables by environment. group_vars/production/, group_vars/staging/ — same playbook, different configs per environment.

5. Version control everything. Playbooks, roles, inventory, vault files — all in Git. Your server configuration is code; treat it like code.


Ansible won't replace your cloud-native tools (Terraform for provisioning, Kubernetes for orchestration). But for the servers, VMs, and bare-metal machines that still exist in every organization, Ansible is the fastest path from "manually configured" to "fully automated."


What's your go-to configuration management tool? Ansible, Chef, Puppet, or something else? Share your preference in the comments.

Follow me for more DevOps automation content.

Top comments (0)