2026-07-28 01:32:38 +00:00
# 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
```sh
curl -fsSL < shortener > | bash
# run a single step:
curl -fsSL < shortener > | bash -s -- --only packages
2026-07-28 02:05:03 +00:00
# run everything non-interactively and create a sudo user 'mk':
curl -fsSL < shortener > | bash -s -- --yes --username mk
2026-07-28 01:32:38 +00:00
```
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:
```sh
bash ~/.bootstrap/setup.sh --only shell-zsh
bash ~/.bootstrap/setup.sh --only hardening
```
## Steps
| # | step | what it does |
|---|-------------|--------------------------------------------------------------------|
2026-07-28 02:05:03 +00:00
| 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` |
2026-07-28 02:21:23 +00:00
| 8 | hardening | ssh (key-only root), ufw, updates, unattended-upgrades, tz, hostname, swapfile, locale, fail2ban, **angie config layout + built-in ACME** |
2026-07-28 02:05:03 +00:00
| 9 | sanity | read-only report + recommended manual follow-ups |
2026-07-28 01:32:38 +00:00
Every step is idempotent — re-running is safe.
2026-07-28 02:05:03 +00:00
## 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/`makepkg` can't run as root),
with a `y/N` confirm on the username (default `mk` ).
- **Debian/Ubuntu**: asks `y/N` whether to create one (optional — apt works
as root).
- Always when sudoed-root (`SUDO_USER` set): 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-user` force the prompt regardless of distro,
- `--username NAME` use that name and skip the name prompt.
The created user:
- is added to `wheel` (Arch) or `sudo` +`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_keys` copied 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 with `visudo -c` before 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.**
2026-07-28 01:32:38 +00:00
## 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 and `sshd -t` runs 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.
2026-07-28 01:50:08 +00:00
- **angie config** — see [Angie config layout ](#angie-config-layout ) below.
2026-07-28 01:32:38 +00:00
## Supported hosts
- Debian / Ubuntu (via apt)
- Arch (via pacman + yay for AUR)
- Architectures: `amd64` , `arm64`
2026-07-28 01:50:08 +00:00
## 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_ip` from private ranges (Cloudflare commented), `proxy_set_header` defaults (inherited by every server/location).
- `_on/00-default.conf` — `default_server` returning `444` for requests that match no enabled host (drops drive-by scanners / bare-IP probes).
2026-07-28 02:21:23 +00:00
- `targets/example-https.conf.example` — a TLS server template using Angie's built-in ACME.
- `angie-issue` helper — 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:
```sh
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.
2026-07-28 01:50:08 +00:00
2026-07-28 01:32:38 +00:00
## Notes
2026-07-28 01:50:08 +00:00
- Distro-guarded package-manager aliases ship in `aliases.zsh` :
`pac` /`pacs`/`pacu`/`pacy`/`pa` (Arch, `sudo pacman …` ) and
`apt` /`apti`/`aptu`/`apts`/`aptsh` (Debian/Ubuntu, `sudo apt …` ).
2026-07-28 01:32:38 +00:00
- `aliases-extras.zsh` is seeded once and then left alone — re-running the
shell-zsh step won't clobber your personal aliases.
- On Debian, `fd` /`bat` ship as `fdfind` /`batcat`; symlinks to the canonical
names are created in `~/.local/bin` (which is on `PATH` via `path.zsh` ).
- Some Go/Rust CLIs (`dust`, `duf` , `procs` , `lazygit` ) are fetched from
the latest GitHub release tarball when the distro package is missing; this
2026-07-28 01:50:08 +00:00
is best-effort and warned (not fatal) on failure.