When I rebuild a machine, the annoying part is not installing packages again. It’s remembering the small fixes I made months ago: a package tweak, a service setting, a folder permission, a line added to some config file. Ansible gives me a place to write those decisions down and run them again.
I do not think of Ansible as a big enterprise tool first. For my use case, it is closer to a checklist that can execute itself. This example uses the Fedora Server VM from my libvirt post and turns a few setup steps into a playbook.
Installing Ansible
On my machine, I usually install Ansible with pip:
python3 -m pip install --user ansible
Note
The official installation guide covers distro-specific options if
pipis not how you want to install it.
You can confirm your installation by running:
ansible --version
Creating Your inventory.yaml
The inventory is where I tell Ansible which machines it can touch. For this post there is only one target: the Fedora VM.
Here I’ll simulate automated configuration on the Fedora Server virtual machine from Creating a Fedora Server 36 Virtual Machine.
To start, create a file called inventory.yaml and inside it define:
vms:
hosts:
fedora:
ansible_host: 192.168.122.143
ansible_connection: ssh
ansible_user: youruser
ansible_host is the IP of the machine and ansible_user is the user Ansible will use over SSH. If you are testing against your own machine instead of a VM, use localhost.
Before doing anything destructive, I like to confirm that Ansible can see the inventory:
[user@localhost ~]# ansible -i inventory.yaml all --list-hosts
hosts (1):
fedora
After that, configure SSH access by copying your public key to the VM:
[user@localhost]# ssh-copy-id -i ~/.ssh/id_rsa.pub [email protected]
/usr/bin/ssh-copy-id: INFO: Source of key(s) to be installed: "/home/youruser/.ssh/id_rsa.pub"
The authenticity of host '192.168.122.143 (192.168.122.143)' can't be established.
ED25519 key fingerprint is ...
This key is not known by any other names
Are you sure you want to continue connecting (yes/no/[fingerprint])? yes
/usr/bin/ssh-copy-id: INFO: attempting to log in with the new key(s), to filter out any that are already installed
/usr/bin/ssh-copy-id: INFO: 1 key(s) remain to be installed -- if you are prompted now it is to install the new keys
[email protected]'s password:
bash: warning: setlocale: LC_ALL: cannot change locale (pt_BR.UTF-8)
/usr/bin/sh: warning: setlocale: LC_ALL: cannot change locale (pt_BR.UTF-8)
/usr/bin/sh: warning: setlocale: LC_ALL: cannot change locale (pt_BR.UTF-8)
/usr/bin/sh: warning: setlocale: LC_ALL: cannot change locale (pt_BR.UTF-8)
Number of key(s) added: 1
Now try logging into the machine, with: "ssh '[email protected]'"
and check to make sure that only the key(s) you wanted were added.youruser
The first Ansible command I run against a new host is ping. It checks the SSH connection and confirms Python can run on the target:
[user@localhost]# ansible -i inventory.yaml vms -m ping
fedora | SUCCESS => {
"ansible_facts": {
"discovered_interpreter_python": "/usr/bin/python3"
},
"changed": false,
"ping": "pong"
}
Creating Your Playbook
The playbook is the part worth keeping in Git. It is where the manual steps become repeatable tasks.
Create a new file called playbook.yaml with the following content:
- hosts: vms
become: true
tasks:
- name: Update packages with DNF
package:
name: '*'
state: latest
- name: Message
ansible.builtin.debug:
msg: Packages were updated!
Run it with --ask-become-pass because updating packages with DNF needs elevated permissions. If your target uses passwordless sudo, you can omit that flag.
[user@localhost]# ansible-playbook -i inventory.yaml --ask-become-pass playbook.yaml
BECOME password:
PLAY [vms] ***********************************************************************************************
TASK [Gathering Facts] ***********************************************************************************
ok: [fedora]
TASK [DNF Update] ****************************************************************************************
ok: [fedora]
TASK [Print message] *************************************************************************************
ok: [fedora] => {
"msg": "Done!"
}
PLAY RECAP ***********************************************************************************************
fedora : ok=3 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
The recap is the part I check first: failed=0 and unreachable=0 mean the playbook reached the VM and finished without errors.
Working with Variables
Variables are useful once the same playbook needs to run against machines that are almost the same, but not identical. I usually keep host-specific values in the inventory.
Defining Variables in the Inventory
For example, add an installation path to the inventory:
vms:
hosts:
fedora:
ansible_host: 192.168.122.143
ansible_connection: ssh
ansible_user: youruser
remote_install_path: /usr/bin
Then reference it in the playbook:
- hosts: vms
become: true
tasks:
- name: Copy installation script
ansible.builtin.copy:
src: /home/user/Downloads/my-script.sh
dest: "{{ remote_install_path }}/my-script.sh"
Defining List Type Variables
Lists are useful for repeated resources, like folders:
vms:
hosts:
fedora:
ansible_host: 192.168.122.143
ansible_connection: ssh
ansible_user: youruser
folders:
- /usr/bin
- /usr/local/bin
Then loop over the list:
- hosts: vms
become: true
tasks:
- name: Create folders
ansible.builtin.file:
path: "{{ item }}"
state: directory
loop: "{{ folders }}"
Return Variables
Another pattern I use a lot is registering command output and making decisions from it. This example checks the CPU architecture before running architecture-specific tasks:
- hosts: vms
become: true
tasks:
- name: Check architecture
ansible.builtin.shell: uname -m
register: host_arch
- name: Display architecture
ansible.builtin.debug:
msg: "{{ host_arch.stdout }}"
- name: Display error if not supported
ansible.builtin.debug:
msg: No support for aarch64
when: host_arch.stdout == 'aarch64'
One detail that bit me early on: Ansible variables have precedence rules. When a value looks “ignored”, check Understanding variable precedence before blaming the playbook.
Playbook Organization
Once a playbook grows, I prefer splitting it by topic instead of keeping one long file. For example:
- hosts: vms
become: true
tasks:
- name: Configure DNF
ansible.builtin.import_tasks: tasks/configure_dnf.yaml
- name: Install basic packages
ansible.builtin.import_tasks: tasks/install_base_packages.yaml
- name: Configure docker
ansible.builtin.import_tasks: tasks/configure_docker.yaml
The imported file, tasks/configure_dnf.yaml, contains only the DNF-specific work:
- name: Configure DNF for better performance
ansible.builtin.blockinfile:
backup: yes
path: /etc/dnf/dnf.conf
block: |
fastestmirror=true
max_parallel_downloads=20
You can also split by playbook when the setup grows into larger areas:
- hosts: vms
- import_playbook: webservers.yaml
- import_playbook: databases.yml
- import_playbook: containers.yml
Modules
For this kind of machine setup, these are the modules I usually check first:
- ansible.builtin.copy: Copy local or remote host files to some location on the remote host;
- ansible.builtin.find: Returns a list of files using a criterion;
- ansible.builtin.cron: Manages crons and environment variables;
- ansible.builtin.dnf: Manages packages with the
dnfpackage manager; - ansible.builtin.git: Deploys software using git checkout;
- ansible.builtin.pause: Pauses playbook execution when a manual step is unavoidable;
- ansible.builtin.lineinfile: Deals with lines in files, changes individual lines in files.
To see the complete list of modules that come integrated with Ansible, click here.
Where This Fits
This example is intentionally small. That is the point: start by automating one VM, one package update, one config change. Once the habit is there, the playbook becomes the place where those small fixes stop living only in your memory.
I’d keep the Ansible documentation nearby, along with Martin Fowler’s SnowflakeServer.
Comments