| config/zsh | ||
| lib | ||
| modules | ||
| .gitignore | ||
| install.sh | ||
| README.md | ||
| setup.sh | ||
bootstrap
Turn a fresh Debian/Ubuntu/Arch VPS into a familiar workstation in one command. Detects the distro, installs a curated CLI package set, sets up zsh + oh-my-zsh with your ported config, brings your nvim config, hardens sane system defaults, and reports state.
Usage
curl -fsSL <shortener> | bash
# run a single step:
curl -fsSL <shortener> | bash -s -- --only packages
# run everything non-interactively and create a sudo user 'mk':
curl -fsSL <shortener> | bash -s -- --yes --username mk
The shortener points at the raw install.sh in this repo, which clones the
full repo to ~/.bootstrap and runs setup.sh. To update: cd ~/.bootstrap && git pull (or just re-run the one-liner — it pulls existing clones).
Re-run individual steps locally after the initial bootstrap:
bash ~/.bootstrap/setup.sh --only shell-zsh
bash ~/.bootstrap/setup.sh --only hardening
Steps
| # | step | what it does |
|---|---|---|
| 0 | preflight | verify distro + network + privileges |
| 1 | create-user | (when direct root) create a passwordless sudo user, copy root's authorized_keys, retarget config at them |
| 2 | packages | git, byobu, ufw, mosh, btop, eza, fzf, ripgrep, bat, zoxide, jq, fd, tree, ncdu, lazygit, starship, … (+ yay on Arch) |
| 3 | docker | Docker engine + compose v2 plugin, $TARGET_USER in docker group |
| 4 | angie | Angie (nginx fork) web server |
| 5 | shell-zsh | zsh + oh-my-zsh + plugins, set default shell, ship config (to ~$TARGET_USER/.config/zsh) |
| 6 | nvm | nvm → ~$TARGET_USER/.nvm + Node LTS |
| 7 | nvim | clone g.mk.fo/bootstrap/nvim-minimal → ~$TARGET_USER/.config/nvim |
| 8 | hardening | ssh (key-only root), ufw, updates, unattended-upgrades, tz, hostname, swapfile, locale, fail2ban, angie config layout + built-in ACME |
| 9 | sanity | read-only report + recommended manual follow-ups |
Every step is idempotent — re-running is safe.
Sudo user (when running as direct root)
Running the one-liner as direct root (no SUDO_USER) is the common
provider-provisioned-VPS case. The create-user step then:
- Arch: auto-creates a non-root sudoer (AUR/
makepkgcan't run as root), with ay/Nconfirm on the username (defaultmk). - Debian/Ubuntu: asks
y/Nwhether to create one (optional — apt works as root). - Always when sudoed-root (
SUDO_USERset): uses that invoking user as the target, syncing their sudoers + ssh keys instead of creating a new account. - Never when not root: you're already a normal user; yourself is the target.
Flags:
--create-userforce the prompt regardless of distro,--username NAMEuse that name and skip the name prompt.
The created user:
- is added to
wheel(Arch) orsudo+adm(Debian), shell set to zsh, - has no password (
passwd -d) — SSH key login only (you'll disable ssh password auth in the hardening step), - gets
/root/.ssh/authorized_keyscopied to~user/.ssh/(chmod 600/700), with the fingerprints printed so you can sanity-check they're yours, - gets
/etc/sudoers.d/<user>:Defaults:<user> !tty_tickets,Defaults:<user> timestamp_timeout=10080(7-day cache), and<user> ALL=(ALL) NOPASSWD:ALL. Validated withvisudo -cbefore install.
All later config steps (zsh/omz, nvim, nvm, docker group) target
~$TARGET_USER. After bootstrap, test ssh <user>@<host> from a new
terminal before closing the root session.
Zsh plugins enabled (oh-my-zsh)
git sudo command-not-found colored-man-pages extract copypath dirhistory
plus, cloned into $ZSH_CUSTOM: zsh-autosuggestions,
zsh-syntax-highlighting (loaded last), zsh-completions,
history-substring-search, fzf-tab. Prompt: starship.
Config layout (shipped to ~/.config/zsh)
config/zsh/
zshrc # main config (sources the pieces below)
path.zsh # PATH + env (XDG, EDITOR, LANG, nvm, pnpm)
aliases.zsh # ported from fish + curated defaults
aliases-extras.zsh # your personal additions (preserved across re-runs)
functions.zsh # mcd, conf, so, ssh-wrapper, dot-expansion
plugins.zsh # omz plugin list + load oh-my-zsh
~/.zshrc is a tiny shim that sets ZDOTDIR=~/.config/zsh and sources the
real config there — keeps omz "happy" while your config lives under XDG.
Hardening choices
- SSH:
PermitRootLogin prohibit-password+PasswordAuthentication no. Root key login stays available; password login is disabled. A backup is made andsshd -truns before reload. Always keep your current session open and test a new login before closing it. - ufw: deny incoming, allow
22/tcp,60000:61000/udp(mosh),80,443/tcp(angie). - updates + unattended-upgrades (Debian), system upgrade on Arch.
- timezone (default UTC), hostname (optional).
- swapfile 2G if RAM < 2G and no swap present.
- locale en_US.UTF-8.
- fail2ban with an sshd jail.
- angie config — see Angie config layout below.
Supported hosts
- Debian / Ubuntu (via apt)
- Arch (via pacman + yay for AUR)
- Architectures:
amd64,arm64
Angie config layout
The hardening step sets up a sane /etc/angie/ structure (Angie is an nginx fork; same config syntax):
/etc/angie/
angie.conf # managed by bootstrap (written once; not overwritten on re-runs)
mime.types # shipped by the package — left alone
_on/*.conf # enabled hosts (sites-enabled alt.) ← included from http{}
targets/*.conf # all host definitions (sites-available alt.)
modules/*.conf # top-level context snippets (stream{}, env, load_module, ...)
modules/http/*.conf # http{}-level snippets (upstreams, maps, real_ip, proxy defaults)
Convention: write each host in targets/<name>.conf, then enable it with
angie-enable <name> (symlinks targets/<name>.conf → _on/<name>.conf, validates,
reloads). Disable with angie-disable <name>. Both helpers are installed to
/usr/local/bin/.
The root angie.conf ships: worker_processes auto, server_tokens off,
gzip, client_max_body_size 16m, a main log_format, and includes
modules/http/*.conf then _on/*.conf. user/pid/error_log/load_module
directives are extracted from the original packaged angie.conf and preserved
(so Debian's user angie vs Arch's user http is carried over). A backup is
kept at /etc/angie/angie.conf.orig.bootstrap.
Defaults shipped:
modules/http/00-common.conf—real_ipfrom private ranges (Cloudflare commented),proxy_set_headerdefaults (inherited by every server/location)._on/00-default.conf—default_serverreturning444for requests that match no enabled host (drops drive-by scanners / bare-IP probes).targets/example-https.conf.example— a TLS server template using Angie's built-in ACME.angie-issuehelper — create a TLS host with a Let's Encrypt cert (no certbot).
TLS / Let's Encrypt (Angie built-in ACME — no certbot)
Angie ships the http_acme module, so it obtains and renews TLS certificates
itself — no certbot, no separate renewal cron/timer. The hardening step ships an
example TLS host and an angie-issue helper:
angie-issue example.com you@email.com # first call: writes the shared
# acme_client (Let's Encrypt) + email
angie-issue www.example.com # later domains reuse the same client
All enabled hosts using acme default share one certificate covering every
server_name that references it; Angie requests it on reload and renews before
expiry. The shared client lives at /etc/angie/modules/http/acme.conf (with a
resolver, required by acme_client). HTTP→HTTPS redirect, http2, and the cert
are wired into the target template — replace location / with your app/proxy.
Notes
- Distro-guarded package-manager aliases ship in
aliases.zsh:pac/pacs/pacu/pacy/pa(Arch,sudo pacman …) andapt/apti/aptu/apts/aptsh(Debian/Ubuntu,sudo apt …). aliases-extras.zshis seeded once and then left alone — re-running the shell-zsh step won't clobber your personal aliases.- On Debian,
fd/batship asfdfind/batcat; symlinks to the canonical names are created in~/.local/bin(which is onPATHviapath.zsh). - Some Go/Rust CLIs (
dust,duf,procs,lazygit) are fetched from the latest GitHub release tarball when the distro package is missing; this is best-effort and warned (not fatal) on failure.