The question shows up in almost every consulting call we run: “We have a folder full of Bash scripts — should we switch to Ansible?” The honest answer is: it depends. And not on buzzword bingo, but on the number of servers, the size of the team and how often things change.
This article shows the tipping point at which switching from Bash to Ansible actually saves time — and when it merely produces overhead. Including a real migration example from an SMB project.
The starting point: why Bash is still alive
Bash is not dead. Bash is preinstalled on every Linux system, has no dependencies, can be pushed to any host over SSH and is readable by any admin. For a handful of servers — say ten Proxmox nodes, three TrueNAS systems and two OPNsense firewalls — a cleanly structured Git repository full of shell scripts is often the best solution. No control node, no YAML overhead, no Python dependencies on the targets.
The classic pattern looks like this:
#!/usr/bin/env bash
# update-node.sh -- update a Debian/Proxmox node
set -euo pipefail
HOST="$1"
ssh "root@${HOST}" 'apt-get update && apt-get -y dist-upgrade && apt-get -y autoremove'
ssh "root@${HOST}" 'systemctl reboot'
That works. Until it doesn’t. The breaking point isn’t gradual — it arrives on a Friday afternoon when a colleague runs the same command twice, or when a script without set -e aborts mid-way and leaves the node in an intermediate state.
The tipping point: idempotency becomes mandatory
Somewhere between 30 and 50 managed systems the physics changes. Not because Bash technically couldn’t do the job, but because two properties become critical that Bash simply doesn’t provide:
- Idempotency — being able to run a script arbitrarily often without causing damage.
- Inventory — the mapping “which host gets which configuration” must be declarative, not hidden inside scripts.
A Bash script that calls useradd monitoring fails on the second run. An Ansible task ansible.builtin.user: name=monitoring state=present checks state and simply does nothing on the second run. Sounds trivial, but it’s the difference between “I run this playbook once a month against all hosts” and “I hand-craft state checks into every single script”.
The rule of thumb from our customer projects:
| Environment | Server count | Changes/month | Recommendation |
|---|---|---|---|
| Small | 5-15 | 1-2 | Bash + Git, ssh in a loop |
| Medium | 15-40 | 3-10 | Structured Bash, ad-hoc Ansible if needed |
| Growing | 40-100 | 10+ | Ansible with roles and inventory |
| Large | 100+ | weekly | Ansible + AWX/Semaphore, CI/CD |
The transition from “growing” to “large” is fluid. More important than raw server count is the number of people who touch the systems. Once more than two admins work in parallel, a declarative state is worth its weight in gold.
Migration example: from script to playbook
Take a concrete case from a Linux consulting project: a manufacturing customer ran 42 Ubuntu LTS servers (24.04) on two Proxmox clusters, plus twelve Debian-based application servers. The base configuration — SSH hardening, monitoring agent, backup client, log shipping — lived in eight Bash scripts that were distributed via Ansible… err, via for host in $(cat hosts.txt); do ssh ....
That worked for three years. Then came the switch from rsyslog to journald remote forwarding, and suddenly the scripts had three branches per host depending on age, distribution version and whether a previous colleague had tweaked something manually. Rollout effort grew from one hour to a full day.
The rebuild ran in three steps:
Step 1: extract the inventory. The previous hosts.txt became a structured inventory.yml:
all:
children:
proxmox_hosts:
hosts:
pve01.intern:
pve02.intern:
app_servers:
hosts:
app[01:12].intern:
vars:
backup_target: nas01.intern
db_servers:
hosts:
db[01:04].intern:
vars:
backup_target: nas02.intern
Step 2: one base role instead of eight scripts. The eight shell scripts collapsed into one Ansible role common with tasks for SSH config, user management, monitoring agent and log forwarding. Every task was deliberately written to be idempotent:
- name: SSH -- disable password root login
ansible.builtin.lineinfile:
path: /etc/ssh/sshd_config
regexp: '^#?PermitRootLogin'
line: 'PermitRootLogin prohibit-password'
validate: 'sshd -t -f %s'
notify: restart sshd
- name: Install monitoring agent
ansible.builtin.apt:
name: prometheus-node-exporter
state: present
update_cache: yes
cache_valid_time: 3600
Step 3: dry-run and rollout. ansible-playbook site.yml --check --diff against all 54 hosts revealed which systems drifted — without changing anything. That was the aha moment for the team: for the first time the actual state of every host was visible on one page.
The real rollout then ran in four waves of twelve to fifteen hosts each. Time budget: just under three hours, down from eight previously.
What Ansible does not solve
Ansible is not a silver bullet. Three points we stress in every consulting conversation:
First: Ansible does not replace monitoring. A playbook that finishes successfully says nothing about whether the service is actually answering requests afterwards. ansible.builtin.uri as a post-check is mandatory, but real monitoring and backup with Zabbix, Checkmk or Prometheus is still required alongside.
Second: Ansible is slow. For 200 hosts with a complex role, playbook runtimes of 20 to 40 minutes are normal. strategy: free, serial batching and mitigations like pipelining or fact caching help, but if you need sub-minute deployments, look towards Salt or immutable infrastructure.
Third: YAML is not self-documenting. A 2000-line playbook with Jinja templates, when conditions and handlers can be just as unmaintainable as a Bash jungle. Without a clear role structure, naming conventions and peer review, you have merely shifted the problem.
The pragmatic middle path
For many mid-sized environments we recommend a hybrid strategy: Bash stays for one-off maintenance jobs, emergency scripts and boot automation. Ansible takes over anything that has to happen repeatedly or synchronously across multiple hosts. The control node can be a small VM on the Proxmox cluster, a nightly cron invocation is enough to start with. AWX or Semaphore as a web UI only enter the picture once non-admins need to trigger playbooks.
The important discipline is to route every change through Ansible — even when “just a quick SSH” would be faster. As soon as two paths exist, state drifts.
Conclusion
Bash is not a beginner tool, and Ansible is not a magic wand. The tipping point sits less at a specific server count than at the moment drift appears between “how it should be” and “how it actually is”. Anyone who can no longer audit the state of their servers in ten minutes needs Ansible — regardless of how many systems are involved.
DATAZONE supports small and mid-sized IT teams with the structured build-out of Ansible roles, inventory design and the migration of existing script landscapes. From the first role to a productive AWX instance on Proxmox — get in touch at /en/kontakt/ and we will look at your automation landscape together and find the right moment for the switch.
More on these topics:
More articles
Samba as Active Directory: Replacing a Windows Domain
Replace Windows Server AD with Samba 4 AD-DC: BIND9 DNS integration, replication, group policy limits and coexistence during migration.
systemd-networkd vs. NetworkManager: When to Use Which on Servers
systemd-networkd or NetworkManager on Linux servers? Comparison of the two network stacks with bridge, VLAN and migration examples for Proxmox, KVM and containers.
Proxmox Templates + Cloud-Init: A Practical Workflow
Automate Proxmox templates with Cloud-Init: from cloud image to reproducible VM deployment with a Bash script that rolls out 10 VMs in under 60 seconds.