Vivian Voss

SSH + Shell: The Configuration Management You Already Have

ssh unix devops

The Replacement ■ Episode 07

Ansible. Puppet. Chef. Salt. Terraform. All solving the same problem: how do I run the same commands on multiple machines?

Here is a thought: SSH already does that.

The Loop

#!/bin/sh
SERVERS="web1 web2 db1 db2 cache1"

for host in $SERVERS; do
  ssh "$host" 'pkg upgrade -y && service nginx reload'
done

That is configuration management. Five lines. No framework. No YAML. No Python runtime on the target machine. Just a shell, a loop, and the protocol that has been shipping with every Unix system since 1999.

Idempotency

The usual objection arrives quickly. "But what about idempotency?" A fair question. Here is a fair answer:

ssh web1 'grep -q "worker_processes 4" /etc/nginx/nginx.conf || \
  sed -i "" "s/worker_processes.*/worker_processes 4;/" /etc/nginx/nginx.conf && \
  service nginx reload'

Only changes if needed. Only reloads if changed. Idempotent. No module system required. The tools are grep, sed, and a conditional. Available since the 1970s.

State Management

#!/bin/sh
# deploy.sh
set -e

for host in $SERVERS; do
  echo "=== $host ==="
  scp configs/nginx.conf "$host":/etc/nginx/nginx.conf
  scp configs/rc.conf "$host":/etc/rc.conf
  ssh "$host" 'service nginx configtest && service nginx reload'
done

The state lives in your git repository. The configs/ folder. Version controlled. Diffable. Auditable. No inventory plugin. No state file. No backend configuration. Git is the backend.

Secrets

# Encrypt with age (single binary, no GPG complexity)
age -r age1ql3z7hjy... secret.txt > secret.txt.age

# Decrypt on target
scp secret.txt.age "$host":~
ssh "$host" 'age -d -i ~/.age/key.txt secret.txt.age > /etc/secret.txt'

age, written by Filippo Valsorda in 2019. One binary. No key servers. No web of trust. No GPG configuration odyssey. Or use ssh-agent forwarding. The tooling exists.

Parallel Execution

#!/bin/sh
for host in $SERVERS; do
  ssh "$host" 'pkg upgrade -y' &
done
wait
echo "All done."

Background jobs. The ampersand. Available since 1971. No task runner. No worker pool library. The shell has had concurrency longer than most programming languages have existed.

Configuration Management Stacks Ansible Python on every target YAML playbooks Jinja2 templates Module ecosystem Galaxy dependencies Inventory plugins SSH + Shell /bin/sh ssh + scp git age (optional) runs everywhere no runtime on target

What Ansible Actually Gives You

YAML syntax (a debatable improvement over shell). Inventory files (a text file with hostnames). Modules (shell commands with extra steps). Playbooks (shell scripts with extra steps). Galaxy (dependencies with extra steps).

What you give Ansible: Python on every target machine. A domain-specific language to learn. YAML indentation errors to debug. Tasks that run every time despite being marked "changed." Module version compatibility issues between releases.

The Honest Caveat

"But we have 500 servers across 3 continents!" Then you need Ansible. Possibly Terraform. Maybe even Kubernetes. Terribly sorry, this article is not for you.

But if you are managing five servers? Ten? A small startup? A personal lab? You do not need configuration management software. You need ssh, a for loop, and configs in git.

The Complete Stack infrastructure/ servers.txt web1 web2 db1 db2 deploy.sh the main script configs/ nginx.conf rc.conf pf.conf

The tools are not complicated. We just convinced ourselves they must be.