Created
March 20, 2026 07:51
-
-
Save toolittlecakes/99a26e191a0396da18d348ac6aab9e2c to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env bash | |
| set -euo pipefail | |
| usage() { | |
| cat <<'EOF' | |
| Usage: | |
| setup_vps_post.sh --host <SSH_HOST> [--port <PORT>] [--remote-user <USER>] | |
| [--docker|--no-docker] | |
| [--caddy|--no-caddy] | |
| [--swap-gb <N>|--no-swap] | |
| Notes: | |
| - Runs non-interactively over SSH (BatchMode=yes). | |
| - Assumes the connected user can run sudo non-interactively. | |
| - All configuration stays on bash (no zsh). | |
| EOF | |
| } | |
| HOST="" | |
| PORT="" | |
| REMOTE_USER="" | |
| INSTALL_DOCKER="1" | |
| INSTALL_CADDY="1" | |
| INSTALL_SWAP="1" | |
| SWAP_GB="2" | |
| while [[ $# -gt 0 ]]; do | |
| case "$1" in | |
| --host) | |
| HOST="${2:-}"; shift 2 ;; | |
| --port) | |
| PORT="${2:-}"; shift 2 ;; | |
| --remote-user) | |
| REMOTE_USER="${2:-}"; shift 2 ;; | |
| --docker) | |
| INSTALL_DOCKER="1"; shift 1 ;; | |
| --no-docker) | |
| INSTALL_DOCKER="0"; shift 1 ;; | |
| --caddy) | |
| INSTALL_CADDY="1"; shift 1 ;; | |
| --no-caddy) | |
| INSTALL_CADDY="0"; shift 1 ;; | |
| --swap-gb) | |
| SWAP_GB="${2:-}"; INSTALL_SWAP="1"; shift 2 ;; | |
| --no-swap) | |
| INSTALL_SWAP="0"; shift 1 ;; | |
| -h|--help) | |
| usage; exit 0 ;; | |
| *) | |
| echo "Unknown argument: $1"; usage; exit 2 ;; | |
| esac | |
| done | |
| if [[ -z "$HOST" ]]; then | |
| echo "Missing required argument: --host" | |
| usage | |
| exit 2 | |
| fi | |
| if [[ -n "$REMOTE_USER" ]] && ! [[ "$REMOTE_USER" =~ ^[a-z_][a-z0-9_-]{0,31}$ ]]; then | |
| echo "Invalid --remote-user. Use: [a-z_][a-z0-9_-]{0,31}" | |
| exit 2 | |
| fi | |
| if ! [[ "$SWAP_GB" =~ ^[0-9]+$ ]] || [[ "$SWAP_GB" -lt 1 ]]; then | |
| echo "Invalid --swap-gb. Use a positive integer." | |
| exit 2 | |
| fi | |
| command -v ssh >/dev/null || { echo "Missing dependency: ssh"; exit 1; } | |
| SSH=(ssh -o BatchMode=yes) | |
| if [[ -n "$PORT" ]]; then | |
| SSH+=(-p "$PORT") | |
| fi | |
| echo "==> Connecting to: $HOST" | |
| "${SSH[@]}" "$HOST" \ | |
| "REMOTE_USER='${REMOTE_USER}' INSTALL_DOCKER='${INSTALL_DOCKER}' INSTALL_CADDY='${INSTALL_CADDY}' INSTALL_SWAP='${INSTALL_SWAP}' SWAP_GB='${SWAP_GB}' bash -se" <<'REMOTE' | |
| set -euo pipefail | |
| export DEBIAN_FRONTEND=noninteractive | |
| export NEEDRESTART_MODE=a | |
| export UCF_FORCE_CONFFOLD=1 | |
| REMOTE_USER="${REMOTE_USER:-$(id -un)}" | |
| INSTALL_DOCKER="${INSTALL_DOCKER:-1}" | |
| INSTALL_CADDY="${INSTALL_CADDY:-1}" | |
| INSTALL_SWAP="${INSTALL_SWAP:-1}" | |
| SWAP_GB="${SWAP_GB:-2}" | |
| if [[ "$(id -u)" -eq 0 ]]; then | |
| SUDO="" | |
| else | |
| SUDO="sudo" | |
| fi | |
| if [[ -n "$SUDO" ]]; then | |
| sudo -n true | |
| fi | |
| APT_NO_PROMPT=( | |
| -y | |
| -o Dpkg::Use-Pty=0 | |
| -o Dpkg::Options::="--force-confdef" | |
| -o Dpkg::Options::="--force-confold" | |
| ) | |
| run_as_user() { | |
| local user="$1" | |
| if [[ "$(id -un)" == "$user" ]]; then | |
| bash -se | |
| return | |
| fi | |
| if command -v sudo >/dev/null 2>&1; then | |
| sudo -iu "$user" bash -se | |
| return | |
| fi | |
| if command -v runuser >/dev/null 2>&1; then | |
| runuser -l "$user" -- bash -se | |
| return | |
| fi | |
| su - "$user" -s /bin/bash -c 'bash -se' | |
| } | |
| echo "==> Updating system..." | |
| # If stale caddy repo exists without keyring, apt-get update will fail. | |
| if [[ -f /etc/apt/sources.list.d/caddy-stable.list ]] && [[ ! -f /usr/share/keyrings/caddy-stable-archive-keyring.gpg ]]; then | |
| $SUDO rm -f /etc/apt/sources.list.d/caddy-stable.list | |
| fi | |
| $SUDO apt-get update -y | |
| # Pre-seed debconf to avoid interactive prompts for modified config files | |
| echo 'debconf debconf/frontend select Noninteractive' | $SUDO debconf-set-selections 2>/dev/null || true | |
| $SUDO apt-get "${APT_NO_PROMPT[@]}" upgrade | |
| echo "==> Installing base packages..." | |
| $SUDO apt-get "${APT_NO_PROMPT[@]}" install \ | |
| ca-certificates curl gnupg git unzip build-essential lsb-release \ | |
| file procps | |
| if [[ "$INSTALL_SWAP" == "1" ]]; then | |
| echo "==> Ensuring swap is present..." | |
| if swapon --show=NAME --noheadings 2>/dev/null | grep -q .; then | |
| echo "Swap already enabled; skipping." | |
| else | |
| if [[ ! -f /swapfile ]]; then | |
| if command -v fallocate >/dev/null 2>&1; then | |
| $SUDO fallocate -l "${SWAP_GB}G" /swapfile | |
| else | |
| $SUDO dd if=/dev/zero of=/swapfile bs=1M count="$((SWAP_GB * 1024))" status=none | |
| fi | |
| $SUDO chmod 600 /swapfile | |
| $SUDO mkswap /swapfile >/dev/null | |
| fi | |
| $SUDO swapon /swapfile | |
| if ! grep -qE '^[^#]*\s+/swapfile\s+' /etc/fstab; then | |
| echo '/swapfile none swap sw 0 0' | $SUDO tee -a /etc/fstab >/dev/null | |
| fi | |
| $SUDO install -m 0755 -d /etc/sysctl.d | |
| echo 'vm.swappiness=10' | $SUDO tee /etc/sysctl.d/99-swap.conf >/dev/null | |
| $SUDO sysctl -p /etc/sysctl.d/99-swap.conf >/dev/null 2>&1 || true | |
| fi | |
| fi | |
| if [[ "$INSTALL_DOCKER" == "1" ]]; then | |
| echo "==> Installing Docker Engine + Compose v2..." | |
| $SUDO install -m 0755 -d /etc/apt/keyrings | |
| . /etc/os-release | |
| DISTRO_ID="$ID" | |
| CODENAME="$(lsb_release -cs)" | |
| curl -fsSL "https://download.docker.com/linux/${DISTRO_ID}/gpg" \ | |
| | $SUDO gpg --dearmor --batch --yes --no-tty -o /etc/apt/keyrings/docker.gpg | |
| $SUDO chmod a+r /etc/apt/keyrings/docker.gpg | |
| echo \ | |
| "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \ | |
| https://download.docker.com/linux/${DISTRO_ID} ${CODENAME} stable" \ | |
| | $SUDO tee /etc/apt/sources.list.d/docker.list >/dev/null | |
| $SUDO apt-get update -y | |
| $SUDO apt-get "${APT_NO_PROMPT[@]}" install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin | |
| $SUDO systemctl enable --now docker | |
| $SUDO install -m 0755 -d /etc/docker | |
| if [[ ! -f /etc/docker/daemon.json ]]; then | |
| cat <<'JSON' | $SUDO tee /etc/docker/daemon.json >/dev/null | |
| { | |
| "log-driver": "json-file", | |
| "log-opts": { | |
| "max-size": "10m", | |
| "max-file": "3" | |
| } | |
| } | |
| JSON | |
| $SUDO systemctl restart docker | |
| fi | |
| $SUDO usermod -aG docker "$REMOTE_USER" || true | |
| fi | |
| if [[ "$INSTALL_CADDY" == "1" ]]; then | |
| echo "==> Installing Caddy..." | |
| # Official instructions: https://caddyserver.com/docs/install#debian-ubuntu-raspbian | |
| $SUDO apt-get "${APT_NO_PROMPT[@]}" install debian-keyring debian-archive-keyring apt-transport-https | |
| curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' \ | |
| | $SUDO gpg --dearmor --batch --yes --no-tty -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg | |
| curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' \ | |
| | $SUDO tee /etc/apt/sources.list.d/caddy-stable.list >/dev/null | |
| $SUDO chmod o+r /usr/share/keyrings/caddy-stable-archive-keyring.gpg | |
| $SUDO chmod o+r /etc/apt/sources.list.d/caddy-stable.list | |
| $SUDO apt-get update -y | |
| $SUDO apt-get "${APT_NO_PROMPT[@]}" install caddy | |
| $SUDO systemctl enable --now caddy | |
| if command -v ufw >/dev/null 2>&1; then | |
| $SUDO ufw allow 80/tcp || true | |
| $SUDO ufw allow 443/tcp || true | |
| fi | |
| fi | |
| echo "==> Installing uv (user-scoped)..." | |
| run_as_user "$REMOTE_USER" <<'EOS' | |
| set -euo pipefail | |
| if ! command -v uv >/dev/null 2>&1; then | |
| curl -LsSf https://astral.sh/uv/install.sh | sh | |
| fi | |
| touch ~/.profile ~/.bashrc | |
| if ! grep -qF "# >>> uv-path >>>" ~/.profile; then | |
| cat >> ~/.profile <<'EOF' | |
| # >>> uv-path >>> | |
| export PATH="$HOME/.local/bin:$PATH" | |
| # <<< uv-path <<< | |
| EOF | |
| fi | |
| if ! grep -qF "# >>> uv-path >>>" ~/.bashrc; then | |
| cat >> ~/.bashrc <<'EOF' | |
| # >>> uv-path >>> | |
| export PATH="$HOME/.local/bin:$PATH" | |
| # <<< uv-path <<< | |
| EOF | |
| fi | |
| EOS | |
| echo "==> Installing bun (user-scoped)..." | |
| run_as_user "$REMOTE_USER" <<'EOS' | |
| set -euo pipefail | |
| if ! command -v bun >/dev/null 2>&1; then | |
| curl -fsSL https://bun.sh/install | bash | |
| fi | |
| touch ~/.profile ~/.bashrc | |
| if ! grep -qF "# >>> bun-path >>>" ~/.profile; then | |
| cat >> ~/.profile <<'EOF' | |
| # >>> bun-path >>> | |
| export BUN_INSTALL="$HOME/.bun" | |
| export PATH="$BUN_INSTALL/bin:$PATH" | |
| # <<< bun-path <<< | |
| EOF | |
| fi | |
| if ! grep -qF "# >>> bun-path >>>" ~/.bashrc; then | |
| cat >> ~/.bashrc <<'EOF' | |
| # >>> bun-path >>> | |
| export BUN_INSTALL="$HOME/.bun" | |
| export PATH="$BUN_INSTALL/bin:$PATH" | |
| # <<< bun-path <<< | |
| EOF | |
| fi | |
| EOS | |
| echo | |
| echo "==> Done" | |
| echo "Notes:" | |
| echo " - Re-login SSH to apply docker group changes." | |
| echo " - Tools (after new shell / login):" | |
| echo " - uv: ~/.local/bin/uv" | |
| echo " - bun: ~/.bun/bin/bun" | |
| REMOTE | |
| echo "==> Completed." |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment