Automating Linux Patching with Ansible
Overview
Keeping a fleet of Linux servers patched by hand doesn't scale — logging into each box, running apt update && apt upgrade, checking for reboots, and repeating it weekly gets tedious and error-prone fast.
Ansible solves this by letting you describe what you want ("all servers patched and rebooted if needed") in a simple YAML file, then push that out to every server at once over SSH. There's no agent to install on the servers you're managing — Ansible just connects over SSH and runs the commands for you.
This guide walks through setting up Ansible from zero: preparing your servers, connecting them securely, writing your first patch playbook, testing it safely, and scheduling it to run automatically.
What you'll end up with:
- A central "control node" that can securely manage all your servers
- An inventory file listing every server you want to patch, grouped by how much risk a bad patch carries
- A playbook that updates packages, cleans up old ones, and reboots only if needed
- A second playbook for infrastructure hosts that reports instead of rebooting
- Cron jobs that run both on a schedule, with logs you can review
Estimated time: 30–45 minutes for your first few servers.
Jump To
| I need to... | Section |
|---|---|
| Understand the terminology | Key Concepts |
Set up the ansible account on a host |
Step 2 |
| Install the SSH key, including on hardened hosts | Step 4 |
| Write or restructure the inventory | Step 5 |
| Fix a host that suddenly won't connect | Handling a Changed Host Key |
| Write the patch playbook | Step 7 |
| Schedule it with cron | Step 9 |
| Patch a hypervisor or public VPS safely | Step 10 |
| Diagnose a failing host | Troubleshooting |
| Look up a command | Quick Reference |
Key Concepts (Read This First)
If you're new to Ansible, these four terms will come up constantly:
| Term | What it means |
|---|---|
| Control node | The machine you run Ansible from. This is the only place Ansible needs to be installed. |
| Managed node | A server Ansible connects to and configures. No agent software required — just SSH access. |
| Inventory | A text file listing which servers Ansible should manage, and how to connect to them. |
| Playbook | A YAML file describing the tasks you want performed (e.g. "update packages, then reboot if needed"). |
Ansible connects to managed nodes over SSH using key-based authentication — no passwords typed in, no agent installed on the target machines. This is what makes the setup lightweight and secure.
Which machine am I typing this on?
The single most common source of confusion when following any Ansible guide. The rule for this page:
- Anything starting with
ansibleoransible-playbookruns on the control node. - Everything else runs on the managed node being changed.
A command run from the wrong machine produces a confusing but perfectly logical error. For example, running ssh ansible@<ip> from a managed node instead of the control node offers that node's key rather than the control node's, and you get Permission denied (publickey) even though everything is configured correctly.
Step 1: Choose and Prepare Your Control Node
Pick one machine to act as your control node. It can be a small VM, a container, or even your own workstation — it just needs network access to every server you want to patch.
Install Ansible:
Confirm it installed correctly:
You should see version info, the config file path, and the Python interpreter being used.
Step 2: Create a Dedicated Service Account on Each Server
Instead of connecting as root or your personal user, create a dedicated ansible account on every server you plan to manage. This keeps access scoped, auditable, and easy to revoke later if needed.
On each managed server:
The -m flag creates the home directory owned by the new user. This matters more than it looks: Ansible writes temporary files to /home/ansible/.ansible/tmp on every single task, so a home directory that exists but is owned by root causes every playbook run against that host to fail.
Verify it immediately:
You want drwxr-x--- ansible ansible or tighter. If it shows root root, fix it now:
Give it passwordless sudo access (needed for package updates and reboots):
echo "ansible ALL=(ALL) NOPASSWD:ALL" | sudo tee /etc/sudoers.d/ansible
sudo chmod 440 /etc/sudoers.d/ansible
sudo visudo -c
visudo -c must print parsed OK for every file it checks. Never leave a managed host without running it — a malformed sudoers file breaks sudo entirely, including your own ability to fix it.
Why passwordless sudo?
Ansible runs unattended, often via cron in the middle of the night. There's no way to type a sudo password interactively during an automated run, so the service account needs NOPASSWD sudo rights scoped to system administration tasks.
Step 3: Generate an SSH Key on the Control Node
Back on your control node, generate a dedicated SSH keypair just for Ansible (don't reuse your personal SSH key — keeping this separate makes it easy to rotate or revoke later without affecting your own access).
- Press Enter to skip a passphrase (needed for unattended cron runs) — or set one and use
ssh-agentif you want extra security and don't mind the added complexity. - This creates two files:
ansible_key(private — keep this secret) andansible_key.pub(public — safe to distribute).
Step 4: Upload the SSH Key to Every Managed Server
Now copy the public key to each server so the ansible account can log in without a password.
The easiest method is ssh-copy-id, run once per server from the control node:
You'll be prompted for the ansible account's password one final time — after this, key-based login takes over.
If password login is disabled on the target
Common on hardened servers and any host where you already ran the bootstrap playbook. Copy the key by hand instead.
On the control node, display the key and copy the whole line:
On the managed server, as root, paste it in:
Paste as a single line with no wrapping, save, then fix permissions:
chmod 600 /home/ansible/.ssh/authorized_keys
chown -R ansible:ansible /home/ansible
ls -la /home/ansible/.ssh
You want drwx------ on the directory and -rw------- on the file, both owned by ansible.
Check for SSH restrictions before troubleshooting the key
On hardened hosts, a correct key still fails if the SSH daemon refuses the user outright. Check on the managed server:
grep -iE '^(Port|PermitRootLogin|PasswordAuthentication|AllowUsers|AllowGroups|PubkeyAuthentication)' /etc/ssh/sshd_config
ls /etc/ssh/sshd_config.d/ 2>/dev/null && grep -riE '^(Port|AllowUsers|AllowGroups)' /etc/ssh/sshd_config.d/
The second command matters. Hardening guides and cloud-init both drop override files into sshd_config.d/, and those settings won't appear in the main config file.
If AllowUsers or AllowGroups is set and ansible isn't listed, add it, then:
sshd -t validates the config first. Never restart SSH on a remote host without it — a syntax error leaves the daemon dead and you locked out.
If Port is not 22, that belongs in the inventory as ansible_port=<port>, not worked around at the SSH layer.
Verify key-based login works
From the control node:
ssh -i ~/.ssh/ansible_key -o BatchMode=yes ansible@<server-ip> 'hostname; sudo -n true && echo sudo-ok'
Two lines back — the hostname, then sudo-ok — means the key and sudo are both correct. BatchMode=yes prevents it from silently falling back to a password prompt and masking a broken key.
Repeat for every server you plan to manage.
Step 5: Build Your Inventory File
The inventory tells Ansible which servers exist and how to reach them. Create a project folder and an inventory file:
Group by risk, not just by existence
The instinct is to put every Linux host in one group. Don't. A bad patch on a container running a wiki is an annoyance. The same patch on the hypervisor that hosts every other container, or on a public-facing mail server, is an outage.
Split the inventory by how much a bad run costs:
# Application containers — safe to patch and reboot unattended
[linux_servers]
web01 ansible_host=192.168.1.10
web02 ansible_host=192.168.1.11
db01 ansible_host=192.168.1.20
# Hypervisors — patched separately, never rebooted unattended
[proxmox]
pve ansible_host=192.168.1.2
pve1 ansible_host=192.168.1.3
# Public-facing VPS — patched separately, never rebooted unattended
[vps]
mail ansible_host=<public-ip>
app ansible_host=<public-ip>
[linux_servers:vars]
ansible_user=ansible
ansible_become=yes
ansible_become_method=sudo
ansible_ssh_private_key_file=~/.ssh/ansible_key
[proxmox:vars]
ansible_user=ansible
ansible_become=yes
ansible_become_method=sudo
ansible_ssh_private_key_file=~/.ssh/ansible_key
[vps:vars]
ansible_user=ansible
ansible_become=yes
ansible_become_method=sudo
ansible_ssh_private_key_file=~/.ssh/ansible_key
Group variables are not inherited — every group needs its own :vars block
A [groupname:vars] section applies only to that one group. Adding a new group without its own vars block means those hosts fall back to Ansible's defaults, which is connecting as the user running the playbook — usually root.
On any host with PermitRootLogin no, that produces Permission denied (publickey), which looks exactly like a broken SSH key. It isn't.
Two ways this goes wrong in practice:
- The block is missing entirely. Easy to spot once you know to look.
- The block is present but empty. A
[vps:vars]header immediately followed by another[groupname:vars]header means the variables below belong to the second group, not the first. The file looks correct at a glance.
Verify what Ansible actually resolved rather than trusting the file:
The output must include "ansible_user": "ansible". If it only shows ansible_host, the vars block isn't reaching that host.
Use named hosts, not raw IPs
Giving each server a friendly name instead of just an IP makes playbook output and logs far easier to read, and means an IP change is one value to edit rather than a hunt through the file.
It also solves a real problem: an inventory of bare IPs contains no record of what each address is. If an IP gets reassigned to a different application, Ansible will happily patch the new machine under the old entry, and nothing in the output tells you.
Generating a named inventory from an existing IP-only one
If you already have a working inventory of bare IPs, let the hosts name themselves. Run on the control node:
cd ~/ansible-patching
for ip in $(awk '/^[0-9]/ {print $1}' hosts.ini); do
name=$(ssh -o BatchMode=yes -o ConnectTimeout=5 -o StrictHostKeyChecking=accept-new \
ansible@"$ip" hostname 2>/dev/null)
if [ -n "$name" ]; then
printf '%-24s ansible_host=%s\n' "$name" "$ip"
else
printf '# UNREACHABLE ansible_host=%s\n' "$ip"
fi
done | tee hosts-named.txt
Unreachable hosts come back commented rather than silently dropped, so you can see what needs attention. Review the output, sort hosts into groups, and rebuild hosts.ini from it.
Watch for duplicate hostnames in the output. If two containers both report ubuntu or localhost, Ansible silently uses only one of the entries.
Step 6: Test Connectivity
Before writing any automation, confirm Ansible can actually reach every server:
Every host should return something like:
Test every group, not just the first one:
If a host fails here, fix that connection issue before moving on — nothing downstream will work reliably until this passes cleanly for every server.
Common causes of failure at this stage:
- Missing or empty
[group:vars]block → Ansible connects asroot, which is refused. Check withansible-inventory -i hosts.ini --host <name> /home/ansibleowned by root →Failed to create temporary directory. Fix withchown -R ansible:ansible /home/ansible- SSH key not copied correctly → re-run Step 4
- Firewall blocking SSH → check
ufw status/ security group rules - Host key changed after an IP reassignment → see Handling a Changed Host Key below
Handling a Changed Host Key
When an IP gets reassigned to a different machine, SSH refuses to connect because the cached fingerprint no longer matches. This shows up as a host key warning interactively, and as an unreachable host in Ansible.
Fix it on the control node, as the user that runs the playbook. If cron runs as root, the relevant file is /root/.ssh/known_hosts, not your own:
ssh-keygen -f /root/.ssh/known_hosts -R <ip>
ssh-keyscan -H -t ed25519 <ip> >> /root/.ssh/known_hosts
ssh -o BatchMode=yes ansible@<ip> 'hostname; sudo -n true && echo sudo-ok'
For a host on a non-standard port, ssh-keyscan needs -p <port> too.
Clearing the fingerprint is only half the fix
The new machine at that address also won't have your control node's public key in authorized_keys. Expect to go from a host key error straight to Permission denied (publickey), and plan to redo Step 4 for that host.
Also update the inventory entry to reflect what actually lives at that IP now. An inventory that still names the old application is worse than one that fails, because it patches the wrong machine successfully.
Step 7: Write the Patch Playbook
Create the playbook that will actually perform the updates:
---
- name: Linux Patch Management
hosts: linux_servers
become: yes
serial: 2 # Update 2 servers at a time — limits blast radius if something breaks
tasks:
- name: Update package cache and upgrade all packages
apt:
update_cache: yes
upgrade: dist # Full dist-upgrade, handles kernel updates cleanly
autoremove: yes # Remove packages no longer needed
purge: yes # Fully remove old kernel config files
autoclean: yes # Clear out old downloaded package files
register: apt_update_result
- name: Check for Docker
ansible.builtin.stat:
path: /usr/bin/docker
register: docker_bin
- name: Prune unused Docker images
ansible.builtin.command: docker image prune -a -f
register: docker_prune
when: docker_bin.stat.exists
changed_when: "'Total reclaimed space: 0B' not in docker_prune.stdout"
- name: Check if a reboot is required
stat:
path: /var/run/reboot-required
register: reboot_required_file
- name: Reboot the server if required
reboot:
msg: "Ansible triggered reboot after patching"
connect_timeout: 5
reboot_timeout: 300
pre_reboot_delay: 0
post_reboot_delay: 30
when: reboot_required_file.stat.exists
- name: Report update status
debug:
msg: >
Patches applied.
Reboot status: {{ 'Server was rebooted' if reboot_required_file.stat.exists else 'No reboot needed' }}.
What each part does, in plain terms:
serial: 2— Patches two servers at a time instead of all at once, so a bad update doesn't take your whole environment down simultaneously. Adjust this number based on how much risk you're comfortable with.aptmodule — This example targets Debian/Ubuntu. For RHEL-based systems, swap this task to use thednfmodule instead (see the box below).- Docker prune tasks — Every container image update leaves the old image behind. On a host that runs a few Docker services, this accumulates fast enough to fill a 20GB disk in a handful of upgrades. See the note below on placement.
stat+reboot— Only reboots a server if the update actually requires one, avoiding unnecessary downtime.debug— Prints a clear summary at the end of each server's run, useful when reading logs later.
Put the Docker prune before the reboot task, not after
Task order matters here. Placed after the reboot task, the prune waits through a full 300-second reboot cycle on any host that needs one before it runs. Placed before, it runs while the host is definitely up.
Verify the Docker binary path across your fleet before trusting the stat check
The stat check assumes /usr/bin/docker. A snap or manual install puts it elsewhere, and the task silently skips those hosts. Confirm once:
Managing RHEL / Rocky / AlmaLinux instead of Debian/Ubuntu
Replace the apt task with:
- name: Update and upgrade all packages
dnf:
name: "*"
state: latest
update_cache: yes
register: dnf_update_result
needs-restarting command instead of the /var/run/reboot-required file, since that file is Debian-specific:
- name: Check if a reboot is required
command: needs-restarting -r
register: reboot_required
failed_when: false
changed_when: false
[debian_servers] and [rhel_servers]) and write a play for each.
Step 8: Dry-Run Before Trusting It
Before letting this touch real servers, run it in check mode — Ansible will report what would change without actually changing anything:
ansible-playbook -i hosts.ini update_lab.yml --syntax-check
ansible-playbook -i hosts.ini update_lab.yml --check
--syntax-check catches YAML errors instantly and costs nothing. Run it every time you edit a playbook.
Note that --check skips command tasks entirely, so it won't exercise the Docker prune. It still confirms the playbook parses and targets the right hosts.
Review the output carefully. If everything looks sane, do a real run against just one server first:
Pick a host you know the current state of. Once you're confident, run it against the full group:
Step 9: Automate It with Cron
Once you trust the playbook, schedule it to run on its own. Edit root's crontab (or the crontab of whichever user runs Ansible):
Add a line like this to run every Sunday at 3:00 AM:
0 3 * * 0 ANSIBLE_HOST_KEY_CHECKING=False /usr/bin/ansible-playbook -i /root/ansible-patching/hosts.ini /root/ansible-patching/update_lab.yml >> /var/log/ansible-patching.log 2>&1
Breaking down the cron syntax:
| Field | Value | Meaning |
|---|---|---|
| Minute | 0 |
On the hour |
| Hour | 3 |
3 AM |
| Day of month | * |
Every day |
| Month | * |
Every month |
| Day of week | 0 |
Sunday |
What the extra pieces do:
ANSIBLE_HOST_KEY_CHECKING=False— skips the interactive SSH fingerprint prompt, since cron has no terminal to answer it.>> /var/log/ansible-patching.log 2>&1— appends both normal output and errors to a log file you can review later.
ANSIBLE_HOST_KEY_CHECKING=False has a real cost
It disables host key verification entirely for that run. That's why a changed fingerprint can break your interactive SSH while the cron job keeps working silently.
The tradeoff: any machine substituted at a managed IP gets patched with root privileges and no warning. Acceptable in a private lab, worth thinking twice about anywhere else.
Test the cron job manually first
Copy the exact command from the crontab line and run it manually once (without >> if you want to see the output live) to confirm it works outside of cron's stripped-down environment before trusting it to run unattended.
Step 10: Patching Infrastructure Hosts
Hypervisors and public-facing servers need a different playbook, not just a different schedule.
The lab playbook reboots automatically. That's correct for an application container. It is not correct for a hypervisor, where a reboot takes every guest down with it, or for a mail server, where it drops SMTP mid-delivery.
A separate file also means a separate log, so a bad run on infrastructure is obvious rather than buried in 14 hosts of application output.
---
- name: Patch Proxmox hosts
hosts: proxmox
become: yes
serial: 1
tasks:
- name: Update apt cache and upgrade packages
apt:
update_cache: yes
upgrade: dist
register: pve_upgrade
- name: Check if a reboot is required
stat:
path: /var/run/reboot-required
register: pve_reboot
- name: Report reboot status
debug:
msg: >
{{ inventory_hostname }}: patches applied.
{{ 'REBOOT REQUIRED, schedule manually' if pve_reboot.stat.exists
else 'No reboot needed' }}.
- name: Patch VPS hosts
hosts: vps
become: yes
serial: 1
tasks:
- name: Update apt cache and upgrade packages
apt:
update_cache: yes
upgrade: dist
autoremove: yes
register: vps_upgrade
- name: Check for Docker
ansible.builtin.stat:
path: /usr/bin/docker
register: docker_bin
- name: Prune unused Docker images
ansible.builtin.command: docker image prune -a -f
register: docker_prune
when:
- docker_bin.stat.exists
- inventory_hostname != 'mail'
changed_when: "'Total reclaimed space: 0B' not in docker_prune.stdout"
- name: Check if a reboot is required
stat:
path: /var/run/reboot-required
register: vps_reboot
- name: Report reboot status
debug:
msg: >
{{ inventory_hostname }}: patches applied.
{{ 'REBOOT REQUIRED, schedule manually' if vps_reboot.stat.exists
else 'No reboot needed' }}.
What differs from the lab playbook, and why
No reboot task anywhere. Both plays detect and report. You reboot these hosts by hand, when you're awake and can watch them come back.
serial: 1 on Proxmox. Prevents concurrent package operations across your entire virtualization layer, even without reboots involved.
No autoremove or purge on Proxmox. PVE keeps multiple kernels deliberately. autoremove on a Proxmox host has removed running kernels for people, and purge compounds it by deleting configs. The Proxmox-documented upgrade path is apt update && apt dist-upgrade, nothing more.
Mailcow excluded from the Docker prune. Mailcow's own update.sh manages its image lifecycle. docker image prune -a -f there can remove images the updater still references. Adjust the inventory_hostname != condition to match your host's name.
Verify the Proxmox repos before the first run
The playbook's first action is update_cache: yes. If apt update fails, the whole play aborts before patching anything.
The usual cause is an enabled enterprise repo without a subscription, which returns 401.
If you see 401s from enterprise.proxmox.com, disable those repos on the host.
Proxmox 9 / Debian 13 use the deb822 .sources format
Repos moved from /etc/apt/sources.list.d/*.list to *.sources, which use Types:, URIs:, and Suites: fields instead of deb lines. A grep '^deb ' finds nothing on these hosts and makes it look like no repos are configured.
List them properly:
ansible proxmox -i hosts.ini -m shell -a "grep -rhE '^(Types|URIs|Suites|Components|Enabled):' /etc/apt/sources.list.d/*.sources 2>/dev/null"
Disable a repo by adding Enabled: false inside its stanza rather than commenting lines out. Check whether it's already there before adding it — a duplicate Enabled field is harmless but confusing.
Watch for duplicate repos across both formats
A host upgraded from an older Proxmox release often has both pve-no-subscription.list and proxmox.sources defining the same repo. apt update reports it:
Warning: Target Packages (pve-no-subscription/binary-amd64/Packages) is configured
multiple times in .../proxmox.sources:1 and .../pve-no-subscription.list:1
Not harmful, but it doubles index downloads and means future repo changes have to be made in two places. Move the legacy .list files aside rather than deleting them:
mkdir -p /root/apt-backup
mv /etc/apt/sources.list.d/pve-no-subscription.list /root/apt-backup/
mv /etc/apt/sources.list.d/ceph.list /root/apt-backup/
mv /etc/apt/sources.list.d/pve-enterprise.list /root/apt-backup/
apt update
Leave genuine third-party repos alone (Tailscale, Cloudflare, Netbird) — those have no deb822 twin.
Test and schedule
ansible-playbook -i hosts.ini update_infra.yml --syntax-check
ansible-playbook -i hosts.ini update_infra.yml --limit <one-host>
Start with whichever host has the fewest pending updates. A run with nothing to install still exercises the playbook mechanics at zero risk.
Once you've run it manually for a few weeks and trust the reboot-required reporting, schedule it in its own log:
0 4 * * 0 ANSIBLE_HOST_KEY_CHECKING=False /usr/bin/ansible-playbook -i /root/ansible-patching/hosts.ini /root/ansible-patching/update_infra.yml >> /var/log/ansible-infra.log 2>&1
An hour after the lab run, so the two never overlap.
Don't automate this on day one
These are the hosts where an unattended surprise costs the most. Run it by hand for two or three cycles, read the output each time, and confirm the reboot detection behaves before adding the cron line.
Step 11: Review Logs Regularly
After each scheduled run, check the log to confirm everything succeeded:
Search for failures specifically:
At the end of every playbook run, Ansible prints a PLAY RECAP summarizing results per host:
web01 : ok=4 changed=1 unreachable=0 failed=0 skipped=1
web02 : ok=4 changed=1 unreachable=0 failed=0 skipped=1
failed=0andunreachable=0across the board means a clean run.- Any
failedorunreachablecount above 0 means that server needs manual investigation.
A host that fails every week fails silently
Nothing alerts you when one host in a group has been unreachable for a month. The rest of the run succeeds, the recap scrolls past in a log nobody reads, and that host quietly stops getting patched.
A host out of disk is the classic case: it fails with Failed to create temporary directory ... No space left on device, which looks like a permissions problem and gets ignored.
Make the check part of the routine:
Troubleshooting Common Issues
| Symptom | Likely Cause | Fix |
|---|---|---|
UNREACHABLE! Failed to create temporary directory |
/home/ansible exists but is owned by root, so the ansible user can't write to it |
chown -R ansible:ansible /home/ansible && chmod 700 /home/ansible on the target. Recreating the user won't help — the directory already exists |
Same error, but ending in No space left on device |
Target host disk is full | Free space on that host. This is a host problem, not an Ansible problem |
Permission denied (publickey) and the error shows root@<ip> |
The group's :vars block is missing or empty, so Ansible fell back to connecting as root |
Add a [groupname:vars] block with ansible_user=ansible. Verify with ansible-inventory -i hosts.ini --host <name> |
Permission denied (publickey) showing the correct user |
Key not installed, wrong permissions, or AllowUsers excludes ansible |
Re-check Step 4, including the sshd_config.d/ override files |
| Prompted for password despite key setup | Public key not copied, or authorized_keys permissions wrong |
Re-run ssh-copy-id, or verify chmod 600 on authorized_keys and chmod 700 on .ssh |
sudo: a password is required |
Sudoers entry missing or malformed | Recheck /etc/sudoers.d/ansible on the target; run visudo -c to validate |
| Host key verification failure after an IP change | Cached fingerprint belongs to the previous machine | See Handling a Changed Host Key |
ansible-inventory --host shows only ansible_host |
Group vars aren't reaching that host | The :vars header may be present but empty — check that the variable lines below it aren't actually under a different group's header |
| Playbook works manually but not via cron | Environment variables or PATH differ under cron | Use full paths to binaries (/usr/bin/ansible-playbook) and set variables explicitly in the crontab line |
| Proxmox host fails at the first task | Enterprise repo enabled without a subscription, returning 401 on apt update |
Add Enabled: false to the enterprise stanza in /etc/apt/sources.list.d/pve-enterprise.sources |
One server always shows changed=1 even with nothing to update |
Timestamps or metadata changing on every apt cache refresh | Usually harmless — confirm with --check mode that no packages are actually different |
Next Steps
Once basic patch automation is stable, consider building on it:
host_vars/group_vars— override settings per host (e.g. disable the Docker prune on one specific machine) instead of adding conditions to the playbook- Ansible Vault — encrypt any secrets (API tokens, credentials) referenced in playbooks, rather than storing them in plaintext
- Roles — once playbooks grow beyond simple patching, break them into reusable roles for better organization
- Alerting on failures — a task that posts the PLAY RECAP to a notification channel closes the "silently failing host" gap that log review alone doesn't
- A UI/scheduler — tools like AWX (the open-source upstream of Red Hat Ansible Tower) add a web dashboard, scheduling, and role-based access on top of plain Ansible
Quick Reference
# Test connectivity, per group
ansible linux_servers -i hosts.ini -m ping
ansible proxmox -i hosts.ini -m ping
ansible vps -i hosts.ini -m ping
# See the variables Ansible actually resolved for a host
ansible-inventory -i hosts.ini --host web01
# List which hosts a group contains
ansible linux_servers -i hosts.ini --list-hosts
# Validate playbook syntax
ansible-playbook -i hosts.ini update_lab.yml --syntax-check
# Dry run — see what would change
ansible-playbook -i hosts.ini update_lab.yml --check
# Run against one host only
ansible-playbook -i hosts.ini update_lab.yml --limit web01
# Full application run
ansible-playbook -i hosts.ini update_lab.yml
# Infrastructure run (Proxmox + VPS, no auto-reboot)
ansible-playbook -i hosts.ini update_infra.yml
# Run an ad-hoc command on all hosts
ansible linux_servers -i hosts.ini -a "uptime"
# Check Docker presence across the fleet
ansible linux_servers -i hosts.ini -m shell -a "command -v docker || echo NO-DOCKER" --one-line
# Clear a stale host key after an IP reassignment
ssh-keygen -f /root/.ssh/known_hosts -R <ip>
ssh-keyscan -H -t ed25519 <ip> >> /root/.ssh/known_hosts
# View recent log output
tail -100 /var/log/ansible-patching.log
tail -100 /var/log/ansible-infra.log