340-line idempotent VM setup — Zsh, Node.js, Docker, 35+ tools, dotfiles checkout, all in one command.
#!/usr/bin/env bash
# =============================================================================
# Ubuntu 24.04 LTS VM Bootstrap Script
#
# First-time setup (bare git repo):
# gh auth login
# git clone --bare https://github.com/starmorph/dotfiles $HOME/.dotfiles
# git --git-dir=$HOME/.dotfiles --work-tree=$HOME checkout main -f
# ~/.bootstrap.sh
#
# Re-run (already cloned):
# ~/.bootstrap.sh
# =============================================================================
set -euo pipefail
LOG_PREFIX="\033[1;34m==>\033[0m"
log() { echo -e "${LOG_PREFIX} $1"; }
warn() { echo -e "\033[1;33m==> WARNING:\033[0m $1"; }
err() { echo -e "\033[1;31m==> ERROR:\033[0m $1" >&2; }
# ---------------------------------------------------------------------------
# 0. Pre-flight checks
# ---------------------------------------------------------------------------
if [[ "$(uname)" != "Linux" ]]; then
err "This script is designed for Linux (Ubuntu). Detected: $(uname)"
exit 1
fi
if ! command -v apt-get &>/dev/null; then
err "apt-get not found. This script requires a Debian/Ubuntu system."
exit 1
fi
log "Starting bootstrap..."
log "Detected: $(lsb_release -ds 2>/dev/null || cat /etc/os-release | grep PRETTY_NAME | cut -d= -f2)"
# ---------------------------------------------------------------------------
# 1. System update
# ---------------------------------------------------------------------------
log "Updating system packages..."
# Suppress needrestart interactive prompts during apt operations
export NEEDRESTART_MODE=a
export DEBIAN_FRONTEND=noninteractive
sudo apt-get update -y
sudo apt-get upgrade -y
# ---------------------------------------------------------------------------
# 2. Core packages
# ---------------------------------------------------------------------------
log "Installing core packages..."
APT_PACKAGES=(
# Shell
zsh
# Dev essentials
git
curl
wget
unzip
build-essential
software-properties-common
gpg # needed for adding external repos
# Terminal tools
tmux
ripgrep
fd-find # binary is fdfind, aliased in .zshrc.linux
fzf
jq
htop
tree
bat # binary is batcat on Ubuntu, aliased in .zshrc.linux
eza # modern ls replacement (icons + grid)
stow
tig
mc # midnight-commander
xclip # clipboard support (pbcopy/pbpaste aliases)
# Media / processing
ffmpeg
imagemagick
# Networking
nmap
net-tools # ifconfig, netstat
# Security
fail2ban # auto-blocks SSH brute force
ufw # firewall
# System monitoring & admin
ncdu # interactive disk usage analyzer
btop # all-in-one system monitor (CPU, RAM, disk, net)
lsof
sysstat # sar, iostat, mpstat
iotop # I/O monitor
iftop # network bandwidth per-connection
nethogs # network bandwidth per-process
# Log viewing
lnav # TUI log viewer with SQL queries
multitail # tail multiple logs in split panes
# File navigation
ranger # vim-style terminal file manager
# Python
python3
python3-pip
python3-venv
)
sudo apt-get install -y "${APT_PACKAGES[@]}"
# Neovim (PPA — apt version is too old for LazyVim)
if ! nvim --version 2>/dev/null | head -1 | grep -qE 'v0\.(9|[1-9][0-9])'; then
log "Installing Neovim from PPA (LazyVim requires >= 0.8.0)..."
if sudo add-apt-repository ppa:neovim-ppa/unstable -y \
&& sudo apt-get update -y \
&& sudo apt-get install -y neovim; then
log "Neovim $(nvim --version | head -1) installed"
else
warn "Failed to install Neovim from PPA — skipping"
fi
fi
# delta (git pager) — not in default repos
if ! command -v delta &>/dev/null; then
log "Installing git-delta..."
DELTA_VERSION="0.18.2"
ARCH=$(dpkg --print-architecture)
DELTA_DEB="/tmp/git-delta_${DELTA_VERSION}_${ARCH}.deb"
if wget -qO "$DELTA_DEB" "https://github.com/dandavison/delta/releases/download/${DELTA_VERSION}/git-delta_${DELTA_VERSION}_${ARCH}.deb" \
&& (sudo dpkg -i "$DELTA_DEB" || sudo apt-get install -f -y); then
log "git-delta installed"
else
warn "Failed to install git-delta — skipping (git diff will still work)"
fi
rm -f "$DELTA_DEB"
fi
# ---------------------------------------------------------------------------
# 3. Ghostty terminfo (so SSHing from Ghostty on macOS works correctly)
# ---------------------------------------------------------------------------
if ! infocmp xterm-ghostty &>/dev/null 2>&1; then
log "Ghostty terminfo not found — install it from your Mac with:"
log " infocmp -x xterm-ghostty | ssh USER@THIS-VM 'tic -x -'"
log "Or enable auto-install in Ghostty config: shell-integration-features = ssh-terminfo"
fi
# ---------------------------------------------------------------------------
# 4. Zsh + Oh My Zsh + Powerlevel10k
# ---------------------------------------------------------------------------
log "Setting up zsh..."
# Set zsh as default shell
if [[ "$SHELL" != "$(which zsh)" ]]; then
sudo chsh -s "$(which zsh)" "$USER"
log "Default shell changed to zsh (takes effect on next login)"
fi
# Oh My Zsh
if [[ ! -d "$HOME/.oh-my-zsh" ]]; then
log "Installing Oh My Zsh..."
sh -c "$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)" "" --unattended
fi
# Powerlevel10k
if [[ ! -d "$HOME/powerlevel10k" ]]; then
log "Installing Powerlevel10k..."
git clone --depth=1 https://github.com/romkatv/powerlevel10k.git ~/powerlevel10k
fi
# ---------------------------------------------------------------------------
# 5. Node.js via fnm + pnpm
# ---------------------------------------------------------------------------
log "Setting up Node.js..."
if ! command -v fnm &>/dev/null; then
log "Installing fnm..."
if ! curl -fsSL https://fnm.vercel.app/install | bash -s -- --skip-shell; then
warn "Failed to install fnm — Node.js setup will be skipped"
fi
fi
# Ensure fnm is on PATH for this session
export PATH="$HOME/.local/share/fnm:$PATH"
eval "$(fnm env)" 2>/dev/null || true
# Remove system Node if installed via apt (ancient v12) — fnm manages Node instead
if dpkg -l nodejs &>/dev/null 2>&1; then
warn "Removing system Node.js (apt) — fnm will manage Node versions instead"
sudo apt-get remove -y nodejs npm 2>/dev/null || true
sudo apt-get autoremove -y
hash -r # clear shell's command cache
fi
if ! fnm ls 2>/dev/null | grep -q "lts-latest\|v[0-9]"; then
log "Installing Node.js LTS via fnm..."
fnm install --lts
fnm default lts-latest
fi
# Re-evaluate after node install
export PATH="$HOME/.local/share/fnm:$PATH"
eval "$(fnm env)" 2>/dev/null || true
if ! command -v pnpm &>/dev/null; then
log "Installing pnpm..."
npm install -g pnpm
fi
# ---------------------------------------------------------------------------
# 6. GitHub CLI (gh)
# ---------------------------------------------------------------------------
if ! command -v gh &>/dev/null; then
log "Installing GitHub CLI..."
sudo mkdir -p -m 755 /etc/apt/keyrings
wget -qO- https://cli.github.com/packages/githubcli-archive-keyring.gpg | sudo tee /etc/apt/keyrings/githubcli-archive-keyring.gpg > /dev/null
sudo 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" | sudo tee /etc/apt/sources.list.d/github-cli.list > /dev/null
sudo apt-get update -y
sudo apt-get install -y gh
fi
# ---------------------------------------------------------------------------
# 7. Claude Code
# ---------------------------------------------------------------------------
if ! command -v claude &>/dev/null; then
log "Installing Claude Code..."
npm install -g @anthropic-ai/claude-code
fi
# ---------------------------------------------------------------------------
# 8. zoxide (smart cd — not in apt)
# ---------------------------------------------------------------------------
if ! command -v zoxide &>/dev/null; then
log "Installing zoxide..."
if curl -sSfL https://raw.githubusercontent.com/ajeetdsouza/zoxide/main/install.sh | sh; then
log "zoxide installed"
else
warn "Failed to install zoxide — skipping (cd will still work)"
fi
fi
# ---------------------------------------------------------------------------
# 9. Dotfiles (bare git repo at ~/.dotfiles)
# ---------------------------------------------------------------------------
alias dotfiles='git --git-dir=$HOME/.dotfiles --work-tree=$HOME'
if [[ -d "$HOME/.dotfiles" ]]; then
log "Dotfiles bare repo found at ~/.dotfiles"
# Configure bare repo
dotfiles config --local status.showUntrackedFiles no
# Checkout files (force — bootstrap already ran clone + checkout above)
dotfiles checkout main -f 2>/dev/null || true
# SSH config — strip macOS-only UseKeychain directive
mkdir -p "$HOME/.ssh"
chmod 700 "$HOME/.ssh"
if [[ -f "$HOME/.ssh/config" ]]; then
sed -i '/UseKeychain/d' "$HOME/.ssh/config"
chmod 600 "$HOME/.ssh/config"
fi
# Use Linux-specific zshrc
cp "$HOME/.zshrc.linux" "$HOME/.zshrc"
else
warn "Bare repo not found at ~/.dotfiles — skipping dotfiles checkout"
warn "Clone it first: git clone --bare https://github.com/starmorph/dotfiles \$HOME/.dotfiles"
fi
# Secrets file
if [[ ! -f "$HOME/.zsh_secrets" ]]; then
log "Creating empty ~/.zsh_secrets — add your API keys here"
touch "$HOME/.zsh_secrets"
chmod 600 "$HOME/.zsh_secrets"
fi
# ---------------------------------------------------------------------------
# 10. Firewall (basic lockdown)
# ---------------------------------------------------------------------------
log "Configuring firewall..."
sudo ufw allow OpenSSH
sudo ufw --force enable
log "Firewall enabled. SSH allowed. Add more rules with: sudo ufw allow <port>/tcp"
# ---------------------------------------------------------------------------
# 11. Fail2ban (SSH brute-force protection)
# ---------------------------------------------------------------------------
log "Enabling fail2ban..."
sudo systemctl enable fail2ban
sudo systemctl start fail2ban
# ---------------------------------------------------------------------------
# 12. Generate SSH key if none exists
# ---------------------------------------------------------------------------
if [[ ! -f "$HOME/.ssh/id_ed25519" ]]; then
log "Generating SSH key..."
ssh-keygen -t ed25519 -C "$USER@$(hostname)" -f "$HOME/.ssh/id_ed25519" -N ""
echo ""
log "Add this public key to GitHub: gh ssh-key add ~/.ssh/id_ed25519.pub"
cat "$HOME/.ssh/id_ed25519.pub"
echo ""
fi
# ---------------------------------------------------------------------------
# 13. Timezone
# ---------------------------------------------------------------------------
log "Setting timezone to America/Phoenix..."
sudo timedatectl set-timezone America/Phoenix 2>/dev/null || true
# ---------------------------------------------------------------------------
# Done
# ---------------------------------------------------------------------------
echo ""
log "\033[1;32mBootstrap complete!\033[0m"
echo ""
echo " What was set up:"
echo " - zsh + Oh My Zsh + Powerlevel10k"
echo " - neovim + your LazyVim config"
echo " - tmux with custom config (prefix: Ctrl-a)"
echo " - Node.js LTS (via fnm) + pnpm"
echo " - Claude Code"
echo " - git-delta, ripgrep, fd, fzf, bat, jq, zoxide"
echo " - btop, lnav, ranger, iftop, nethogs, multitail"
echo " - fail2ban + ufw firewall (SSH only)"
echo ""
echo " Next steps:"
echo " 1. Log out and back in (or run: exec zsh)"
echo " 2. Add API keys to ~/.zsh_secrets"
echo " 3. Run: gh auth login (if not already authenticated)"
echo " 4. Open more ports as needed: sudo ufw allow 3000/tcp"
echo " 5. Use 'dotfiles' alias to manage config: dotfiles status, dotfiles add, etc."
echo ""How to use
Run chmod +x bootstrap.sh && ./bootstrap.sh on a fresh Ubuntu 24.04 VM. Or pipe directly: curl -fsSL <url> | bash. Safe to re-run anytime.
A single-command bootstrap script that turns a fresh Ubuntu 24.04 VM into a fully configured development environment. Installs Zsh + Oh My Zsh + Powerlevel10k, Node.js via fnm, 35+ modern CLI tools (btop, lnav, ranger, zoxide, ripgrep, fd, delta, ncdu), configures ufw firewall + fail2ban, and checks out your dotfiles. Fully idempotent — safe to re-run anytime.
1 file included