Ansible Fundamentals: Automating Server Configuration
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 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
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)
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=200YAML format
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: ubuntuTest connectivity
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.cfgansible.cfg
Place in the project directory (or ~/.ansible.cfg for global config):
1[defaults]
2inventory = inventory.ini
3remote_user = ubuntu
4private_key_file = ~/.ssh/id_rsa
5
6[privilege_escalation]
7become = True
8become_method = sudoWith 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.
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.
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: true1# 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 debugCore Modules
Use the Fully Qualified Collection Name (FQCN) — ansible.builtin.xxx — to be explicit about which collection a module comes from.
Package management
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: presentFile operations
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 regexTemplating
- 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:
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
- name: Manage service
ansible.builtin.service:
name: nginx
state: started # started, stopped, restarted, reloaded
enabled: true # Start on bootUser management
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_keyis in theansible.posixcollection. It's included in the fullansiblepackage (pip3 install ansible). If you installedansible-core, add it manually:ansible-galaxy collection install ansible.posix.
Running commands
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/myappPrefer command over shell when you don't need shell features — it's safer (no shell injection risk).
Variables
In the playbook
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
# group_vars/web.yml
http_port: 80
max_connections: 500
nginx_version: "1.24"Variable precedence (lowest to highest)
- Role defaults (
roles/xxx/defaults/main.yml) - Inventory group_vars
- Inventory host_vars
- Play
vars: - Task
vars: --extra-vars(command line) — highest priority
# Override at runtime
ansible-playbook deploy.yml --extra-vars "app_version=2.2.0 env=prod"Registering task output
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:
- name: Show facts
ansible.builtin.debug:
msg: "{{ ansible_hostname }} runs {{ ansible_distribution }} {{ ansible_distribution_version }}"Key facts:
| Fact | Example value |
|---|---|
ansible_hostname | web01 |
ansible_fqdn | web01.example.com |
ansible_distribution | Ubuntu |
ansible_distribution_version | 24.04 |
ansible_processor_vcpus | 4 |
ansible_memtotal_mb | 8192 |
ansible_default_ipv4.address | 10.0.0.5 |
ansible_os_family | Debian |
Conditionals and Loops
when — run task only if condition is true
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_versionloop — repeat a task over a list
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.
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: restartedIf 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.
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.ymlIn the vault file, prefix secrets with vault_:
# group_vars/all/vault.yml (encrypted)
vault_db_password: "s3cr3t!"
vault_api_key: "abc123xyz"Reference them in non-encrypted variables:
# group_vars/all/vars.yml (plaintext)
db_password: "{{ vault_db_password }}"
api_key: "{{ vault_api_key }}"Run playbooks with vault:
# 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_passA Complete Example
Deploying a Node.js application:
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: restartedFrequently 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
- Ansible Jinja2 Templates and Roles — structure larger codebases with reusable roles, advanced Jinja2, and troubleshooting
- Linux Commands for Advanced Engineers — systemd, ulimit, and sysctl — the same settings Ansible manages
Official References
- ansible-core release and maintenance — the control-node and managed-node Python support matrix
- Ansible playbook guide — tasks, handlers, roles, and execution order
- Ansible inventory guide — static and dynamic inventory formats
Next in Ansible Automation
Ansible Jinja2 Templates and Roles
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.