Bash Provisioning Scripts - Server Setup with Shell
Status: Active
Last Updated: 2026-08-14
Category: Infrastructure - Phase 2: Shell Script Automation
Prerequisites: bash-scripting, manual-server-setup
Time: 3-4 hours
Tags: bash, provisioning, shell-scripts, automation, bootstrap, limitations
Summary
Build real server-setup scripts in bash: user creation, package installation, and templated configuration files โ done properly with strict mode, logging, and error handling. Then deliberately push bash to its breaking point so you understand exactly why Phase 3 replaces it with Ansible.
๐ฏ What You'll Learn
By the end of this article, you'll be able to:
- โ Structure a robust provisioning script (strict mode, logging, root checks)
- โ Automate user creation and SSH key deployment safely
- โ Install packages and manage repos from a script
- โ Generate configuration files from templates
- โ Make scripts re-runnable where bash allows it
- โ Identify the failure modes that motivate proper config management
๐งฑ Script Anatomy: Start Every Provisioning Script Like This
#!/usr/bin/env bash
#
# provision-web01.sh - bootstrap an Ubuntu web server
# Usage: sudo ./provision-web01.sh [hostname]
# Assumes: Ubuntu 24.04, run as root, outbound internet access
set -euo pipefail # see "Strict Mode" below
IFS=$'\n\t' # safer word splitting
readonly SCRIPT_NAME="$(basename "$0")"
readonly LOG_FILE="/var/log/provision-${SCRIPT_NAME%.sh}.log"
log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" | tee -a "$LOG_FILE"; }
fail() { log "ERROR: $*" >&2; exit 1; }
# --- Preconditions -------------------------------------------------
[[ $EUID -eq 0 ]] || fail "must run as root"
[[ -f /etc/os-release ]] && grep -q 'VERSION_ID="24' /etc/os-release \
|| fail "this script targets Ubuntu 24.04"
log "=== provisioning started on $(hostname) ==="
What Happens (the flags matter more than you think)
| Flag | Prevents |
|---|---|
set -e |
Continuing after a failed command โ without it, apt install failing silently poisons every later step |
set -u |
Use of unset variables ($HOSTNANE typos become crashes instead of blank hostnames) |
set -o pipefail |
A pipeline like curl ... | grep x | tee f failing only at the last stage |
IFS=$'\n\t' |
Word-splitting surprises when filenames/args contain spaces |
โ ๏ธ
set -ecaveats: commands inifconditions,&&chains, or followed by\|\|won't trigger exit. That's usually what you want (if grep -q ...; then), but know it before debugging.
๐ค Step 1: User Creation Script
create_deploy_user() {
local username="deploy"
if id "$username" &>/dev/null; then
log "user '$username' already exists - skipping creation"
else
useradd -m -s /bin/bash "$username" || fail "useradd failed for $username"
# Set a random password that must be changed / replaced by keys
echo "$username:$(openssl rand -base64 24)" | chpasswd
log "created user '$username'"
fi
# Idempotent group membership (idempotent because -aG is additive-safe)
usermod -aG sudo "$username"
# Sudoers fragment - idempotent write via install + heredoc
install -d -m 0750 /etc/sudoers.d
cat > "/etc/sudoers.d/$username" <<EOF
$username ALL=(ALL) NOPASSWD:ALL
EOF
chmod 0440 "/etc/sudoers.d/$username"
visudo -cf "/etc/sudoers.d/$username" || fail "invalid sudoers syntax!"
}
What Happens:
- The
idcheck makes the function safe on second run โ this is hand-rolled idempotency, remember the effort required visudo -cfvalidates the file before it can lock anyone out of sudochmod 0440satisfies sudo's paranoid permission requirements
Deploy SSH Keys
setup_ssh_keys() {
local username="deploy"
local pubkey="${1:?usage: setup_ssh_keys <pubkey-file>}"
install -d -m 0700 -o "$username" -g "$username" "/home/$username/.ssh"
install -m 0600 -o "$username" -g "$username" "$pubkey" \
"/home/$username/.ssh/authorized_keys"
log "installed key for $username"
}
setup_ssh_hardening() {
local conf=/etc/ssh/sshd_config.d/99-provisioned.conf
cat > "$conf" <<EOF
PermitRootLogin no
PasswordAuthentication no
PubkeyAuthentication yes
EOF
sshd -t || fail "sshd config invalid - NOT restarting"
systemctl restart ssh
log "ssh hardened ($conf)"
}
โ ๏ธ Order matters even in bash: deploy keys BEFORE disabling password auth, or you brick remote access. Scripts encode order dependencies invisibly โ file them mentally under "things Ansible handlers do better."
๐ฆ Step 2: Package Installation Script
install_packages() {
export DEBIAN_FRONTEND=noninteractive # never block on prompts
apt-get update -qq || fail "apt update failed"
local base=(curl ca-certificates gnupg htop jq unzip)
apt-get install -y -qq "${base[@]}" || fail "base packages failed"
log "base packages installed"
}
add_docker_repo() {
install -d -m 0755 /etc/apt/keyrings
if [[ ! -f /etc/apt/keyrings/docker.gpg ]]; then
curl -fsSL https://download.docker.com/linux/ubuntu/gpg \
| gpg --dearmor -o /etc/apt/keyrings/docker.gpg \
|| fail "docker gpg download failed"
chmod a+r /etc/apt/keyrings/docker.gpg
fi
local list=/etc/apt/sources.list.d/docker.list
if [[ ! -f $list ]]; then
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \
https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" \
> "$list"
apt-get update -qq
fi
apt-get install -y -qq docker-ce docker-ce-cli containerd.io \
docker-compose-plugin || fail "docker install failed"
systemctl enable --now docker
log "docker installed and running"
}
What Happens: Note how much of each function is checking whether work is already done โ [[ ! -f ... ]] guards everywhere. You are hand-writing the state tracking that declarative tools get for free.
๐ Step 3: Configuration From Templates
Two viable pure-bash techniques:
Technique A โ envsubst for Variable Substitution
Template file nginx.conf.tmpl:
server {
listen 80;
server_name ${DOMAIN};
root ${APP_ROOT}/public;
access_log /var/log/nginx/${DOMAIN}.access.log;
location / {
proxy_pass http://127.0.0.1:${UPSTREAM_PORT};
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
Render it:
render_template() {
local tmpl="$1" dest="$2"
export DOMAIN="app.example.com" APP_ROOT="/srv/app" UPSTREAM_PORT="3000"
envsubst '${DOMAIN} ${APP_ROOT} ${UPSTREAM_PORT}' < "$tmpl" > "$dest"
nginx -t || fail "rendered nginx config invalid"
}
What Happens: envsubst substitutes listed variables (always pass the allow-list โ otherwise $host, $remote_addr in the nginx directives get clobbered too, the classic bug).
Technique B โ Heredoc With Delimiters
write_netplan() {
local ip="$1" iface="$2"
cat > "/etc/netplan/01-provisioned.yaml" <<EOF
network:
version: 2
ethernets:
$iface:
addresses: [$ip/24]
routes:
- to: default
via: 192.168.1.1
EOF
chmod 600 /etc/netplan/01-provisioned.yaml
netplan apply
}
The problem with both: no change detection. Running again rewrites the file (fine) but there's no notion of "config drifted from template, here's exactly what changed." Compare with Ansible's template module which reports changed per file and can notify handlers to reload services.
๐ Step 4: Making Bash As Re-runnable As Possible
Idempotency patterns worth memorizing:
| Pattern | Non-idempotent โ | Idempotent โ |
|---|---|---|
| Append line | echo x >> file |
grep -qxF 'x' file || echo x >> file |
| Create dir | mkdir /opt/app (fails 2nd time) |
mkdir -p /opt/app |
| Copy config | cp src dst every run |
cmp -s src dst || cp src dst |
| User exists | useradd deploy |
id deploy &>/dev/null || useradd -m deploy |
| Line in file | manual sed chaos | lineinfile-style guard or just use Ansible |
| Enable service | systemctl start |
systemctl is-active --quiet svc || systemctl start svc |
And a generic "run once ever" latch for genuinely one-time operations:
STAMP=/var/lib/provision/.firstboot-done
if [[ ! -f $STAMP ]]; then
do_one_time_thing
mkdir -p "$(dirname "$STAMP")" && touch "$STAMP"
fi
๐ง Where It Cracks (The Point of This Lesson)
Run your script twice. Then imagine maintaining ten variants. These failures are structural, not skill issues:
1. Idempotency Is Hand-Rolled Everywhere
Every function needs its own existence checks. Miss one guard โ second run corrupts state (>> duplicates, useradd aborts mid-script under set -e). In practice ~40% of a mature bash provisioning script is guard clauses.
2. No Error Context
When step 14 of 20 fails at 2 AM, bash gives you an exit code and your own log lines. Compare Ansible's output naming the exact task, host, diff, and suggested docs. Debugging means reading your own code โ during an outage.
3. State Lives Nowhere
Which servers did the script touch? Which version? Was v3 applied to web02 or only v2? Nothing answers except tribal memory. Terraform has state; Ansible has inventories + runs; bash has vibes.
4. Multi-Host Is Out of Scope
for host in web01 web02 web03; do
ssh "$host" 'sudo bash -s' < provision.sh # hope it doesn't die halfway
done
No parallelism control, no per-host failure isolation, no "continue with remaining hosts then report." Building that yourself = rebuilding Ansible badly.
5. Secrets Handling
Keys/passwords end up as script arguments (ps leakage), heredoc literals (committed to Git), or world-readable temp files. Proper secret flows (secrets-in-iac) need tool support.
6. Templating Ceiling Reached Fast
Conditional blocks, loops over lists, defaults, per-host overrides in envsubst/heredocs quickly become unreadable. Jinja2 does all of this natively.
When Bash Is STILL the Right Answer
Be fair to it:
- Control-node bootstrap: installing Ansible itself
- Cloud-Init
runcmd/bootstraps: small, first-boot-only logic - Glue between tools in CI pipelines
- One-shot migrations with human supervision
- Anywhere the dependency cost of Python/Ansible outweighs the benefit
The professional pattern isn't "bash vs IaC tools" โ it's bash at the edges, declarative tools in the middle.
๐งช Exercise
Take your SETUP-NOTES.md from manual-server-setup and write provision.sh covering steps 2โ6. Then:
- Run it on a fresh VM โ record total time vs manual
- Run it again immediately โ count the failures/guards needed
- Add a third server variant ("web02 with no Docker") โ fork or parameterize? Feel the pain
- Delete a package the script installs, re-run, confirm repair works
- Write down every place you typed
|| failโ that list is your Ansible syllabus
๐ ๏ธ Common Issues
| Symptom | Cause | Fix |
|---|---|---|
| Script dies partway, system half-configured | set -e + no resume logic |
Design idempotent functions; rerun safely |
Works locally, fails under sudo sh script.sh |
sh โ bash; flags unsupported |
Shebang + execute directly; check with shellcheck |
| Envsubst ate nginx variables | No allow-list passed | Always enumerate vars explicitly |
Password visible in ps aux |
Secrets as CLI args | Env vars/stdin, or move to vault-based tooling |
| Second run appends duplicate sudoers/hosts entries | Unguarded >> |
Grep-guard pattern or switch to Ansible lineinfile |
| Silent partial success | Ignored exit codes in pipelines | pipefail + explicit || fail |
Run ShellCheck on every provisioning script โ it catches most of these classes statically:
shellcheck provision.sh && echo "clean"
๐ Related
- Previous: why-infrastructure-as-code โ why we're automating at all
- Next: from-scripts-to-config-mgmt โ the formal case for declarative tools
- bash-scripting โ language fundamentals behind these patterns
- ansible-basics โ where every guard clause becomes a free feature
- cloud-init-basics โ first-boot scripting with better primitives
- secrets-in-iac โ fixing the secrets problem properly
๐ Sources & Related
- Google Shell Style Guide
- ShellCheck documentation
- KB: kb/basics/bash-scripting