Claude 7f23860078 docs(ai-stack): name the R580 driver branch for Volta/Pascal cards
Confirmed via NVIDIA's own release notes: R580 is the last branch
adding Volta/Pascal support (through ~June 2028). Also flag the
proprietary-vs-open kernel module split, since -open only covers
Turing and newer and autoinstall could pick it on a newer distro.
2026-08-22 03:52:39 +00:00
2026-01-01 18:37:38 -05:00

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:

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):

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.

Cloud provider install-script / user-data field (IONOS, DigitalOcean, Hetzner, ...)

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.

#!/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."

Usage

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:

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)
  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 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, gitea (self-hosted Git server — raw local clones plus optional two-way GitHub mirror sync, standalone from the ai-stack bundle's own Gitea container), 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.

Copiable list of all services by category
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

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:

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 — 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.

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, 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 timesudo ./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

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 timesudo ./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:

    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 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).

S
Description
Mirror of outis1one/ubuntu-post-install from GitHub
Readme MIT
13 MiB
Languages
Shell 96.4%
Python 3.6%