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:

๐Ÿงฑ 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 -e caveats: commands in if conditions, && 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:

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:

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:

  1. Run it on a fresh VM โ€” record total time vs manual
  2. Run it again immediately โ€” count the failures/guards needed
  3. Add a third server variant ("web02 with no Docker") โ€” fork or parameterize? Feel the pain
  4. Delete a package the script installs, re-run, confirm repair works
  5. 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

๐Ÿ“š Sources & Related

Choose Theme

Your selection is saved locally.

Neural Cacophony
Aperture v2
Flux v1
Mosaic Chaos
Nexus v1
Nexus Zest
Prism v2
Synapse