Automate Your Server Setup with Ansible: A Practical Getting Started Guide

Automate Your Server Setup with Ansible: A Practical Getting Started Guide


If you manage more than one server — or even just one that you rebuild occasionally — you’ve probably felt the pain of manually installing packages, editing config files, and repeating the same steps over and over. Ansible solves this by letting you describe your server’s desired state in simple YAML files and applying that configuration automatically, every time, consistently.

Unlike tools like Puppet or Chef, Ansible is agentless. It connects to your servers over SSH and runs commands remotely. There’s nothing to install on the target machines. That simplicity is what makes it one of the most popular automation tools in the DevOps world.

In this guide, you’ll go from zero Ansible knowledge to writing real playbooks that install packages, deploy apps, manage services, and configure multiple servers at once.

What Is Ansible?

Ansible is an open-source IT automation engine maintained by Red Hat. It handles three main categories of automation:

  • Provisioning — Setting up new servers with the right software and configuration
  • Configuration management — Keeping servers in a consistent, desired state
  • Application deployment — Pushing code and restarting services across your fleet

Everything in Ansible is defined in YAML, which reads almost like plain English. There are no proprietary scripting languages to learn.

Key Concepts

TermMeaning
Control nodeThe machine where Ansible is installed (your laptop, CI server, etc.)
Managed nodeA remote server Ansible connects to and manages
InventoryA file listing your managed nodes (hosts)
ModuleA unit of work Ansible can execute (e.g., apt, copy, service)
TaskA single call to an Ansible module
PlayA set of tasks applied to a group of hosts
PlaybookA YAML file containing one or more plays
RoleA reusable, organized collection of tasks, files, and templates

Installing Ansible

Ansible runs on Linux and macOS. On the control node (your local machine or a management server), install it with:

Ubuntu / Debian

sudo apt update
sudo apt install -y software-properties-common
sudo add-apt-repository --yes --update ppa:ansible/ansible
sudo apt install -y ansible

macOS (Homebrew)

brew install ansible

pip (any platform with Python)

pip install ansible

Verify the installation:

ansible --version

You should see output showing the installed version (e.g., ansible [core 2.16.x]), the config file path, and the Python version it’s using.

Note: You do not need to install anything on the remote servers. Ansible only requires Python (which is pre-installed on virtually all Linux distributions) and SSH access.

Setting Up Your Inventory

The inventory file tells Ansible which servers to manage. Create a file called inventory.ini:

[webservers]
web-01 ansible_host=203.0.113.10 ansible_user=deploy
web-02 ansible_host=203.0.113.11 ansible_user=deploy

[databases]
db-01 ansible_host=203.0.113.20 ansible_user=deploy

[all:vars]
ansible_python_interpreter=/usr/bin/python3

Here we’ve defined two groups — webservers (two servers) and databases (one server). The ansible_user tells Ansible which SSH user to connect as.

Test Connectivity

Ping all servers to verify Ansible can connect:

ansible all -i inventory.ini -m ping

If SSH key authentication is configured (and it should be — see our SSH key guide), you’ll get a SUCCESS response from each host:

web-01 | SUCCESS => {
    "changed": false,
    "ping": "pong"
}

You can also run ad-hoc commands against your inventory:

# Check uptime on all web servers
ansible webservers -i inventory.ini -a "uptime"

# Check disk space on all servers
ansible all -i inventory.ini -a "df -h"

# Install a package on database servers
ansible databases -i inventory.ini -m apt -a "name=htop state=present" --become

The --become flag tells Ansible to use sudo for commands that need root privileges.

Writing Your First Playbook

A playbook is a YAML file that describes the desired state of your servers. Create a file called setup-webserver.yml:

---
- name: Set up web servers
  hosts: webservers
  become: true

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

    - name: Install required packages
      apt:
        name:
          - nginx
          - curl
          - ufw
          - fail2ban
        state: present

    - name: Start and enable Nginx
      service:
        name: nginx
        state: started
        enabled: true

    - name: Allow HTTP through firewall
      ufw:
        rule: allow
        port: "80"
        proto: tcp

    - name: Allow HTTPS through firewall
      ufw:
        rule: allow
        port: "443"
        proto: tcp

    - name: Enable UFW
      ufw:
        state: enabled
        default: deny

Run the playbook:

ansible-playbook -i inventory.ini setup-webserver.yml

Ansible will connect to each server in the webservers group and execute every task in order. The output shows whether each task was ok (already in desired state), changed (a modification was made), or failed.

Idempotency — The Key Principle

Every Ansible module is designed to be idempotent: running the same playbook twice produces the same result. If Nginx is already installed and running, Ansible recognizes that and skips the task. This makes playbooks safe to run repeatedly without side effects.

Using Variables and Templates

Real-world configurations need dynamic values. Ansible supports variables at multiple levels.

Defining Variables in the Playbook

---
- name: Deploy application
  hosts: webservers
  become: true
  vars:
    app_port: 3000
    app_user: appuser
    domain_name: example.com

  tasks:
    - name: Create application user
      user:
        name: "{{ app_user }}"
        shell: /bin/bash
        create_home: true

    - name: Deploy Nginx config from template
      template:
        src: templates/nginx-site.conf.j2
        dest: "/etc/nginx/sites-available/{{ domain_name }}"
        owner: root
        group: root
        mode: "0644"
      notify: Reload Nginx

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

Jinja2 Templates

Create templates/nginx-site.conf.j2:

server {
    listen 80;
    server_name {{ domain_name }};

    location / {
        proxy_pass http://127.0.0.1:{{ app_port }};
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

Ansible replaces {{ domain_name }} and {{ app_port }} with the actual variable values when deploying the file. This is incredibly powerful for managing configurations across multiple environments (staging, production) with different values.

Handlers

Notice the notify: Reload Nginx directive. A handler is a special task that only runs when notified — meaning Nginx only gets reloaded if the config file actually changed. This prevents unnecessary service restarts.

Organizing with Roles

As your playbooks grow, Ansible roles let you organize tasks, templates, files, and variables into a reusable directory structure.

Create a role skeleton:

ansible-galaxy init roles/webserver

This generates:

roles/webserver/
├── defaults/
│   └── main.yml       # Default variables
├── files/              # Static files to copy
├── handlers/
│   └── main.yml       # Handlers
├── meta/
│   └── main.yml       # Role metadata
├── tasks/
│   └── main.yml       # Main task list
├── templates/          # Jinja2 templates
└── vars/
    └── main.yml       # Role variables

Move your tasks into roles/webserver/tasks/main.yml, templates into roles/webserver/templates/, and then use the role in your playbook:

---
- name: Configure web servers
  hosts: webservers
  become: true
  roles:
    - webserver

This keeps your project clean and makes roles shareable across different projects.

Real-World Example: Full Stack Deployment

Here’s a more complete playbook that sets up a Node.js application with Nginx as a reverse proxy and PostgreSQL as the database:

---
- name: Set up database server
  hosts: databases
  become: true
  tasks:
    - name: Install PostgreSQL
      apt:
        name:
          - postgresql
          - postgresql-contrib
          - python3-psycopg2
        state: present

    - name: Start PostgreSQL
      service:
        name: postgresql
        state: started
        enabled: true

    - name: Create application database
      become_user: postgres
      postgresql_db:
        name: myapp_production
        state: present

    - name: Create database user
      become_user: postgres
      postgresql_user:
        db: myapp_production
        name: myapp_user
        password: "{{ db_password }}"
        priv: ALL
        state: present

- name: Deploy application to web servers
  hosts: webservers
  become: true
  vars:
    node_version: "20"
    app_dir: /opt/myapp
  tasks:
    - name: Install Node.js repository
      shell: curl -fsSL https://deb.nodesource.com/setup_{{ node_version }}.x | bash -
      args:
        creates: /etc/apt/sources.list.d/nodesource.list

    - name: Install Node.js
      apt:
        name: nodejs
        state: present

    - name: Create app directory
      file:
        path: "{{ app_dir }}"
        state: directory
        owner: deploy
        group: deploy

    - name: Copy application files
      synchronize:
        src: ./app/
        dest: "{{ app_dir }}/"
        delete: true

    - name: Install npm dependencies
      npm:
        path: "{{ app_dir }}"
        production: true

    - name: Deploy systemd service
      template:
        src: templates/myapp.service.j2
        dest: /etc/systemd/system/myapp.service
      notify:
        - Reload systemd
        - Restart myapp

  handlers:
    - name: Reload systemd
      systemd:
        daemon_reload: true

    - name: Restart myapp
      service:
        name: myapp
        state: restarted

Run it with:

ansible-playbook -i inventory.ini deploy.yml -e "db_password=supersecretpass"

The -e flag passes extra variables at runtime — useful for secrets you don’t want to hardcode.

Ansible Vault: Managing Secrets

Never store passwords in plain text. Ansible Vault encrypts sensitive data:

# Create an encrypted variables file
ansible-vault create vars/secrets.yml

# Edit an encrypted file
ansible-vault edit vars/secrets.yml

# Run a playbook with vault-encrypted files
ansible-playbook -i inventory.ini deploy.yml --ask-vault-pass

Inside vars/secrets.yml:

db_password: supersecretpass
api_key: sk-abc123xyz789
ssl_private_key: |
  -----BEGIN PRIVATE KEY-----
  MIIEvQIBADANBg...
  -----END PRIVATE KEY-----

Reference these in your playbook:

vars_files:
  - vars/secrets.yml

For CI/CD pipelines, store the vault password in a file and reference it:

ansible-playbook deploy.yml --vault-password-file .vault_pass

Add .vault_pass to your .gitignore so it’s never committed.

Useful Ansible Tips

Dry Run (Check Mode)

Preview what changes a playbook would make without actually applying them:

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

Limit to Specific Hosts

Run a playbook against only one server from a group:

ansible-playbook -i inventory.ini setup.yml --limit web-01

Tags for Selective Execution

Tag your tasks and run only specific ones:

- name: Install Nginx
  apt:
    name: nginx
    state: present
  tags: [nginx, packages]
ansible-playbook setup.yml --tags "nginx"

Ansible Galaxy — Community Roles

Browse pre-built roles at https://galaxy.ansible.com. Install them directly:

# Install a popular Docker role
ansible-galaxy install geerlingguy.docker

# Install a Certbot/Let's Encrypt role
ansible-galaxy install geerlingguy.certbot

Use them in your playbook:

roles:
  - geerlingguy.docker
  - geerlingguy.certbot

Jeff Geerling’s roles (username geerlingguy on Galaxy) are particularly well-maintained and cover hundreds of common tasks.

Project Structure Best Practice

For a well-organized Ansible project, structure your files like this:

ansible-project/
├── ansible.cfg
├── inventory/
│   ├── production.ini
│   └── staging.ini
├── group_vars/
│   ├── all.yml
│   ├── webservers.yml
│   └── databases.yml
├── roles/
│   ├── common/
│   ├── webserver/
│   └── database/
├── templates/
├── playbooks/
│   ├── site.yml
│   ├── webservers.yml
│   └── databases.yml
└── vars/
    └── secrets.yml  (encrypted with ansible-vault)

Create an ansible.cfg in your project root to set defaults:

[defaults]
inventory = inventory/production.ini
remote_user = deploy
private_key_file = ~/.ssh/id_ed25519
host_key_checking = False
retry_files_enabled = False

[privilege_escalation]
become = True
become_method = sudo
become_ask_pass = False

Where to Go Next

Once you’re comfortable with the basics, explore:

  • Ansible AWX / Semaphore — Web UIs for running Ansible playbooks with a dashboard, scheduling, and team access control. AWX is the open-source upstream of Red Hat Ansible Tower. Semaphore (https://semaphoreui.com) is a lightweight alternative.
  • Dynamic inventory — Instead of static .ini files, pull your host list from AWS EC2, DigitalOcean, or any cloud API automatically.
  • Ansible Lint — Run ansible-lint on your playbooks to catch style issues and potential bugs before execution.
  • Molecule — A testing framework for Ansible roles that spins up containers or VMs to verify your roles work correctly.

Wrapping Up

Ansible removes the tedium and risk of manual server configuration. With a few YAML files, you get repeatable, version-controlled infrastructure that you can apply to one server or a thousand. The agentless architecture means there’s almost zero barrier to getting started — if you can SSH into a server, you can automate it with Ansible.

Start small: write a playbook that installs your essential packages and configures your SSH settings. Once you see how much time it saves, you’ll want to automate everything.

The official documentation at https://docs.ansible.com is excellent and covers every module in detail. The Ansible community on GitHub (https://github.com/ansible/ansible) is active and welcoming for contributions and questions.