Part ofAnsible Automation·Step 1 of 2
DevOps & Platform

Ansible Fundamentals: Automating Server Configuration

Beginner70 min to complete18 min readJune 1, 2026Updated August 19, 2026

Quick answer

Ansible automates server configuration over SSH — no agents, no daemons. Learn inventory, ad-hoc commands, playbooks, core modules, variables, handlers, and secrets with Ansible Vault.

beginner · 70 min

Before you begin

  • Linux intermediate — SSH, sudo, package managers, systemd
  • At least one Linux server to manage (VM, cloud instance, or local)
  • Python 3.8+ on the control node (your machine)
Ansible
Automation
Infrastructure as Code
Linux
DevOps

Ansible Fundamentals: Automating Server Configuration

Ansible is an agentless configuration management tool. It connects to servers over SSH, runs tasks described in YAML files called playbooks, and disconnects. There's no daemon to install on managed nodes, no special port to open — if you can SSH in, Ansible can manage it.

The core design principle is idempotency: running the same playbook twice produces the same result. Tasks check current state and only make changes when needed.


Installation

bash
1# macOS
2brew install ansible
3
4# Ubuntu / Debian
5sudo apt update
6sudo apt install software-properties-common -y
7sudo add-apt-repository --yes --update ppa:ansible/ansible
8sudo apt install ansible -y
9
10# pip (any platform)
11pip3 install ansible
12
13# Verify
14ansible --version
15# ansible [core 2.x.x]

Managed nodes need Python 3.8+ with current ansible-core (2.19 and later); Python 2 support on managed nodes was dropped in ansible-core 2.17, and on the control node back in ansible-core 2.12. Python 3.8+ is pre-installed on Ubuntu 20.04+, Debian 11+, and Amazon Linux 2023. No Ansible installation required on managed nodes — only Python.


Inventory

The inventory tells Ansible which servers exist and how to reach them.

INI format (simplest)

ini
1# inventory.ini
2
3# Ungrouped hosts
4192.168.1.10
5web01.example.com
6
7# Group: web servers
8[web]
9web01.example.com
10web02.example.com ansible_port=2222    # Non-standard SSH port
11
12# Group: database servers
13[db]
14db01.example.com ansible_user=ubuntu   # Override SSH user per host
15db02.example.com
16
17# Group of groups
18[production:children]
19web
20db
21
22# Group variables
23[web:vars]
24http_port=80
25max_connections=200

YAML format

yaml
1# inventory.yaml
2all:
3  children:
4    web:
5      hosts:
6        web01.example.com:
7        web02.example.com:
8          ansible_port: 2222
9    db:
10      hosts:
11        db01.example.com:
12          ansible_user: ubuntu

Test connectivity

bash
1# Ping all hosts in inventory
2ansible all -i inventory.ini -m ansible.builtin.ping
3
4# Ping a specific group
5ansible web -i inventory.ini -m ansible.builtin.ping
6
7# Set default inventory in ansible.cfg

ansible.cfg

Place in the project directory (or ~/.ansible.cfg for global config):

ini
1[defaults]
2inventory = inventory.ini
3remote_user = ubuntu
4private_key_file = ~/.ssh/id_rsa
5
6[privilege_escalation]
7become = True
8become_method = sudo

With this file present, you don't need to pass -i inventory.ini every time.


Ad-hoc Commands

Ad-hoc commands run a single module directly — no playbook needed. Useful for quick checks and one-off tasks.

bash
1# Test connectivity
2ansible all -m ansible.builtin.ping
3
4# Run a shell command
5ansible web -m ansible.builtin.command -a "uptime"
6
7# Shell command (supports pipes, redirects)
8ansible web -m ansible.builtin.shell -a "df -h | grep /dev/sda1"
9
10# Install a package
11ansible web -m ansible.builtin.apt -a "name=nginx state=present" --become
12
13# Copy a file
14ansible web -m ansible.builtin.copy -a "src=./nginx.conf dest=/etc/nginx/nginx.conf" --become
15
16# Restart a service
17ansible web -m ansible.builtin.service -a "name=nginx state=restarted" --become
18
19# Gather facts about a host
20ansible web01.example.com -m ansible.builtin.setup

--become escalates to sudo. -a passes arguments to the module.


Playbooks

A playbook is a YAML file containing one or more plays. Each play targets a group of hosts and runs a list of tasks.

yaml
1# deploy-nginx.yml
2---
3- name: Install and configure Nginx
4  hosts: web
5  become: true
6
7  tasks:
8    - name: Install Nginx
9      ansible.builtin.apt:
10        name: nginx
11        state: present
12        update_cache: true
13
14    - name: Copy Nginx config
15      ansible.builtin.copy:
16        src: files/nginx.conf
17        dest: /etc/nginx/nginx.conf
18        owner: root
19        group: root
20        mode: "0644"
21
22    - name: Ensure Nginx is running and enabled
23      ansible.builtin.service:
24        name: nginx
25        state: started
26        enabled: true
bash
1# Run the playbook
2ansible-playbook deploy-nginx.yml
3
4# Dry run (check what would change, don't apply)
5ansible-playbook deploy-nginx.yml --check
6
7# Show diffs of file changes
8ansible-playbook deploy-nginx.yml --check --diff
9
10# Run against a specific host pattern
11ansible-playbook deploy-nginx.yml --limit web01.example.com
12
13# Verbose output
14ansible-playbook deploy-nginx.yml -v      # Task results
15ansible-playbook deploy-nginx.yml -vv     # File/connection details
16ansible-playbook deploy-nginx.yml -vvv    # Full connection debug

Core Modules

Use the Fully Qualified Collection Name (FQCN) — ansible.builtin.xxx — to be explicit about which collection a module comes from.

Package management

yaml
1- name: Install packages (apt  Ubuntu/Debian)
2  ansible.builtin.apt:
3    name:
4      - nginx
5      - curl
6      - git
7    state: present       # present, absent, latest
8    update_cache: true   # Run apt update first
9
10- name: Install packages (yum/dnf  RHEL/CentOS/Fedora)
11  ansible.builtin.dnf:
12    name: httpd
13    state: present

File operations

yaml
1- name: Copy static file
2  ansible.builtin.copy:
3    src: files/app.conf         # Relative to playbook
4    dest: /etc/app/app.conf
5    owner: root
6    group: root
7    mode: "0644"
8    backup: true                # Keep backup of original
9
10- name: Create directory
11  ansible.builtin.file:
12    path: /opt/myapp/logs
13    state: directory
14    owner: myapp
15    group: myapp
16    mode: "0755"
17
18- name: Create symlink
19  ansible.builtin.file:
20    src: /opt/myapp/current/bin/app
21    dest: /usr/local/bin/app
22    state: link
23
24- name: Add a line to a file (idempotent)
25  ansible.builtin.lineinfile:
26    path: /etc/sysctl.conf
27    line: "net.ipv4.ip_forward = 1"
28    regexp: "^net.ipv4.ip_forward"    # Replace line matching this regex

Templating

yaml
- name: Deploy config from template
  ansible.builtin.template:
    src: templates/nginx.conf.j2      # Jinja2 template
    dest: /etc/nginx/nginx.conf
    owner: root
    mode: "0644"

The template file can reference any Ansible variable:

jinja2
1{# templates/nginx.conf.j2 #}
2worker_processes {{ ansible_processor_vcpus }};
3
4server {
5    listen {{ http_port | default(80) }};
6    server_name {{ inventory_hostname }};
7}

Service management

yaml
- name: Manage service
  ansible.builtin.service:
    name: nginx
    state: started       # started, stopped, restarted, reloaded
    enabled: true        # Start on boot

User management

yaml
1- name: Create user
2  ansible.builtin.user:
3    name: deploy
4    shell: /bin/bash
5    groups: sudo
6    append: true         # Add to groups without removing existing ones
7    create_home: true
8
9- name: Add authorized key
10  ansible.posix.authorized_key:    # requires: ansible-galaxy collection install ansible.posix
11    user: deploy
12    key: "{{ lookup('file', '~/.ssh/id_rsa.pub') }}"
13    state: present

ansible.posix.authorized_key is in the ansible.posix collection. It's included in the full ansible package (pip3 install ansible). If you installed ansible-core, add it manually: ansible-galaxy collection install ansible.posix.

Running commands

yaml
1- name: Run a command (no shell features)
2  ansible.builtin.command:
3    cmd: /usr/bin/myapp --init
4    creates: /opt/myapp/.initialized   # Skip if this path exists (idempotency)
5
6- name: Run shell command (pipes, redirects, wildcards)
7  ansible.builtin.shell:
8    cmd: "echo {{ app_version }} > /opt/myapp/VERSION"
9    chdir: /opt/myapp

Prefer command over shell when you don't need shell features — it's safer (no shell injection risk).


Variables

In the playbook

yaml
1- name: Deploy application
2  hosts: web
3  vars:
4    app_version: "2.1.0"
5    app_port: 8080
6  tasks:
7    - name: Print version
8      ansible.builtin.debug:
9        msg: "Deploying version {{ app_version }}"

In group_vars and host_vars

Ansible automatically loads variable files based on the inventory:

inventory.ini
group_vars/
  all.yml          # Applies to all hosts
  web.yml          # Applies to [web] group
  db.yml           # Applies to [db] group
host_vars/
  web01.example.com.yml    # Applies to this host only
yaml
# group_vars/web.yml
http_port: 80
max_connections: 500
nginx_version: "1.24"

Variable precedence (lowest to highest)

  1. Role defaults (roles/xxx/defaults/main.yml)
  2. Inventory group_vars
  3. Inventory host_vars
  4. Play vars:
  5. Task vars:
  6. --extra-vars (command line) — highest priority
bash
# Override at runtime
ansible-playbook deploy.yml --extra-vars "app_version=2.2.0 env=prod"

Registering task output

yaml
1- name: Get current version
2  ansible.builtin.command: cat /opt/myapp/VERSION
3  register: current_version
4
5- name: Print it
6  ansible.builtin.debug:
7    msg: "Current version: {{ current_version.stdout }}"

Facts

Ansible auto-collects facts about each managed host at play start:

yaml
- name: Show facts
  ansible.builtin.debug:
    msg: "{{ ansible_hostname }} runs {{ ansible_distribution }} {{ ansible_distribution_version }}"

Key facts:

FactExample value
ansible_hostnameweb01
ansible_fqdnweb01.example.com
ansible_distributionUbuntu
ansible_distribution_version24.04
ansible_processor_vcpus4
ansible_memtotal_mb8192
ansible_default_ipv4.address10.0.0.5
ansible_os_familyDebian

Conditionals and Loops

when — run task only if condition is true

yaml
1- name: Install Apache (Debian only)
2  ansible.builtin.apt:
3    name: apache2
4    state: present
5  when: ansible_os_family == "Debian"
6
7- name: Install Apache (RedHat only)
8  ansible.builtin.dnf:
9    name: httpd
10    state: present
11  when: ansible_os_family == "RedHat"
12
13- name: Only run in production
14  ansible.builtin.shell: systemctl restart app
15  when: env == "production"
16
17- name: Skip if version already installed
18  ansible.builtin.command: ./install.sh
19  when: current_version.stdout != app_version

loop — repeat a task over a list

yaml
1- name: Install multiple packages one at a time
2  ansible.builtin.apt:
3    name: "{{ item }}"
4    state: present
5  loop:
6    - nginx
7    - curl
8    - git
9    - jq
10
11- name: Create multiple users
12  ansible.builtin.user:
13    name: "{{ item.name }}"
14    groups: "{{ item.groups }}"
15    shell: /bin/bash
16  loop:
17    - { name: alice, groups: sudo }
18    - { name: bob, groups: developers }
19    - { name: carol, groups: developers }

Handlers

Handlers are tasks that only run when notified — and only run once at the end of the play, regardless of how many times they were notified. The canonical use case is restarting a service after its config changes.

yaml
1- name: Configure and start Nginx
2  hosts: web
3  become: true
4
5  tasks:
6    - name: Install Nginx
7      ansible.builtin.apt:
8        name: nginx
9        state: present
10
11    - name: Copy Nginx config
12      ansible.builtin.copy:
13        src: files/nginx.conf
14        dest: /etc/nginx/nginx.conf
15      notify: Restart Nginx         # Trigger handler if this task changed anything
16
17    - name: Copy site config
18      ansible.builtin.copy:
19        src: files/site.conf
20        dest: /etc/nginx/sites-enabled/site.conf
21      notify: Restart Nginx         # Same handler — only fires once
22
23  handlers:
24    - name: Restart Nginx
25      ansible.builtin.service:
26        name: nginx
27        state: restarted

If neither copy task changed anything (files already match), the handler never runs.


Ansible Vault — Encrypting Secrets

Never put passwords, API keys, or private keys in plaintext playbooks. Ansible Vault encrypts variable files at rest.

bash
1# Encrypt a new file
2ansible-vault create group_vars/all/vault.yml
3
4# Encrypt an existing file
5ansible-vault encrypt group_vars/all/vault.yml
6
7# Edit an encrypted file
8ansible-vault edit group_vars/all/vault.yml
9
10# View encrypted file contents
11ansible-vault view group_vars/all/vault.yml
12
13# Decrypt (careful — only for migration)
14ansible-vault decrypt group_vars/all/vault.yml

In the vault file, prefix secrets with vault_:

yaml
# group_vars/all/vault.yml (encrypted)
vault_db_password: "s3cr3t!"
vault_api_key: "abc123xyz"

Reference them in non-encrypted variables:

yaml
# group_vars/all/vars.yml (plaintext)
db_password: "{{ vault_db_password }}"
api_key: "{{ vault_api_key }}"

Run playbooks with vault:

bash
# Prompt for vault password
ansible-playbook deploy.yml --ask-vault-pass

# Use a password file (for CI/CD)
ansible-playbook deploy.yml --vault-password-file ~/.vault_pass

A Complete Example

Deploying a Node.js application:

yaml
1# deploy-app.yml
2---
3- name: Deploy Node.js application
4  hosts: web
5  become: true
6  vars:
7    app_user: deploy
8    app_dir: /opt/myapp
9    app_version: "{{ version | default('latest') }}"
10
11  tasks:
12    - name: Create app user
13      ansible.builtin.user:
14        name: "{{ app_user }}"
15        shell: /bin/bash
16        create_home: true
17
18    - name: Install Node.js dependencies
19      ansible.builtin.apt:
20        name:
21          - nodejs
22          - npm
23        state: present
24        update_cache: true
25
26    - name: Create app directory
27      ansible.builtin.file:
28        path: "{{ app_dir }}"
29        state: directory
30        owner: "{{ app_user }}"
31        mode: "0755"
32
33    - name: Copy application files
34      ansible.builtin.copy:
35        src: app/
36        dest: "{{ app_dir }}/"
37        owner: "{{ app_user }}"
38      notify: Restart app
39
40    - name: Deploy systemd service
41      ansible.builtin.template:
42        src: templates/myapp.service.j2
43        dest: /etc/systemd/system/myapp.service
44      notify: Reload systemd
45
46    - name: Enable and start service
47      ansible.builtin.service:
48        name: myapp
49        state: started
50        enabled: true
51
52  handlers:
53    - name: Reload systemd
54      ansible.builtin.systemd:
55        daemon_reload: true
56
57    - name: Restart app
58      ansible.builtin.service:
59        name: myapp
60        state: restarted

Frequently Asked Questions

Is Ansible really idempotent?

The well-written modules are — running twice produces the same result as running once. The command and shell modules are not, because Ansible cannot know what your command does. Use a real module where one exists, and add a condition or a creates guard when you must shell out.

How should I manage secrets?

Ansible Vault for values in the repository, or better, pull from a secret manager at runtime so nothing sensitive is committed at all. Never put credentials in plain inventory or group variables — those are the files most likely to be shared or copied into a ticket.

Why does my playbook work locally but fail on a real host?

Usually Python interpreter or privilege escalation differences. Ansible needs a suitable Python on the managed host, and tasks requiring root need become. Errors about a missing module often mean the module is missing on the target, not on your machine.

Should I use Ansible to create cloud infrastructure?

For ephemeral resources or bootstrapping, it works. For anything long-lived, the absence of state is the problem — no plan, no drift detection, no reliable teardown. Provision with a state-aware tool and let Ansible configure what is inside the machine.

What's Next

Official References

Next in Ansible Automation

Ansible Jinja2 Templates and Roles

Continue

We built Podscape to simplify Kubernetes workflows like this — logs, events, and cluster state in one interface, without switching tools.

Struggling with this in production?

We help teams fix these exact issues. Our engineers have deployed these patterns across production environments at scale.