Ansible for Server Configuration Management: Getting Started

I resisted Ansible for a while. My servers were small in number and I could remember what was on each one. Then I had to rebuild a server from scratch after a hardware failure, and I realized I had no idea exactly what I’d done to set it up. It took most of a day to get it back to where it was, mostly guessing based on what was running.

That was the moment I started using Ansible properly.

What Ansible Actually Is

Ansible is configuration management. You describe the desired state of your servers in YAML files (called playbooks), and Ansible makes it so. Run the same playbook twice and the second run changes nothing if the system is already in the right state. This idempotency is the key property that makes configuration management useful.

It connects over SSH. No agent to install on managed nodes, no daemon running on them. Just SSH and Python.

Installation

# On the control machine (your laptop or a management server)
pip install ansible
# or
apt install ansible

That’s it for the control side. Managed hosts just need SSH access and Python 3.

Inventory

Before running anything, tell Ansible what servers you have. The simplest inventory is a text file:

# inventory.ini

[webservers]

web1.example.com web2.example.com

[databases]

db1.example.com ansible_user=ubuntu ansible_ssh_private_key_file=~/.ssh/deploy_key

[staging]

staging.example.com

Test connectivity:

ansible all -i inventory.ini -m ping

This SSHs to every host and returns pong if it works.

Your First Playbook

A playbook that installs and configures Nginx:

# nginx.yml
---
- name: Configure web servers
  hosts: webservers
  become: true  # sudo

  tasks:
    - name: Install Nginx
      apt:
        name: nginx
        state: present
        update_cache: yes

    - name: Ensure Nginx is running and enabled
      service:
        name: nginx
        state: started
        enabled: yes

    - name: Copy Nginx config
      template:
        src: templates/nginx.conf.j2
        dest: /etc/nginx/nginx.conf
        owner: root
        group: root
        mode: '0644'
      notify: Reload Nginx

  handlers:
    - name: Reload Nginx
      service:
        name: nginx
        state: reloaded

Run it:

ansible-playbook -i inventory.ini nginx.yml

become: true — run tasks with sudo.
notify: Reload Nginx — triggers the handler when the config template changes.
Handlers run at the end of a play and only when notified. This means Nginx reloads only when the config actually changes, not on every run.

Variables

Hard-coding values in playbooks gets messy. Use variables:

# group_vars/webservers.yml
nginx_worker_processes: auto
nginx_worker_connections: 1024
nginx_server_name: example.com

Reference in playbooks or templates:

- name: Set worker processes
  lineinfile:
    path: /etc/nginx/nginx.conf
    regexp: '^worker_processes'
    line: "worker_processes {{ nginx_worker_processes }};"

Variables can live in:

  • group_vars/groupname.yml — applies to all hosts in a group
  • host_vars/hostname.yml — host-specific variables
  • vars: block in a playbook
  • --extra-vars on the command line

Templates

Templates are Jinja2 files. Create templates/nginx.conf.j2:

worker_processes {{ nginx_worker_processes }};

events {
    worker_connections {{ nginx_worker_connections }};
}

http {
    server {
        server_name {{ nginx_server_name }};
        # ...
    }
}

Ansible renders the template with variables and deploys it. Any time a variable changes, the template is regenerated and deployed.

Roles: Organizing Complex Playbooks

For anything beyond simple scripts, organize with roles. A role is a structured directory:

roles/
└── nginx/
    ├── tasks/
    │   └── main.yml       # Task list
    ├── handlers/
    │   └── main.yml       # Handlers
    ├── templates/
    │   └── nginx.conf.j2  # Templates
    ├── vars/
    │   └── main.yml       # Role variables
    └── defaults/
        └── main.yml       # Default variable values

Use roles in a playbook:

- name: Configure web servers
  hosts: webservers
  become: true
  roles:
    - nginx
    - php-fpm
    - certbot

Roles are reusable, composable, and shareable. Ansible Galaxy (galaxy.ansible.com) has thousands of community roles — I use them as starting points and customize for my setup.

Common Modules

The modules I use most:

# Package management
- apt:
    name: "{{ packages }}"
    state: present
  vars:
    packages:
      - nginx
      - php8.2-fpm
      - postgresql

# File operations
- file:
    path: /var/log/myapp
    state: directory
    owner: www-data
    mode: '0755'

- copy:
    src: files/myapp.conf
    dest: /etc/myapp.conf

- template:
    src: templates/myapp.conf.j2
    dest: /etc/myapp.conf

# Service management
- service:
    name: nginx
    state: started
    enabled: yes

# Run commands (when no module exists)
- command: /usr/local/bin/init-db.sh
  args:
    creates: /var/lib/myapp/.initialized  # Only run if this file doesn't exist

# User management
- user:
    name: deploy
    groups: www-data
    shell: /bin/bash
    create_home: yes

# Add to authorized_keys
- authorized_key:
    user: deploy
    key: "{{ lookup('file', '~/.ssh/id_rsa.pub') }}"

Checking What Would Change (Dry Run)

ansible-playbook -i inventory.ini nginx.yml --check

--check mode shows what would change without making changes. Like rsync --dry-run. I run this before any significant playbook run on production.

Add --diff to see the actual content changes for files:

ansible-playbook -i inventory.ini nginx.yml --check --diff

Targeting Specific Hosts or Groups

# Run only on one host
ansible-playbook -i inventory.ini nginx.yml --limit web1.example.com

# Run only on staging
ansible-playbook -i inventory.ini nginx.yml --limit staging

# Run a specific tag
ansible-playbook -i inventory.ini nginx.yml --tags "config"

Tags let you skip or run specific tasks:

- name: Copy Nginx config
  template:
    src: nginx.conf.j2
    dest: /etc/nginx/nginx.conf
  tags: config

A Realistic Server Setup Playbook

Here’s approximately what I run on new servers:

---
- name: Base server setup
  hosts: all
  become: true

  vars:
    deploy_user: deploy
    ssh_port: 22

  tasks:
    - name: Update apt cache
      apt:
        update_cache: yes
        cache_valid_time: 3600

    - name: Upgrade packages
      apt:
        upgrade: safe

    - name: Install base packages
      apt:
        name:
          - vim
          - git
          - curl
          - ufw
          - fail2ban
          - unattended-upgrades
        state: present

    - name: Create deploy user
      user:
        name: "{{ deploy_user }}"
        groups: sudo
        shell: /bin/bash

    - name: Set up SSH keys for deploy user
      authorized_key:
        user: "{{ deploy_user }}"
        key: "{{ lookup('file', '~/.ssh/id_rsa.pub') }}"

    - name: Configure UFW defaults
      ufw:
        default: deny
        direction: incoming

    - name: Allow SSH
      ufw:
        rule: allow
        port: "{{ ssh_port }}"
        proto: tcp

    - name: Enable UFW
      ufw:
        state: enabled

Running this against a new server takes 2–3 minutes and gives me a consistent baseline. Then I layer on application-specific roles.

Vault: Storing Secrets

Never put passwords or API keys in plaintext in playbooks. Use Ansible Vault:

# Encrypt a file
ansible-vault encrypt group_vars/all/secrets.yml

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

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

Run playbook with vault password:

ansible-playbook --ask-vault-pass playbook.yml
# or
ansible-playbook --vault-password-file ~/.vault_pass playbook.yml

The encrypted file is safe to commit to git — the vault password is not.

Ansible has become a core part of how I manage servers. The investment in writing playbooks pays off every time you need to rebuild a server, provision a new one, or audit what’s actually running where.

Scroll to Top