Skip to content

Instantly share code, notes, and snippets.

@dragonworx
Last active July 29, 2026 23:13
Show Gist options
  • Select an option

  • Save dragonworx/1ef59bb118bf018d876d4a0cab78e67d to your computer and use it in GitHub Desktop.

Select an option

Save dragonworx/1ef59bb118bf018d876d4a0cab78e67d to your computer and use it in GitHub Desktop.
#!/usr/bin/env bash
#
# ubuntu-bootstrap.sh — stage 1 of 2. Small enough to read before you run it.
#
# Fresh Ubuntu 26.04 box, logged in as root:
#
# curl -fsSL "https://gist.githubusercontent.com/dragonworx/1ef59bb118bf018d876d4a0cab78e67d/raw/ubuntu-bootstrap.sh?cb=$(date +%s)" | bash
#
# Job: collect what stage 2 needs, patch the box, install the packages needed
# to fetch stage 2, then hand off. Nothing else.
#
# Everything is asked for UP FRONT, before any change is made, so a missing
# answer costs you nothing. Neither gist contains your identity or your keys.
#
set -euo pipefail
# ─────────────────────────────────────────────────────────────────────────────
# Stage 2 location
# ─────────────────────────────────────────────────────────────────────────────
STAGE2_URL="${STAGE2_URL:-https://gist.githubusercontent.com/dragonworx/0953c926ddcb0aae054e5426f19213e7/raw/ubuntu-provision.sh}"
# This raw URL always serves the newest revision, which is what you want:
# edit the gist, the next box picks it up. To freeze a build instead, insert a
# 40-char revision SHA: .../raw/<sha>/ubuntu-provision.sh
STAGE2_SHA256="${STAGE2_SHA256:-}"
STAGE2_PATH="/root/ubuntu-provision.sh"
# Passed through to stage 2. Rootless Docker needs unprivileged user
# namespaces, which Ubuntu blocks by default; true lifts that restriction
# system-wide if the targeted AppArmor exemption fails.
PERMIT_USERNS="${PERMIT_USERNS:-true}"
C_OK=$'\033[32m'; C_ERR=$'\033[31m'; C_DIM=$'\033[2m'; C_HDR=$'\033[1;36m'; C_OFF=$'\033[0m'
log() { printf '%s\n' "${C_HDR}==>${C_OFF} $*"; }
ok() { printf '%s\n' " ${C_OK}OK${C_OFF} $*"; }
die() { printf '%s\n' "${C_ERR}FATAL:${C_OFF} $*" >&2; exit 1; }
# ─────────────────────────────────────────────────────────────────────────────
# 0. Preflight
# ─────────────────────────────────────────────────────────────────────────────
[[ $EUID -eq 0 ]] || die "run this as root"
[[ -r /etc/os-release ]] || die "cannot read /etc/os-release"
. /etc/os-release
[[ "${ID:-}" == "ubuntu" ]] || die "this targets Ubuntu, found: ${ID:-unknown}"
log "Ubuntu ${VERSION_ID:-?} (${VERSION_CODENAME:-?})"
# Prompts read from /dev/tty, not stdin. When this script arrives via
# `curl | bash`, stdin IS the script — a bare `read` would swallow the next
# line of code instead of waiting for you.
HAVE_TTY=false
if [[ -e /dev/tty ]] && : >/dev/tty 2>/dev/null; then HAVE_TTY=true; fi
valid_pubkey() {
local k="$1"
if command -v ssh-keygen >/dev/null 2>&1; then
printf '%s\n' "$k" | ssh-keygen -lf - >/dev/null 2>&1
else
[[ "$k" =~ ^(ssh-ed25519|ssh-rsa|ecdsa-sha2-[a-z0-9]+|sk-[a-z0-9@.-]+)[[:space:]]+[A-Za-z0-9+/=]{20,} ]]
fi
}
# ─────────────────────────────────────────────────────────────────────────────
# 1. The SSH key that will get you back in
# ─────────────────────────────────────────────────────────────────────────────
# Asked first, because it is the only answer whose absence can lock you out.
# Stage 2 disables password authentication; dev must already be reachable by
# key before that happens.
ADMIN_PUBKEY="${ADMIN_PUBKEY:-}"
KEY_SOURCE=""
if [[ -n "$ADMIN_PUBKEY" ]]; then
KEY_SOURCE="supplied via ADMIN_PUBKEY"
elif [[ -r /root/.ssh/authorized_keys ]] \
&& grep -qE '^(ssh-|ecdsa-|sk-)' /root/.ssh/authorized_keys; then
ADMIN_PUBKEY="$(grep -m1 -E '^(ssh-|ecdsa-|sk-)' /root/.ssh/authorized_keys)"
KEY_SOURCE="inherited from root"
elif $HAVE_TTY; then
cat > /dev/tty <<'PROMPT'
You are logged in as root by password, so there is no key to inherit.
Stage 2 turns password login off, so dev needs a public key now.
On the machine you want to connect FROM:
cat ~/.ssh/id_ed25519.pub
ssh-keygen -t ed25519 -C "me@laptop" # if you have no key yet
Paste the PUBLIC key below — one line, starting ssh-ed25519 or ssh-rsa.
PROMPT
while :; do
printf '> ' > /dev/tty
IFS= read -r ADMIN_PUBKEY < /dev/tty
ADMIN_PUBKEY="$(printf '%s' "$ADMIN_PUBKEY" | tr -d '\r' | sed 's/^[[:space:]]*//; s/[[:space:]]*$//')"
if [[ "$ADMIN_PUBKEY" == *"PRIVATE KEY"* ]]; then
echo " That is a PRIVATE key — never put one on a server." > /dev/tty
echo " Run 'ssh-keygen -y -f <keyfile>' and paste that one line." > /dev/tty
continue
fi
if valid_pubkey "$ADMIN_PUBKEY"; then KEY_SOURCE="pasted at bootstrap"; break; fi
echo " Not a valid public key. Expect: '<type> <base64> <comment>'" > /dev/tty
echo " If you copied from a terminal, line wrapping may have broken it." > /dev/tty
done
else
die "no SSH public key, and no terminal to ask for one.
Re-run with: ADMIN_PUBKEY=\"ssh-ed25519 AAAA... me@laptop\" bash \$0"
fi
valid_pubkey "$ADMIN_PUBKEY" || die "ADMIN_PUBKEY is not a valid public key"
if command -v ssh-keygen >/dev/null 2>&1; then
ok "key $(printf '%s\n' "$ADMIN_PUBKEY" | ssh-keygen -lf - | awk '{print $2}') (${KEY_SOURCE})"
else
ok "key accepted (${KEY_SOURCE})"
fi
# ─────────────────────────────────────────────────────────────────────────────
# 1b. Server name
# ─────────────────────────────────────────────────────────────────────────────
# Cosmetic, but fiddly to change later because three separate things have to
# agree: the kernel hostname, /etc/hosts, and cloud-init's preserve_hostname.
# Miss the /etc/hosts line and every sudo stalls for seconds on a failed
# reverse lookup, which is easy to misdiagnose as something else entirely.
HOSTNAME_SET="${HOSTNAME_SET:-}"
HOSTNAME_RE='^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$'
if [[ -z "$HOSTNAME_SET" ]] && $HAVE_TTY; then
echo > /dev/tty
echo "Server name — currently '$(hostname -s)'. Blank to leave it alone." > /dev/tty
echo " Lowercase letters, digits and hyphens. Max 63 characters." > /dev/tty
echo > /dev/tty
while :; do
printf ' Server name : ' > /dev/tty
IFS= read -r HOSTNAME_SET < /dev/tty
HOSTNAME_SET="$(printf '%s' "$HOSTNAME_SET" | tr -d '\r' | tr '[:upper:]' '[:lower:]' \
| sed 's/^[[:space:]]*//; s/[[:space:]]*$//')"
[[ -z "$HOSTNAME_SET" ]] && break
[[ "$HOSTNAME_SET" =~ $HOSTNAME_RE ]] && break
echo " Not a valid hostname — letters, digits, hyphens; no leading or" > /dev/tty
echo " trailing hyphen, no dots, no underscores." > /dev/tty
done
fi
if [[ -n "$HOSTNAME_SET" ]]; then
[[ "$HOSTNAME_SET" =~ $HOSTNAME_RE ]] || die "HOSTNAME_SET is not a valid hostname: ${HOSTNAME_SET}"
ok "server name ${HOSTNAME_SET}"
else
ok "server name unchanged ($(hostname -s))"
fi
# ─────────────────────────────────────────────────────────────────────────────
# 2. Git identity
# ─────────────────────────────────────────────────────────────────────────────
GIT_NAME="${GIT_NAME:-}"
GIT_EMAIL="${GIT_EMAIL:-}"
EMAIL_RE='^[^[:space:]@]+@[^[:space:]@]+\.[^[:space:]@]+$'
if [[ -z "$GIT_NAME" || -z "$GIT_EMAIL" ]]; then
$HAVE_TTY || die "no terminal — pass the identity instead:
GIT_NAME=\"Your Name\" GIT_EMAIL=\"you@example.com\" bash \$0"
echo > /dev/tty
echo "Git identity — used for commits made by the dev user on this box." > /dev/tty
echo > /dev/tty
while [[ -z "$GIT_NAME" ]]; do
printf ' Full name : ' > /dev/tty
read -r GIT_NAME < /dev/tty
done
while [[ ! "$GIT_EMAIL" =~ $EMAIL_RE ]]; do
printf ' Email : ' > /dev/tty
read -r GIT_EMAIL < /dev/tty
[[ "$GIT_EMAIL" =~ $EMAIL_RE ]] || \
echo " ...that doesn't look like an email address." > /dev/tty
done
fi
ok "identity ${GIT_NAME} <${GIT_EMAIL}>"
# ─────────────────────────────────────────────────────────────────────────────
# 2b. Sudo password
# ─────────────────────────────────────────────────────────────────────────────
# This is for sudo only — SSH is key-only, so it can never be used to log in.
# You will type it often, because credential caching is disabled by default, so
# pick something you can actually type on a phone keyboard.
DEV_PASSWORD="${DEV_PASSWORD:-}"
PASSWORD_SOURCE="supplied via DEV_PASSWORD"
if [[ -z "$DEV_PASSWORD" ]] && $HAVE_TTY; then
cat > /dev/tty <<'PROMPT'
Sudo password for the dev user.
· Used for sudo only. SSH is key-only, so this cannot log anyone in.
· You will type it often — no credential caching by default.
· Leave blank to have a random 24-character one generated instead.
PROMPT
while :; do
printf ' Password (hidden) : ' > /dev/tty
IFS= read -rs DEV_PASSWORD < /dev/tty; echo > /dev/tty
if [[ -z "$DEV_PASSWORD" ]]; then
PASSWORD_SOURCE="auto-generated, printed at the end"; break
fi
if (( ${#DEV_PASSWORD} < 12 )); then
echo " Too short — 12 characters minimum." > /dev/tty
DEV_PASSWORD=""; continue
fi
printf ' Confirm : ' > /dev/tty
IFS= read -rs CONFIRM < /dev/tty; echo > /dev/tty
if [[ "$DEV_PASSWORD" == "$CONFIRM" ]]; then
PASSWORD_SOURCE="set by you (${#DEV_PASSWORD} characters)"; break
fi
echo " They don't match — try again." > /dev/tty
DEV_PASSWORD=""
done
unset CONFIRM
elif [[ -z "$DEV_PASSWORD" ]]; then
PASSWORD_SOURCE="auto-generated, printed at the end"
fi
ok "sudo password ${PASSWORD_SOURCE}"
# ─────────────────────────────────────────────────────────────────────────────
# 3. Confirm, then start changing the machine
# ─────────────────────────────────────────────────────────────────────────────
if $HAVE_TTY; then
cat > /dev/tty <<EOF
────────────────────────────────────────────────────────────────────────────
SSH key ${KEY_SOURCE}
Server name ${HOSTNAME_SET:-unchanged}
Git identity ${GIT_NAME} <${GIT_EMAIL}>
Sudo password ${PASSWORD_SOURCE}
Rootless PERMIT_USERNS=${PERMIT_USERNS}
Nothing has changed yet. Past this point stage 2 creates the dev user,
disables password authentication and raises the firewall.
────────────────────────────────────────────────────────────────────────────
EOF
printf ' Proceed? [y/N] ' > /dev/tty
read -r reply < /dev/tty
[[ "$reply" =~ ^[Yy]$ ]] || die "aborted — nothing has been changed"
fi
# ─────────────────────────────────────────────────────────────────────────────
# 4. Patch the box
# ─────────────────────────────────────────────────────────────────────────────
export DEBIAN_FRONTEND=noninteractive
APT_OPTS=(-y -o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confold)
log "Applying all pending security and package updates"
apt-get update -y
apt-get "${APT_OPTS[@]}" full-upgrade
apt-get "${APT_OPTS[@]}" autoremove --purge
ok "system patched"
# Only what's needed to fetch and verify stage 2. Everything else — git
# included — is stage 2's job. A gist is fetched over HTTPS, not cloned.
log "Installing fetch prerequisites"
apt-get "${APT_OPTS[@]}" install curl ca-certificates openssh-client
ok "curl + ca-certificates + openssh-client"
# ─────────────────────────────────────────────────────────────────────────────
# 5. Fetch stage 2
# ─────────────────────────────────────────────────────────────────────────────
log "Fetching ubuntu-provision.sh"
# ?cb= is a cache-buster. GitHub serves raw gist content via a CDN, so for a
# minute or two after an edit the plain URL can still return the old revision,
# which looks exactly like "my fix didn't work".
curl -fsSL --proto '=https' --tlsv1.2 --retry 3 --max-time 60 \
"${STAGE2_URL}?cb=$(date +%s)$$" -o "$STAGE2_PATH" \
|| die "could not fetch stage 2 from ${STAGE2_URL}"
[[ -s "$STAGE2_PATH" ]] || die "stage 2 downloaded empty"
head -1 "$STAGE2_PATH" | grep -q '^#!' || die "stage 2 has no shebang.
Either the gist still holds a placeholder, or the URL returned an error
page. Check: curl -fsSL \"${STAGE2_URL}\" | head"
if [[ -n "$STAGE2_SHA256" ]]; then
GOT="$(sha256sum "$STAGE2_PATH" | awk '{print $1}')"
[[ "$GOT" == "$STAGE2_SHA256" ]] || die "checksum mismatch
expected ${STAGE2_SHA256}
got ${GOT}"
ok "sha256 verified"
else
ok "downloaded $(wc -l < "$STAGE2_PATH") lines"
fi
bash -n "$STAGE2_PATH" || die "stage 2 failed a syntax check — download is corrupt"
chmod 700 "$STAGE2_PATH"
# ─────────────────────────────────────────────────────────────────────────────
# 6. Hand off
# ─────────────────────────────────────────────────────────────────────────────
cat <<EOF
────────────────────────────────────────────────────────────────────────────
Stage 1 done. Stage 2 takes 10-15 minutes.
KEEP THIS SESSION OPEN until 'ssh dev@<host>' works from a second terminal.
Password login stops partway through, and dev will then be reachable only by
the key you supplied above.
Output is logged to /root/provision.log
────────────────────────────────────────────────────────────────────────────
EOF
log "Starting stage 2"
# Exported, not passed as arguments to `env`: a command line is visible in
# `ps` to every user on the machine, while an exported variable lives in
# /proc/PID/environ and is readable only by the process owner. That matters
# here because one of these is a password.
export ADMIN_PUBKEY GIT_NAME GIT_EMAIL PERMIT_USERNS DEV_PASSWORD HOSTNAME_SET
export SKIP_SYSTEM_UPGRADE=true
exec bash "$STAGE2_PATH" 2>&1 | tee /root/provision.log
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment