Skip to content

Instantly share code, notes, and snippets.

@toolittlecakes
Created March 20, 2026 07:51
Show Gist options
  • Select an option

  • Save toolittlecakes/99a26e191a0396da18d348ac6aab9e2c to your computer and use it in GitHub Desktop.

Select an option

Save toolittlecakes/99a26e191a0396da18d348ac6aab9e2c to your computer and use it in GitHub Desktop.
#!/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