10 · Capstone — Server Bootstrap Script¶
The capstone for Level 1: a single script that takes a brand-new server from "just booted, root/password access only" to "hardened, running a web server behind a firewall, managed by systemd" — combining every module in this level into one repeatable, idempotent tool.
What it needs to do¶
- Create a non-root admin user and grant sudo (module 2).
- Install the caller's SSH public key for that user (module 1).
- Harden
sshd_config— disable root login and password auth (module 2). - Install and enable
ufw, allowing only SSH/HTTP/HTTPS (module 2). - Update packages and install nginx (module 3).
- Confirm nginx is enabled and running via
systemctl(module 4). - Write a systemd env file and a placeholder app service (modules 4, 7).
- Set up log rotation awareness by confirming journald persistence (module 8).
- Install a cron job (or timer) for nightly cleanup (module 9).
- Print a final summary report.
The script¶
#!/usr/bin/env bash
#
# bootstrap.sh — Level 1 capstone: harden a fresh server and stand up
# a basic web-serving stack, idempotently.
#
# Usage: sudo ./bootstrap.sh <admin_user> <path_to_ssh_pubkey>
# Example: sudo ./bootstrap.sh deploy /home/me/.ssh/id_ed25519.pub
set -euo pipefail
if [ "$EUID" -ne 0 ]; then
echo "Run this script as root (or with sudo)." >&2
exit 1
fi
ADMIN_USER="${1:?Usage: $0 <admin_user> <path_to_ssh_pubkey>}"
PUBKEY_PATH="${2:?Usage: $0 <admin_user> <path_to_ssh_pubkey>}"
if [ ! -f "$PUBKEY_PATH" ]; then
echo "ERROR: public key file not found at $PUBKEY_PATH" >&2
exit 1
fi
log() { echo "[bootstrap] $*"; }
# --- 1 & 2. Create admin user, grant sudo, install SSH key -----------------
if id "$ADMIN_USER" &>/dev/null; then
log "User $ADMIN_USER already exists — skipping creation"
else
log "Creating user $ADMIN_USER"
adduser --disabled-password --gecos "" "$ADMIN_USER"
usermod -aG sudo "$ADMIN_USER"
fi
USER_HOME="/home/$ADMIN_USER"
mkdir -p "$USER_HOME/.ssh"
cat "$PUBKEY_PATH" >> "$USER_HOME/.ssh/authorized_keys"
sort -u -o "$USER_HOME/.ssh/authorized_keys" "$USER_HOME/.ssh/authorized_keys"
chown -R "$ADMIN_USER:$ADMIN_USER" "$USER_HOME/.ssh"
chmod 700 "$USER_HOME/.ssh"
chmod 600 "$USER_HOME/.ssh/authorized_keys"
log "SSH key installed for $ADMIN_USER"
# --- 3. Harden SSH -----------------------------------------------------------
SSHD_CONFIG="/etc/ssh/sshd_config"
log "Hardening $SSHD_CONFIG"
cp "$SSHD_CONFIG" "${SSHD_CONFIG}.bak.$(date +%s)"
set_sshd_option() {
local key="$1" value="$2"
if grep -qE "^\s*#?\s*${key}\s+" "$SSHD_CONFIG"; then
sed -i -E "s|^\s*#?\s*${key}\s+.*|${key} ${value}|" "$SSHD_CONFIG"
else
echo "${key} ${value}" >> "$SSHD_CONFIG"
fi
}
set_sshd_option "PermitRootLogin" "no"
set_sshd_option "PasswordAuthentication" "no"
set_sshd_option "KbdInteractiveAuthentication" "no"
set_sshd_option "MaxAuthTries" "3"
set_sshd_option "AllowUsers" "$ADMIN_USER"
if sshd -t; then
systemctl restart ssh || systemctl restart sshd
log "SSH hardened and restarted"
else
echo "ERROR: sshd_config failed validation — restoring backup" >&2
cp "${SSHD_CONFIG}.bak."* "$SSHD_CONFIG"
exit 1
fi
# --- 4. Firewall -------------------------------------------------------------
log "Configuring ufw"
if ! command -v ufw >/dev/null 2>&1; then
apt-get update -y
apt-get install -y ufw
fi
ufw --force default deny incoming
ufw --force default allow outgoing
ufw allow OpenSSH
ufw allow 80/tcp
ufw allow 443/tcp
ufw --force enable
log "ufw enabled: $(ufw status | head -1)"
# --- 5 & 6. Install nginx ----------------------------------------------------
log "Installing nginx"
apt-get update -y
apt-get install -y nginx
systemctl enable --now nginx
if systemctl is-active --quiet nginx; then
log "nginx is active and enabled"
else
echo "ERROR: nginx failed to start" >&2
systemctl status nginx --no-pager
exit 1
fi
# --- 7. App env + placeholder service ---------------------------------------
log "Writing baseline app config skeleton"
mkdir -p /etc/myapp
if [ ! -f /etc/myapp/myapp.env ]; then
cat > /etc/myapp/myapp.env <<EOF
APP_ENV=production
LOG_LEVEL=info
EOF
fi
chown root:"$ADMIN_USER" /etc/myapp/myapp.env
chmod 640 /etc/myapp/myapp.env
# --- 8. Confirm journald persistence ----------------------------------------
log "Enabling persistent journald storage"
mkdir -p /var/log/journal
systemd-tmpfiles --create --prefix /var/log/journal
systemctl restart systemd-journald
# --- 9. Nightly cleanup timer ------------------------------------------------
log "Installing nightly cleanup timer"
cat > /etc/systemd/system/tmp-cleanup.service <<'EOF'
[Unit]
Description=Delete /tmp files older than 7 days
[Service]
Type=oneshot
ExecStart=/usr/bin/find /tmp -maxdepth 1 -type f -mtime +7 -delete
EOF
cat > /etc/systemd/system/tmp-cleanup.timer <<'EOF'
[Unit]
Description=Run tmp-cleanup daily at 4am
[Timer]
OnCalendar=*-*-* 04:00:00
Persistent=true
[Install]
WantedBy=timers.target
EOF
systemctl daemon-reload
systemctl enable --now tmp-cleanup.timer
# --- 10. Summary --------------------------------------------------------------
echo
echo "======================================================================"
echo " Bootstrap complete"
echo "======================================================================"
echo " Admin user: $ADMIN_USER (sudo, SSH key-only)"
echo " SSH: root login disabled, password auth disabled"
echo " Firewall (ufw): $(ufw status | head -1)"
echo " Web server: nginx — $(systemctl is-active nginx), $(systemctl is-enabled nginx)"
echo " App config: /etc/myapp/myapp.env (640, root:$ADMIN_USER)"
echo " Journal: persistent storage enabled"
echo " Scheduled cleanup: tmp-cleanup.timer — $(systemctl is-enabled tmp-cleanup.timer)"
echo "======================================================================"
echo " Next: log in as $ADMIN_USER from a NEW terminal to confirm access"
echo " BEFORE closing this session."
echo "======================================================================"
Design notes¶
- Idempotency throughout —
id "$ADMIN_USER" &>/dev/nullbefore creating the user,sort -uonauthorized_keysto avoid duplicate key entries, checking for an existing env file before overwriting it. Running the script a second time should be safe and mostly a no-op. - Backup before mutating
sshd_config, and restore it automatically ifsshd -tfails validation — this is the single highest-consequence file in the whole script, and a bad edit could lock out all future SSH access. - Fail loudly, not silently — every risky step (
sshd -t, the nginx active check) has an explicit failure branch that prints diagnostics and exits non-zero, rather than continuing into an inconsistent state. - The final summary block gives you, at a glance, exactly what the script changed — useful both for you right after running it and for whoever reads the deploy log later.
Worked example: running it on a fresh VM¶
scp bootstrap.sh root@203.0.113.10:/root/
scp ~/.ssh/id_ed25519.pub root@203.0.113.10:/root/mykey.pub
ssh root@203.0.113.10
chmod +x bootstrap.sh
./bootstrap.sh deploy ./mykey.pub
Expected tail of output:
======================================================================
Bootstrap complete
======================================================================
Admin user: deploy (sudo, SSH key-only)
SSH: root login disabled, password auth disabled
Firewall (ufw): Status: active
Web server: nginx — active, enabled
App config: /etc/myapp/myapp.env (640, root:deploy)
Journal: persistent storage enabled
Scheduled cleanup: tmp-cleanup.timer — enabled
======================================================================
Next: log in as deploy from a NEW terminal to confirm access
BEFORE closing this session.
======================================================================
At that point — from a new terminal — ssh deploy@203.0.113.10 should
work with your key, sudo whoami should print root, and
curl -I http://203.0.113.10 should return HTTP/1.1 200 OK from nginx.
Only then close the original root session.
How It Actually Works¶
Why the bootstrap script is safe to re-run (real idempotency, not luck).
Each step is written as a check-then-act guard around a state-mutating
command: id deploy &>/dev/null || useradd ... inspects the actual
/etc/passwd database before deciding to act, and appending an SSH key goes
through a grep -qxF "$key" authorized_keys || echo "$key" >> authorized_keys
pattern rather than an unconditional append. This mirrors how configuration
management tools (Ansible, Puppet) work internally — every resource is a
convergence function from current-state to desired-state, not a fire-and-forget
command — the difference here is you're hand-rolling the same guard logic in
shell.
Why closing the root session only after a new login works matters.
Disabling password auth and root login (sshd_config changes) doesn't take
effect until sshd reloads its config — but your existing SSH session is
a live TCP connection to an already-forked sshd child process that keeps
running under the old settings until it exits. If the new access path is
broken (bad key format, wrong permissions), the reload has already locked
out future logins while your current session is the only way in — closing it
before verifying a fresh connection is how servers get permanently locked
out during hardening.
Exercise¶
- Run the full script against a fresh VM, following the worked example.
- Verify each summary line manually: SSH as
deploy, checksudo ufw status verbose,systemctl status nginx,ls -l /etc/myapp/myapp.env, andsystemctl list-timers | grep tmp-cleanup. - Run the script a second time against the same server and confirm it
completes cleanly with no errors and no duplicated
authorized_keysentries — proving it's idempotent. - Extend the script yourself: add a step that installs
fail2ban(module 2) with a minimal SSH jail, and add its status to the final summary block.