Files
ubuntu-post-install/README.md
T
Claude 4717c3a080 Add garage-webui: browse Garage's buckets/objects like Backblaze's console
User's actual question: Backblaze B2's web console lets them browse a
bucket as folders/files; Garage has no equivalent by default, so after
switching an additional backup mirror from Backblaze-only to also
target local Garage, they had no way to visually confirm data landed
there the way they could on Backblaze. "S3 storage is opaque, you
can't browse it" was true of Garage's *own* CLI, but wrong as a
blanket statement — Backblaze's browsability comes from a client (its
web console) layered on top of the same kind of object storage, and
Garage has an actively-maintained equivalent (khairul169/garage-webui,
1.1k stars, "integrated objects/bucket browser") that gives the same
experience against Garage's S3 API.

services/garage-webui.sh (new): standard service-template Docker
service. Requires an existing services/garage.sh install (checks for
$DOCKER_DIR/garage/.env, errors with instructions if missing — this
is a browser for an existing instance, not a replacement). Reaches
Garage over host.docker.internal (both containers' ports are already
published to the host — simpler and more robust than trying to join
garage's own Compose-project-scoped default network by name). Has its
own login (AUTH_USER_PASS, bcrypt via a throwaway `docker run --rm
httpd:alpine htpasswd` — same $ -> $$ escaping services/wg-easy.sh
already uses for its own bcrypt PASSWORD_HASH, verified here against a
real docker compose config run: unescaped, Compose tries to interpolate
$2y$05... as variable references and silently corrupts the value with
a "not set" warning; escaped, it passes through intact with no
warning), so it doesn't need Authelia gating by default.

Prerequisite fix in services/garage.sh: its admin API (bucket/key
management, object listing — the thing garage-webui talks to) has
been running with zero authentication since this service was first
built, because admin_token was never set in garage.toml. Nothing in
this repo called that API before now, so it went unnoticed; adding a
real consumer is what surfaced it. Fixed: generate admin_token
(openssl rand -base64 32) alongside the existing rpc_secret, persist
GARAGE_ADMIN_TOKEN/GARAGE_ADMIN_PORT to .env for garage-webui to read
locally (never sent over SSH, unlike the S3 credentials backup.sh
reads remotely). Update mode backfills admin_token into an existing
garage.toml (+ restarts just the garage container to apply it) for
anyone who installed before this change, same backfill-not-break
approach as the GARAGE_S3_API_PORT fix from the previous commit.

Verified: bash -n on both files; docker compose config against real
Docker Compose for both the primary garage.toml/.env generation (with
the new admin_token/GARAGE_ADMIN_PORT fields) and the new
garage-webui docker-compose.yml; the bcrypt-escaping behavior
specifically (proved via a minimal repro that unescaped $ corrupts
the value with a warning, escaped does not); the admin_token/
GARAGE_ADMIN_PORT Update-mode backfill logic against old- and
new-style .env/garage.toml fixtures, including idempotency (running
it twice adds nothing a second time); and the credential-parsing
regexes in garage-webui.sh against both a complete .env fixture and
an old one missing the new fields (confirms the "run garage's Update
first" error path actually triggers rather than proceeding with
blanks).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn
2026-08-15 14:28:57 +00:00

481 lines
21 KiB
Markdown

# ubuntu-post-install
Modular post-install system for Ubuntu servers. One repo, one entry point,
install only what you need — interactively or by name.
## Quick start on a fresh box
**Public repo — paste on any new box:**
```bash
curl -fsSL https://raw.githubusercontent.com/outis1one/ubuntu-post-install/main/bootstrap.sh | sudo bash
```
**USB thumb drive — works for public or private repos:**
Prepare the USB once on any machine (no git required):
1. Go to the repo on GitHub → green **Code** button → **Download ZIP**
2. Unzip it — you'll get a folder called `ubuntu-post-install-main`
3. Copy that folder to your USB drive
On every new box:
1. Plug in the USB — it opens in the file manager
2. Navigate into the `ubuntu-post-install-main` folder
3. **Either:**
- Right-click inside the folder → **Open in Terminal** → type `bash bootstrap.sh`
- **Or** double-click `bootstrap.sh` → if prompted, choose **Run in Terminal**
The script asks for your password if needed. It detects it is running from
inside the repo, copies everything to `~/ubuntu-post-install`, then launches
the wizard — the USB can be unplugged once setup starts.
**Private repo — PAT (alternative):**
```bash
sudo bash bootstrap.sh --pat ghp_xxxxxxxxxxxxxxxxxxxx
```
Use a fine-grained read-only PAT scoped to just this repo (Contents: Read).
The PAT is stripped from the stored remote URL after cloning.
<details>
<summary><b>Cloud provider install-script / user-data field (IONOS, DigitalOcean, Hetzner, ...)</b></summary>
These run as root with no terminal attached while the image is still being
provisioned, so `bootstrap.sh`'s interactive hand-off doesn't apply yet.
Use `cloud-init.sh` instead — it's a plain cloud-init user-data shell
script (starts with `#!/bin/bash`, no `#cloud-config` YAML).
IONOS's server-creation screen has a **User Data** box under "Scripts" with
a **Script Type** choice of *Cloud Config* or *Shell Script* — pick
**Shell Script**, then either click **Import from file** and select
`cloud-init.sh`, or paste its contents (below) directly. User-data fields
run the script's own content; they don't fetch a URL, so paste/import the
file itself rather than a link to it. (DigitalOcean/Hetzner's plain "User
data" textbox works the same way — paste the script contents in directly.)
It clones the repo in the background during provisioning and installs a
one-shot login hook. The provider boots Ubuntu 24.04, this runs unattended,
and by the time you SSH in the whiptail service menu is already waiting for
you — same experience as `bootstrap.sh`, just already started. Assumes a
root login (the default for all three providers above); see the comments in
the script if you've provisioned a separate sudo user instead.
```bash
#!/bin/bash
# cloud-init.sh — payload for a cloud provider's "install script" / user-data
# field (IONOS Cloud Server image deploy, DigitalOcean droplet user-data,
# Hetzner Cloud user-data, etc). The provider runs this as root, unattended,
# with no TTY, while the box is still being provisioned — before you have
# ever logged in.
#
# It deliberately does NOT run the interactive wizard itself (there's no
# terminal for whiptail to talk to yet). Instead it does two things:
#
# 1. Clones this repo to /root/ubuntu-post-install (pulls if already there).
# 2. Installs a one-shot /etc/profile.d hook that launches setup.sh —
# the normal whiptail service menu — the first time you actually log
# in over SSH, then deletes itself so it never fires again.
#
# End result: the provider boots Ubuntu 24.04, this runs in the background,
# and by the time you SSH in the checklist menu is sitting there waiting —
# the same experience as running bootstrap.sh by hand, just already started.
#
# Usage: paste this whole file's contents into the provider's install-script /
# user-data field (or use an "import from file" option if it has one).
# User-data fields run the content you give them directly — they don't fetch
# a URL — so paste the script itself, not a link to it.
#
# Assumes the provider logs you in as root (the default for IONOS Cloud
# Server, DigitalOcean droplets, and Hetzner Cloud server images). If you've
# provisioned a separate non-root sudo user instead, the hook won't reach
# you automatically — SSH in and run:
# sudo bash /root/ubuntu-post-install/setup.sh
set -euo pipefail
if [ "$(id -u)" -ne 0 ]; then
echo "cloud-init.sh must run as root — that's how provider install-script hooks already run it." >&2
exit 1
fi
REPO_URL="https://github.com/outis1one/ubuntu-post-install.git"
DEST="/root/ubuntu-post-install"
MARKER="/root/.ubuntu-post-install-pending"
HOOK="/etc/profile.d/99-ubuntu-post-install.sh"
export DEBIAN_FRONTEND=noninteractive
command -v git >/dev/null 2>&1 || { apt-get update -qq && apt-get install -y git; }
if [ -d "$DEST/.git" ]; then
git -C "$DEST" pull --ff-only || true
else
git clone "$REPO_URL" "$DEST"
fi
touch "$MARKER"
# POSIX sh, not bash — /etc/profile.d/*.sh gets sourced by whatever shell
# the login uses, not necessarily bash.
cat > "$HOOK" << 'EOF'
# Installed by cloud-init.sh — launches the ubuntu-post-install wizard on
# the first interactive login, then removes itself so it never fires again.
MARKER="/root/.ubuntu-post-install-pending"
HOOK="/etc/profile.d/99-ubuntu-post-install.sh"
DEST="/root/ubuntu-post-install"
if [ -f "$MARKER" ] && [ -t 0 ] && [ "$(id -u)" -eq 0 ] && [ -f "$DEST/setup.sh" ]; then
rm -f "$MARKER" "$HOOK"
echo ""
echo "ubuntu-post-install: launching the setup wizard..."
echo ""
bash "$DEST/setup.sh"
fi
EOF
chmod 644 "$HOOK"
echo "cloud-init.sh: repo cloned to $DEST — the setup wizard will launch on first login."
```
</details>
## Usage
```bash
sudo ./setup.sh # interactive wizard
sudo ./setup.sh caddy immich # install specific services
sudo ./setup.sh configure # set site defaults (timezone, domain, Caddy network)
sudo ./setup.sh filebrowser --remove # remove a service (or: sudo ./setup.sh filebrowser remove)
./setup.sh --list # list all services grouped by category
sudo ./setup.sh --dry-run immich # preview without making changes
sudo ./setup.sh --unattended base # non-interactive, use defaults
```
### Tab completion
Set up automatically by `base` (checks `~/.bashrc` first, so a rerun never
adds it twice) — open a new shell, or `source ~/.bashrc`, and it's active.
To add it manually on a box that installed `base` before this existed:
```bash
echo "source $(pwd)/tools/setup-completion.bash" >> ~/.bashrc
source ~/.bashrc
```
Then `./setup.sh mat<TAB>` completes to `./setup.sh mattermost` — same for
flags (`--li<TAB>``--list`). Works with a leading `sudo` too. The service
list is read fresh from `services/*.sh` on every completion, not a
hardcoded list baked into the script — a service added since you last
pulled shows up immediately, no re-running anything.
## What the wizard does
**First run:**
1. Installs essential CLI packages (`net-tools`, `ncdu`, `git`, `curl`, `wget`, `htop`, `tree`, `zip`/`unzip`, `ca-certificates`, `gnupg`, `jq`, `rsync`, `glow`), Docker CE + Compose plugin, and `openssh-server` — offers to import SSH keys from GitHub/Launchpad (`ssh-import-id`), disable password login once a key is confirmed, install NetBird, and add SSH Host aliases (see [SSH Host aliases](#ssh-host-aliases))
2. Asks **where Caddy runs** — this machine, a remote machine/VPN peer, or none — before anything else, since every later service prompt depends on the answer
3. If Caddy is local: offers to set **site defaults** — timezone, base domain, Caddy Docker network — so every service picks them up automatically instead of asking each time, then offers to install Caddy itself
4. Drops into a **category menu** — pick a group, tick services, install, repeat
5. Ends by dropping you into a fresh login shell so the `docker` group takes effect immediately (no manual `newgrp docker` or SSH reconnect needed)
**Re-run:** skips steps already completed, shows a summary of installed services, and goes straight to the menu.
**Site defaults** are saved to `~/docker/.config` and pre-fill every service prompt.
Update them any time with `sudo ./setup.sh configure`. Remote/none Caddy mode
skips the domain/timezone prompt entirely — service installers instead save
a ready-to-copy Caddy config snippet to `~/docker/caddy-snippets/`.
## Services
| Group | Services |
|-------|---------|
| `base` | `net-tools`, `ncdu`, `git`, `curl`, `wget`, `htop`, `tree`, `zip`/`unzip`, `ca-certificates`, `gnupg`, `jq`, `rsync`; `glow` (terminal markdown reader, Charm apt repo); Docker CE + Compose plugin; `openssh-server` with GitHub/Launchpad SSH key import, optional password-auth lockdown, and SSH Host aliases; optional NetBird overlay network |
| `homelab` | `caddy`, `crowdsec`, `authelia`, `homeassistant`, `asterisk` (own dedicated coturn for TURN/STUN — see `mattermost` below for the other coturn-owning service), `pstn-trunk`, `sms-inbound`, `security-dashboard`, `sunshine`, `vpn-data-mount` (mount existing SMB shares from a NetBird-connected home box — SSH trust bootstrap, then read-only discovery of shares already configured there; never writes to the home box's Samba config; repeatable, pick from any number of a home box's shares in one pass; optional per-share [gocryptfs decrypt layer](#client-side-encryption-for-vpn-data-mount) so the VPS only ever handles ciphertext) |
| `utilities` | `actualbudget`, `ai-gpu`, `ai-stack`, `archivebox`, `beszel` (lightweight server + Docker monitoring — CPU/RAM/disk/network, auto-discovers running containers via the Docker socket; complements Gatus rather than replacing it — Gatus is a black-box HTTP check, Beszel is white-box host/process monitoring), `beszel-agent` (agent-only Beszel install for a remote/homelab box reporting to a hub elsewhere — connects outbound over HTTPS, no VPN/port-forwarding/FQDN needed on that box), `changedetection`, `ddclient`, `filebrowser`, `fmd`, `garage` (self-hosted S3-compatible object storage, single node — MinIO CE's actively-maintained replacement), `garage-webui` (browser-based bucket/object browser for an existing `garage` install — folders/files view, the same kind of thing Backblaze's own web console gives you), `gatus`, `homebox`, `iopaint`, `joplin`, `koha`, `magicmirror`, `mail-archiver`, `mattermost`, `mealie`, `meshcentral`, `n8n`, `nextcloud`, `ntfy`, `onlyoffice`, `paintplus`, `pihole` (standalone DNS ad/tracker blocking — not wired into any VPN's DNS push), `portainer`, `rustdesk`, `stirling-pdf`, `syncthing`, `traccar`, `unifi`, `uptimekuma`, `vaultwarden`, `watchyourlan`, `watchtower`, `wg-easy`, `wordpress` (multi-site, dedicated MariaDB per site — blogs, business sites, e-commerce via WooCommerce) |
| `media` | `arm`, `audiobookshelf`, `calibre-web`, `emby`, `immich`, `jellyfin`, `lyrion` |
| `cameras` | `frigate`, `frigate-audio`, `frigate-notify`, `sky-cam` |
| `gaming` | `drum-rhythm-game`, `js99er`, `kyber-launcher`, `kyber-server`, `minecraft`, `wolf`, `wolf-pair` |
| `extras` | `kdeconnect`, `silent-send`, `ssh-config`, `ssh-key-import` (import SSH public keys from GitHub/Launchpad, optionally lock down password auth — same step base.sh's required setup runs, re-runnable on its own), `sync-cc` |
| `backup` | `backup` — complete recovery: entire `~/docker/<service>/` for every service via Kopia (Minecraft: flush+snap, no downtime; others: stop/snap/start for DB consistency), optional offsite mirror (`kopia repository sync-to`), plus `dr_bringup.sh` — unattended restore-everything-and-start for standing up a cold spare box; `borg-backup` — same coverage via Borg (chunk dedup, SSH remote repos, Borgmatic/Vorta compatible); `gaming-backup` — frequent game-save snapshots (Minecraft world data, emulator saves, Steam — no downtime, run hourly) |
Run `./setup.sh --list` to see descriptions.
<details>
<summary>Copiable list of all services by category</summary>
```
base
base
glow
homelab
caddy
crowdsec
authelia
homeassistant
asterisk
pstn-trunk
sms-inbound
security-dashboard
sunshine
vpn-data-mount
utilities
actualbudget
ai-gpu
ai-stack
archivebox
changedetection
ddclient
filebrowser
fmd
gatus
homebox
iopaint
joplin
koha
magicmirror
mail-archiver
mattermost
mealie
meshcentral
n8n
nextcloud
ntfy
onlyoffice
paintplus
portainer
rustdesk
stirling-pdf
syncthing
traccar
unifi
uptimekuma
vaultwarden
watchyourlan
watchtower
wg-easy
wordpress
media
arm
audiobookshelf
calibre-web
emby
immich
jellyfin
lyrion
cameras
frigate
frigate-audio
frigate-notify
sky-cam
gaming
drum-rhythm-game
js99er
kyber-launcher
kyber-server
minecraft
wolf
wolf-pair
extras
kdeconnect
silent-send
ssh-config
ssh-key-import
sync-cc
backup
backup
borg-backup
gaming-backup
```
</details>
## Layout
```
setup.sh dispatcher — wizard, direct install, --list, --dry-run
lib/common.sh shared helpers: logging, prompts, site config, OS detection
services/ one file per service (self-registering)
vendor/ full app source trees vendored for a service (e.g. ai-stack,
paintplus, easy-asterisk) — copied into place at install time,
no network clone needed
extras/ non-Docker assets bundled with the repo (e.g. sync_cc.py)
CLAUDE.md contributor guide — how to add services, available helpers
```
## Managing installed services
Every Docker service installs to its own `~/docker/<name>/` folder:
```bash
cd ~/docker/immich
docker compose up -d # start
docker compose logs -f # logs
docker compose pull && docker compose up -d # update
docker compose down # stop
```
## Old config backup pruning
Every service in this repo backs up a live config before overwriting it —
`Caddyfile.backup.<timestamp>`, `/etc/fstab.backup.<timestamp>`, and so on —
but nothing cleans those up afterward, so they build up on any box
reconfigured regularly. `base` sets up a daily systemd timer
(`prune-old-backups`), automatically and without asking (the same way it
sets up [tab completion](#tab-completion) — low-stakes enough not to need
a prompt, and idempotent either way), that removes anything older than 30
days, always keeping at least the single newest backup per file regardless
of age — a box left alone for months never ends up with zero backups for
something.
```bash
sudo bash tools/prune-old-backups.sh [KEEP_DAYS] # run by hand, default 30
systemctl status prune-old-backups.timer # check the schedule
sudo systemctl disable --now prune-old-backups.timer # turn it off
```
Caddy's own access logs (`/var/log/caddy/*.log`) are handled separately —
`caddy` sets up `logrotate` for those directly (14 days, `copytruncate` so
neither the running container nor CrowdSec's log tailing has to notice a
rotation happened).
## SSH key import (GitHub/Launchpad) and disabling password login
Imports your public keys from GitHub and/or Launchpad (Canonical/Ubuntu's
own code-hosting platform) into `~/.ssh/authorized_keys` via
[`ssh-import-id`](https://manpages.ubuntu.com/manpages/noble/man1/ssh-import-id.1.html),
so you can log in with a key instead of a password — then optionally locks
password auth off entirely once at least one key is confirmed imported.
Only your **public** key is ever involved — the same information already
visible on `github.com/<user>.keys`, fetched over HTTPS. No private key
material leaves wherever it was generated, and importing a key does **not**
give this box any ability to authenticate *outward* as you (e.g. it still
can't clone your private repos) — it only grants *inbound* login to
whoever holds the matching private key.
Two ways to run it:
- **During `base` install** — runs automatically as part of the required
setup on a fresh box, right after the SSH server itself is configured
- **Any time** — `sudo ./setup.sh ssh-key-import` runs just this step on
its own: import more keys later (a new admin, a different box), or set
it up on a box that only needs this and nothing else `base` does — e.g.
the home box side of [`vpn-data-mount`](#services)
Password authentication is only offered to be disabled if at least one key
import actually succeeded in that run — never blindly, so you can't get
locked out by declining every import prompt.
## Client-side encryption for vpn-data-mount
A plain SMB mount over the VPN protects the data in transit, but the VPS
itself — its disk, page cache, and anyone with access to the machine (the
host, an attacker who compromises it, a compelled disclosure) — sees
plaintext while it's mounted. `vpn-data-mount`'s optional decrypt layer
closes most of that gap by encrypting on the home box, before anything
ever crosses the network:
1. On the home box, run `sudo bash tools/gocryptfs-setup-home.sh`. It
creates an encrypted directory (a "cipherdir") and a passphrase file,
and prints the exact next step. Point your existing Samba share's
`path =` at the cipherdir itself — this tool never touches smb.conf,
same read-only stance `vpn-data-mount` itself takes on the VPS side.
2. On the VPS, `sudo ./setup.sh vpn-data-mount` as usual. After it mounts
the share over CIFS, it asks whether to layer gocryptfs decryption on
top — say yes and give it the passphrase file's path. It fetches that
file fresh over the same SSH trust already set up for share discovery,
pipes it straight into `gocryptfs`, and never writes it to the VPS's
own disk. A systemd unit keeps the decrypted view coming back on boot,
fetching the passphrase again each time rather than caching it.
What this changes: a disk image, backup, or provider-side look at the VPS
while the passphrase isn't actively loaded shows only ciphertext. What it
doesn't change: anything actually reading through the decrypted mount
while it's live still sees plaintext, same as any data in active use
anywhere — that part isn't a software problem this (or any) tool can
solve out from under the machine actually using the data.
Skip this entirely if it's not worth the added moving part — `vpn-data-mount`
works exactly the same without it, plain CIFS, nothing to opt into.
## SSH Host aliases
`~/.ssh/config` lets you `ssh <alias>` instead of typing `ssh user@1.2.3.4`
every time — especially handy once machines are reachable over a VPN/NetBird
overlay network where the IP is easy to forget:
```
Host myserver
HostName 100.x.x.x
User someuser
Port 22
```
Three ways to manage these entries:
- **During `base` install** — after SSH key import, the wizard offers to add
one or more aliases interactively
- **Any time** — `sudo ./setup.sh ssh-config` lists, adds, or removes aliases
without touching anything else
- **By hand** — edit `~/.ssh/config` directly; it's a plain OpenSSH client
config file, nothing generated or templated beyond the `Host` block itself
Aliases are written to the invoking user's own config (not root's), since
that's whose terminal actually runs `ssh`.
## Installing from a USB thumb drive
No git required. Works for anyone with a browser.
### 1 — Put the repo on the USB
1. On GitHub: click **Code → Download ZIP**
2. Open your Downloads folder — right-click the ZIP → **Extract Here**
3. Drag the `ubuntu-post-install-main` folder onto the USB drive in the
file manager sidebar
To update later: download the ZIP again, extract, drag the new folder to the
USB and replace the old one.
### 2 — Run on the target machine
Plug in the USB. Open the `ubuntu-post-install-main` folder in the file manager,
then either:
- **Right-click inside the folder → Open in Terminal**, then run:
```bash
sudo bash bootstrap.sh
```
- **Double-click `bootstrap.sh`** → click *Run in Terminal* → it prompts for
your sudo password and starts the wizard automatically.
### Notes
- Everything the wizard installs goes to `~/docker/` on the **target machine's
disk** — only the setup scripts live on the USB.
- **exFAT** is the best filesystem for the USB — readable on Windows and macOS
for easy ZIP extraction, and works fine on Linux.
## Compatibility
Tested on **Ubuntu 24.04 LTS** and **26.04 LTS**.
Works on any Ubuntu LTS ≥ 22.04; non-LTS releases also work.
The wizard shows the detected OS in the header and warns on unknown versions.
## Gaming scripts
Gaming setup walkthroughs live next to their service files, not here — see
`services/<name>.md` (e.g. [`services/kyber-launcher.md`](services/kyber-launcher.md)
for SWBF2 (2017) + Kyber: playing, hosting, and troubleshooting). It's
appended automatically to `~/.local/share/kyber/README.md` when you run
`sudo ./setup.sh kyber-launcher`.
Also in `scripts/` (standalone, not part of the main wizard):
`setup-swbf2-linux.sh` (native Steam/Proton fixes for the base game),
`setup-kyber-linux.sh` (standalone equivalent of the `kyber-launcher`
service), and Wolf/Games-on-Whales container variants
(`setup-swbf2-wolf.sh`, `setup-kyber-wolf.sh`).