Skip to content

Instantly share code, notes, and snippets.

@technovangelist
Last active February 26, 2026 00:37
Show Gist options
  • Select an option

  • Save technovangelist/2d89196de66cd6ab3d30c35633d1601e to your computer and use it in GitHub Desktop.

Select an option

Save technovangelist/2d89196de66cd6ab3d30c35633d1601e to your computer and use it in GitHub Desktop.
vps bootstrap
#!/usr/bin/env bash
set -euo pipefail
trap 'rc=$?; echo "FATAL: line $LINENO: $BASH_COMMAND (exit $rc)" >&2; df -h >&2; mount >&2; exit $rc' ERR
SCRIPT_VERSION="2026-02-25-nvim-hard-verify-9"
echo "BOOTSTRAP VERSION: $SCRIPT_VERSION"
if [[ ! -t 0 ]] && [[ ! -e /dev/tty ]]; then
echo "ERROR: interactive mode requires a TTY" >&2
exit 1
fi
############################################################################
# Interactive input (must read from TTY when piped)
###############################################################################
USE_FISH=1 # default
prompt_username() {
local name
while true; do
read -rp "Enter username to create/configure: " name </dev/tty || true
if [[ "$name" =~ ^[a-z_][a-z0-9_-]*$ ]]; then
USER_NAME="$name"
break
else
echo "Invalid username. Use lowercase letters, digits, _ or -."
fi
done
}
prompt_fish() {
local reply
read -rp "Use fish as default shell? [Y/n]: " reply </dev/tty || true
case "${reply:-Y}" in
[Nn]*) USE_FISH=0 ;;
*) USE_FISH=1 ;;
esac
}
prompt_username
prompt_fish
###############################################################################
# Configuration
###############################################################################
HOME_DIR="/home/${USER_NAME}"
LOG_FILE="/var/log/bootstrap-${USER_NAME}.log"
###############################################################################
# Logging setup
###############################################################################
install -d -m 0755 "$(dirname "$LOG_FILE")"
exec > >(tee -a "$LOG_FILE") 2>&1
log() {
printf "\n==> %s\n" "$1"
}
###############################################################################
# Root check
###############################################################################
if [[ "${EUID:-$(id -u)}" -ne 0 ]]; then
echo "ERROR: run as root (sudo bash $0)" >&2
exit 1
fi
log "Logging to $LOG_FILE"
###############################################################################
# System info
###############################################################################
log "System info"
uname -a || true
cat /etc/os-release || true
mount | grep -E " on /home " || true
###############################################################################
# Base tools
###############################################################################
log "Install base tools"
apt-get update -y
apt-get install -y \
ca-certificates curl wget gpg git fish jq fzf ripgrep fd-find
if command -v fdfind >/dev/null; then
ln -sf "$(command -v fdfind)" /usr/local/bin/fd
fi
###############################################################################
# GitHub CLI
###############################################################################
if ! command -v gh >/dev/null 2>&1; then
log "Installing GitHub CLI (gh)"
mkdir -p /etc/apt/keyrings
curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \
| tee /etc/apt/keyrings/githubcli-archive-keyring.gpg >/dev/null
chmod go+r /etc/apt/keyrings/githubcli-archive-keyring.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \
> /etc/apt/sources.list.d/github-cli.list
apt-get update -y
apt-get install -y gh
fi
###############################################################################
# Create /home and user (HARD guarantee)
###############################################################################
log "Creating /home and ${HOME_DIR} unconditionally (HARD guarantee)"
mkdir -p /home
chmod 0755 /home
mkdir -p "$HOME_DIR"
chmod 0755 "$HOME_DIR"
log "Create user if missing, force home to ${HOME_DIR}"
if ! getent passwd "$USER_NAME" >/dev/null; then
useradd -m -d "$HOME_DIR" -s /bin/bash "$USER_NAME" || true
fi
if getent passwd "$USER_NAME" >/dev/null; then
usermod -d "$HOME_DIR" -m "$USER_NAME" || true
chown -R "$USER_NAME:$USER_NAME" "$HOME_DIR" || true
fi
if [[ ! -d "$HOME_DIR" ]]; then
echo "FATAL: $HOME_DIR does not exist after mkdir/usermod."
exit 2
fi
getent passwd "$USER_NAME" || true
ls -ld "$HOME_DIR" || true
###############################################################################
# Sudo + passwordless sudo
###############################################################################
log "Configure sudo access for $USER_NAME"
usermod -aG sudo "$USER_NAME"
install -m 0440 /dev/null "/etc/sudoers.d/90-${USER_NAME}-nopasswd"
echo "${USER_NAME} ALL=(ALL:ALL) NOPASSWD:ALL" \
> "/etc/sudoers.d/90-${USER_NAME}-nopasswd"
visudo -cf "/etc/sudoers.d/90-${USER_NAME}-nopasswd" >/dev/null
###############################################################################
# SSH authorized_keys
###############################################################################
log "Copying root SSH keys"
if [[ -f /root/.ssh/authorized_keys ]]; then
install -d -m 0700 -o "$USER_NAME" -g "$USER_NAME" "${HOME_DIR}/.ssh"
install -m 0600 -o "$USER_NAME" -g "$USER_NAME" \
/root/.ssh/authorized_keys \
"${HOME_DIR}/.ssh/authorized_keys"
fi
###############################################################################
# Fish shell (optional)
###############################################################################
if [[ "$USE_FISH" -eq 1 ]]; then
log "Set fish as default shell"
fish_path="$(command -v fish || true)"
if [[ -n "$fish_path" ]]; then
grep -qx "$fish_path" /etc/shells || echo "$fish_path" >> /etc/shells
chsh -s "$fish_path" "$USER_NAME" || true
fi
else
log "Skipping fish shell setup"
fi
###############################################################################
# SSH hardening
###############################################################################
set_sshd_kv () {
local key="$1" value="$2" cfg="/etc/ssh/sshd_config"
if grep -qiE "^[[:space:]]*${key}[[:space:]]+" "$cfg"; then
sed -i -E "s/^[[:space:]]*${key}[[:space:]]+.*/${key} ${value}/I" "$cfg"
else
echo "${key} ${value}" >> "$cfg"
fi
}
log "Hardening SSH"
cfg="/etc/ssh/sshd_config"
cp -a "$cfg" "${cfg}.bak.$(date +%Y%m%d-%H%M%S)"
set_sshd_kv PasswordAuthentication no
set_sshd_kv KbdInteractiveAuthentication no
set_sshd_kv ChallengeResponseAuthentication no
set_sshd_kv PubkeyAuthentication yes
sshd -t
systemctl reload ssh || systemctl reload sshd
###############################################################################
# Lock root password
###############################################################################
log "Lock root password"
passwd -l root >/dev/null || true
###############################################################################
# Neovim
###############################################################################
log "Installing Neovim"
apt-get install -y ca-certificates curl tar xz-utils
download_with_retry() {
local url="$1" out="$2"
curl -fL \
--retry 8 \
--retry-all-errors \
--retry-delay 2 \
--connect-timeout 10 \
--max-time 300 \
-o "$out" \
"$url"
}
arch="$(dpkg --print-architecture)"
case "$arch" in
amd64) asset="nvim-linux-x86_64.tar.gz" ;;
arm64) asset="nvim-linux-arm64.tar.gz" ;;
*) echo "Unsupported architecture: $arch" >&2; exit 1 ;;
esac
tmp="$(mktemp -d)"
(
set -x # show each command as it runs (so failures are obvious)
cd "$tmp"
url="https://github.com/neovim/neovim/releases/latest/download/${asset}"
download_with_retry "$url" "$asset"
extracted="${asset%.tar.gz}"
echo "DEBUG: extracted=$extracted"
echo "DEBUG: attempting install into /usr/local/lib"
ls -ld /usr/local /usr/local/lib 2>/dev/null || true
df -h /usr/local /tmp || true
if [[ -z "$extracted" || "$extracted" == "." ]]; then
echo "FATAL: could not determine extracted directory name" >&2
tar -tzf "$asset" | head -n 20 >&2
exit 1
fi
# Prefer /usr/local/lib over /opt (avoids provider-specific mount quirks)
mkdir -p /usr/local/lib
rm -rf "/usr/local/lib/$extracted" /usr/local/lib/nvim
tar -C /usr/local/lib -xzf "$asset"
ln -sfn "/usr/local/lib/$extracted" /usr/local/lib/nvim
)
rm -rf "$tmp"
# Put nvim on PATH for root + sudo + non-interactive shells
ln -sfn /usr/local/lib/nvim/bin/nvim /usr/bin/nvim
# Hard verification
ls -l /usr/local/lib/nvim/bin/nvim
command -v nvim >/dev/null || { echo "FATAL: nvim not on PATH after install" >&2; exit 1; }
nvim --version | head -n 2
log "Make vim/vi open Neovim (system-wide)"
ln -sfn /usr/bin/nvim /usr/local/bin/vim
ln -sfn /usr/bin/nvim /usr/local/bin/vi
log "Install build tools for treesitter/mason"
apt-get install -y build-essential clang pkg-config ninja-build
###############################################################################
# LazyVim (root + user)
###############################################################################
install_lazyvim_for_user() {
local user="$1"
local home
home="$(getent passwd "$user" | cut -d: -f6)"
[[ -z "$home" ]] && return
log "LazyVim: installing for $user"
rm -rf "$home/.config/nvim"
install -d -m 0755 -o "$user" -g "$user" "$home/.config"
sudo -u "$user" -H git clone \
https://github.com/LazyVim/starter \
"$home/.config/nvim"
rm -rf "$home/.config/nvim/.git"
chown -R "$user:$user" "$home/.config/nvim"
sudo -u "$user" -H \
env -u XDG_CONFIG_HOME -u XDG_DATA_HOME -u XDG_STATE_HOME -u XDG_CACHE_HOME \
nvim --headless "+Lazy! sync" +qa || true
}
install_lazyvim_for_user root
install_lazyvim_for_user "$USER_NAME"
###############################################################################
# Node.js + npm + user-global npm bin path
###############################################################################
log "Installing Node.js + npm (NodeSource 20.x)"
apt-get install -y ca-certificates curl gpg
# Add NodeSource repo (idempotent)
install -d -m 0755 /etc/apt/keyrings
curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key \
| gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg
echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_22.x nodistro main" \
> /etc/apt/sources.list.d/nodesource.list
apt-get update -y
apt-get install -y nodejs
log "Configuring npm global prefix for ${USER_NAME}"
NPM_PREFIX="${HOME_DIR}/.npm-global"
sudo -u "$USER_NAME" -H mkdir -p "${NPM_PREFIX}/bin"
sudo -u "$USER_NAME" -H npm config set prefix "$NPM_PREFIX"
log "Ensuring npm global bin is on PATH for bash/sh via /etc/profile.d"
cat >/etc/profile.d/npm.sh <<'EOF'
# npm user-global binaries
export PATH="$HOME/.npm-global/bin:$PATH"
EOF
chmod 0644 /etc/profile.d/npm.sh
log "Ensuring npm global bin is on PATH for fish via fish_user_paths"
if command -v fish >/dev/null 2>&1; then
sudo -u "$USER_NAME" -H fish -c '
if not contains "$HOME/.npm-global/bin" $fish_user_paths
fish_add_path -U "$HOME/.npm-global/bin"
end
' || true
fi
log "Verify node/npm in bash + fish for ${USER_NAME}"
sudo -u "$USER_NAME" -H bash -lc 'command -v node && node -v && command -v npm && npm -v'
if command -v fish >/dev/null 2>&1; then
sudo -u "$USER_NAME" -H fish -c 'command -v node; node -v; command -v npm; npm -v' || true
fi
###############################################################################
# Fish aliases (optional)
###############################################################################
if [[ "$USE_FISH" -eq 1 ]]; then
log "Configuring fish aliases"
install -d -m 0755 -o "$USER_NAME" -g "$USER_NAME" \
"${HOME_DIR}/.config/fish"
cat > "${HOME_DIR}/.config/fish/config.fish" <<'FISH'
if command -q nvim
alias vim='nvim'
alias vi='nvim'
end
FISH
chown "$USER_NAME:$USER_NAME" "${HOME_DIR}/.config/fish/config.fish"
fi
###############################################################################
# Shell PATH: npm global installs (~/.npm-global/bin)
###############################################################################
log "Configuring npm global PATH"
# Bash/login shells: ~/.profile
profile="$HOME_DIR/.profile"
install -m 0644 -o "$USER_NAME" -g "$USER_NAME" /dev/null "$profile" 2>/dev/null || true
npm_profile_block='
# npm global installs (user-local)
if [ -d "$HOME/.npm-global/bin" ]; then
export PATH="$HOME/.npm-global/bin:$PATH"
fi
'
if ! grep -qs 'npm-global/bin' "$profile"; then
printf "%s\n" "$npm_profile_block" >> "$profile"
chown "$USER_NAME:$USER_NAME" "$profile"
fi
# Fish: ~/.config/fish/conf.d/npm.fish (only if fish enabled/used)
if [[ "${USE_FISH:-0}" -eq 1 ]]; then
fish_conf_dir="$HOME_DIR/.config/fish/conf.d"
install -d -m 0755 -o "$USER_NAME" -g "$USER_NAME" "$fish_conf_dir"
fish_npm_file="$fish_conf_dir/npm.fish"
if [[ ! -f "$fish_npm_file" ]] || ! grep -qs 'npm-global/bin' "$fish_npm_file"; then
cat > "$fish_npm_file" <<'FISH'
# npm global installs (user-local)
if test -d "$HOME/.npm-global/bin"
fish_add_path --prepend "$HOME/.npm-global/bin"
end
FISH
chown "$USER_NAME:$USER_NAME" "$fish_npm_file"
chmod 0644 "$fish_npm_file"
fi
fi
###############################################################################
# Homebrew prep (Linuxbrew)
###############################################################################
log "Installing Homebrew (Linuxbrew)"
# Install dependencies Homebrew expects on Linux
apt-get install -y build-essential procps file git
###############################################################################
# Final checks
###############################################################################
log "FINAL CHECK"
getent passwd "$USER_NAME" || true
ls -ld "$HOME_DIR" || true
mount | grep -E " on /home " || true
log "Done."
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment