The scenario
You have three Fedora machines. One is a desktop, one is a home server, and one is a fresh VM you just spun up for testing. You need to install the same set of packages, apply a security patch, and drop a configuration file into /etc/ on all of them. Logging into each machine and running sudo dnf install works once. It breaks the second time you forget which machine got the update. You need a way to push changes reliably without typing the same commands three times.
What Ansible actually does
Ansible is a remote execution engine. It connects to your machines over SSH, runs a series of predefined tasks, and reports back what changed. It does not require an agent on the target systems. It relies entirely on OpenSSH and Python. Think of it as a remote control that speaks the language of your package manager and your filesystem. You tell it what the final state should look like. It figures out the steps to get there. If the state already matches, it does nothing.
This behavior is called idempotency. Running the same playbook ten times produces the exact same result as running it once. The engine checks the current state, compares it to your desired state, and only applies the delta. You do not need to write conditional logic to check if a package is already installed. The module handles it.
Convention aside: Ansible uses SSH keys by default. Password authentication works but breaks automation. Set up key-based SSH on your Fedora systems before you write a single playbook. Password prompts will halt your runs and require manual intervention.
Setting up the control node
You run Ansible from one machine, called the control node. This can be your daily desktop or a dedicated server. Install the package and verify the version.
sudo dnf install ansible-core -y
# WHY: ansible-core contains the engine and standard modules.
# The -y flag skips the confirmation prompt for automation.
ansible --version
# WHY: Confirms the installation path and Python version.
# Ansible 2.14+ is standard on Fedora 38 and newer.
Generate an SSH key if you do not have one. Copy it to every target machine.
ssh-keygen -t ed25519 -C "ansible-control"
# WHY: Creates a modern, secure key pair without a passphrase.
# The -C flag adds a comment for identification.
ssh-copy-id user@fedora-desktop
# WHY: Appends the public key to the remote authorized_keys file.
# This enables passwordless login for automation.
Test the connection before proceeding. Ansible will fail silently if SSH refuses the key.
ssh user@fedora-desktop "echo connection successful"
# WHY: Validates key authentication and network reachability.
# Catches firewall blocks or sshd misconfigurations early.
Building the inventory
The inventory file tells Ansible where your machines live and how to reach them. You can use a simple INI file or a YAML file. INI is easier for beginners. Create a file named inventory.ini in your project directory.
[fedora_hosts]
fedora-desktop ansible_host=192.168.1.10 ansible_user=your_user
fedora-server ansible_host=192.168.1.20 ansible_user=your_user
test-vm ansible_host=192.168.1.30 ansible_user=your_user
[fedora_hosts:vars]
ansible_python_interpreter=/usr/libexec/platform-python
# WHY: Forces Ansible to use the system Python instead of searching.
# Fedora disables python3 symlinks, which breaks older Ansible versions.
Convention aside: Keep your inventory files outside version control if they contain internal IPs or credentials. Use environment variables or Ansible Vault for secrets. Never commit raw passwords to a repository. Fedora's package manager respects /etc/ for user modifications and /usr/lib/ for package defaults. Apply the same discipline to your automation files. Separate your base inventory from your environment-specific overrides.
Writing the first playbook
A playbook is a YAML file that defines tasks. Each task uses a module to enforce a state. The dnf module handles package management. The copy module handles files. The lineinfile module edits configuration files. Start with a simple update and package installation.
- name: Baseline Fedora configuration
hosts: fedora_hosts
become: true
tasks:
- name: Refresh package metadata
dnf:
name: "*"
state: latest
update_cache: true
# WHY: update_cache forces a metadata refresh before checking versions.
# This prevents stale cache errors on fresh systems.
- name: Install common utilities
dnf:
name:
- curl
- git
- vim-enhanced
state: present
# WHY: state: present ensures packages are installed.
# It skips them if they are already at the latest version.
Convention aside: Always use become: true at the play level instead of sudo inside tasks. Ansible handles privilege escalation cleanly through become. It maps to sudo by default but can use su or doas if configured. This keeps your playbooks readable and separates privilege logic from task logic.
Handling configuration files
Package installation is only half the job. You usually need to drop configuration files into /etc/. The copy module works for static files. The template module works when you need to inject variables. Create a directory named templates alongside your playbook.
- name: Deploy network configuration
template:
src: templates/network.conf.j2
dest: /etc/NetworkManager/conf.d/custom.conf
owner: root
group: root
mode: "0644"
# WHY: template renders Jinja2 variables before writing to disk.
# mode: "0644" ensures correct permissions for config files.
notify: restart NetworkManager
# WHY: Triggers a handler at the end of the play if the file changes.
# Prevents unnecessary service restarts on idempotent runs.
Handlers run once per host, even if multiple tasks notify them. This keeps your system stable during large deployments. Define the handler at the bottom of the same playbook.
handlers:
- name: restart NetworkManager
systemd:
name: NetworkManager
state: restarted
# WHY: systemd module handles service lifecycle cleanly.
# It waits for the service to fully restart before continuing.
Convention aside: journalctl -xe reads better than journalctl alone. The x flag adds explanatory text and the e flag jumps to the end. Most sysadmins type journalctl -xeu <unit> muscle-memory style. Use that pattern when verifying service restarts after your playbook runs.
Running and verifying
Execute the playbook against your inventory. Use the --check flag first to see what would change without modifying anything.
ansible-playbook -i inventory.ini playbook.yml --check
# WHY: --check runs in dry-run mode.
# It reports changes without touching the target systems.
ansible-playbook -i inventory.ini playbook.yml
# WHY: Applies the tasks for real.
# Output shows changed, ok, failed, or skipped per host.
Verify the results directly on a target machine.
ssh fedora-desktop "dnf list installed curl git vim-enhanced"
# WHY: Confirms the packages actually landed on the remote host.
# Direct SSH verification catches silent failures.
ssh fedora-desktop "cat /etc/NetworkManager/conf.d/custom.conf"
# WHY: Validates the configuration file content and permissions.
# Ensures the template rendered correctly on the target.
Common pitfalls and error patterns
YAML indentation breaks playbooks instantly. Ansible will not tell you which line caused the parse error. It will dump a traceback. Run the YAML through a linter before execution.
ansible-playbook --syntax-check -i inventory.ini playbook.yml
# WHY: Catches indentation errors and missing required fields.
# Fails fast before SSH connections are established.
Privilege escalation fails when become is enabled but the user lacks sudo rights. The output will show FAILED! => {"msg": "Missing sudo password"}. Fix the sudoers file on the target or remove become: true if the task does not require root.
SSH host key verification stops automation on first run. Ansible will refuse to connect and print The authenticity of host '192.168.1.10' can't be established.. Add host_key_checking: false to your inventory variables for lab environments. Never use this flag in production.
Convention aside: Fedora ships with platform-python at /usr/libexec/platform-python. Older Ansible versions expect /usr/bin/python3. The symlink is intentionally removed in modern Fedora. Always set ansible_python_interpreter in your inventory or group vars. Missing this variable causes ModuleNotFoundError on every task.
Run journalctl -xe first. Read the actual error before guessing. Half the time the symptom is a missing dependency or a permission mismatch that the logs explain clearly.
When to use Ansible versus alternatives
Use Ansible when you need agentless configuration management across dozens of Fedora systems. Use systemd timers and local scripts when you only manage one machine and want zero external dependencies. Use terraform when you are provisioning cloud infrastructure and need state tracking for virtual machines. Use saltstack when you require real-time event-driven automation and a master-minion architecture. Stay on manual SSH commands when you are testing a one-off fix and do not want to write YAML.