Using SSH to Build a Minimal, Production-Ready Configuration Management System
Sometimes SSH + Bash is exactly the right tool.
Using SSH to Build a Minimal, Production-Ready Configuration Management System
Sometimes SSH + Bash is exactly the right tool.
DevOps engineers love powerful platforms but experience teaches us that simplicity wins when the problem allows it.
This approach is ideal for:
- Small teams
- Homelabs
- Side projects
- Early-stage startups
- Static or slowly changing infrastructure
- One-off or periodic automation
In this article, we’ll build a minimal but production-safe configuration management system using only SSH and Bash.
No agents. No YAML sprawl. No frameworks to babysit.
Just SSH, Bash and Git.
What Do We Mean by “Configuration Management”?
Traditional CM tools provide:
- Idempotent execution
- Centralized configuration
- Repeatable server state
- Remote execution
- Auditing and history
We can capture most of that value with:
- SSH for transport
- Bash for logic
- Git as the source of truth
- Clear structure and conventions
This works well when:
- You manage fewer than 50 hosts
- Servers are long-lived
- You value transparency over abstraction
- You want zero runtime dependencies
Project Structure
config-mgmt/
├── inventory/
│ ├── prod
│ ├── staging
│ └── dev
├── roles/
│ ├── base/
│ │ ├── install.sh
│ │ └── configure.sh
│ ├── nginx/
│ ├── docker/
│ └── users/
├── scripts/
│ ├── run.sh
│ ├── ssh.sh
│ └── utils.sh
├── files/
│ └── nginx.conf
└── logs/
Conceptually:
- Inventory → hosts per environment
- Roles → reusable configuration logic
- SSH → execution and transport
- Git → desired state and history
Step 1: Inventory (Simple, Safe, Boring)
Each inventory file is one host per line.
inventory/prod
# Production servers
ubuntu@10.0.1.10
ubuntu@10.0.1.11
ubuntu@10.0.1.12
Rules:
- Comments allowed
- Blank lines allowed
- No hidden metadata
- Treat inventory as high-trust input
Step 2: Harden SSH for Automation
Before automating anything, fix SSH properly.
~/.ssh/config
Host *
ServerAliveInterval 30
ServerAliveCountMax 3
StrictHostKeyChecking accept-new
ControlMaster auto
ControlPersist 10m
ControlPath ~/.ssh/cm-%r@%h:%p
BatchMode yes
This gives you:
- Connection reuse ( which is a huge speed boost)
- Stable long-running commands
- Safe host verification
- No hanging password prompts
Step 3: Centralized SSH Wrapper
scripts/ssh.sh
#!/usr/bin/env bash
set -euo pipefail
ssh_exec() {
local host="$1"
local stdin_script="${2:-}"
if [[ -n "$stdin_script" ]]; then
ssh "$host" "bash -s" < "$stdin_script"
else
ssh "$host"
fi
}
Why this matters:
- One place to adjust SSH behavior
- Consistent error handling
- Easier debugging later
Step 4: Idempotent Roles (The Golden Rule)
Every role must be safe to run repeatedly.
Example: Base System Setup
roles/base/install.sh
#!/usr/bin/env bash
set -euo pipefail
# Ensure passwordless sudo
sudo -n true
if ! command -v curl >/dev/null 2>&1; then
sudo apt-get update -y
sudo apt-get install -y curl
fi
if ! command -v vim >/dev/null 2>&1; then
sudo apt-get install -y vim
fi
The rule is; if running a script twice causes damage, the script is wrong.
Step 5: Configuration Files with Validation
Avoid SCP and avoid temp files but never reload a service without validation.
roles/nginx/configure.sh
#!/usr/bin/env bash
set -euo pipefail
sudo -n true
sudo mkdir -p /etc/nginx
sudo tee /etc/nginx/nginx.conf >/dev/null <<'EOF'
user www-data;
worker_processes auto;
events {
worker_connections 1024;
}
http {
include mime.types;
default_type application/octet-stream;
sendfile on;
}
EOF
sudo nginx -t
sudo systemctl reload nginx
This guarantees:
- Atomic replacement
- Syntax validation
- No accidental outages
Step 6: Orchestration Script
scripts/run.sh
#!/usr/bin/env bash
set -euo pipefail
ENV="${1:?Environment required}"
ROLE="${2:?Role required}"
MAX_PARALLEL=5
INVENTORY="inventory/$ENV"
ROLE_DIR="roles/$ROLE"
[[ -f "$INVENTORY" ]] || { echo "Missing inventory: $INVENTORY"; exit 1; }
[[ -d "$ROLE_DIR" ]] || { echo "Missing role: $ROLE"; exit 1; }
failures=()
pids=()
run_host() {
local host="$1"
echo "==== $host ===="
./scripts/ssh.sh "$host" "$ROLE_DIR/install.sh"
./scripts/ssh.sh "$host" "$ROLE_DIR/configure.sh"
echo "Completed on $host"
}
while IFS= read -r host; do
[[ -z "$host" || "$host" =~ ^# ]] && continue
run_host "$host" & pids+=($!)
if (( ${#pids[@]} >= MAX_PARALLEL )); then
wait -n || failures+=("$host")
fi
done < "$INVENTORY"
wait || true
if (( ${#failures[@]} > 0 )); then
echo "Failures: ${failures[*]}"
exit 1
fi
This adds:
- Safe inventory parsing
- Parallel execution
- Per-host isolation
- Failure reporting
- No single flaky host blocking progress
Step 7: Logging and Auditing
Always log executions.
./scripts/run.sh prod nginx \
| tee logs/nginx-prod-$(date +%F-%H%M).log
You now have:
- Execution history
- Debug artifacts
- Change timelines
Step 8: Git Is Your State Management
If used correctly, Git gives you:
- Change tracking
- Peer review
- Rollback via revert
- Tagged snapshots per environment
This is basically GitOps without the ceremony.
But being honest, Git represents desired state not observed runtime state.
If you have hundreds of hosts, complex dependencies or have need for drift detection, this approach is a bad idea. That’s when tools like Ansible or Salt or Puppet earn their cost.
[embed]11 hidden gems of systemd systemd is much more than “start and stop”medium.com
Go from a vanilla Ubuntu server to a production-ready baseline in under 10 minutes with the DevOps Starter Kit — a bundle of 20+ Bash scripts, cron jobs, and Docker templates to speed up your infrastructure setup.
메타데이터
- post_id
- cc8fa7ebe466
- slug
- using-ssh-to-build-a-minimal-production-ready-configuration-management-system-cc8fa7ebe466
- url
- https://medium.com/@obaff/using-ssh-to-build-a-minimal-production-ready-configuration-management-system-cc8fa7ebe466
- canonical_url
- https://medium.com/@obaff/using-ssh-to-build-a-minimal-production-ready-configuration-management-system-cc8fa7ebe466
- author_url
- https://medium.com/@obaff
- status
- ok
- fetched_at
- 2026-07-08 10:57:59