#!/bin/bash # One-time server setup: create service user, install tools, SSH deploy key, age keypair. # Run as root on a fresh Hetzner server before running bootstrap_supervisor.sh. # # Usage: # bash infra/setup_server.sh # ROTATE_KEYS=1 bash infra/setup_server.sh # regenerate SSH deploy key # # What it does: # 1. Creates beanflows_service user (nologin) + adds to docker group # 2. Creates /opt/materia + /data/materia/landing with correct ownership # 3. Installs git, curl, age, sops, rclone, uv # 4. Generates ed25519 SSH deploy key for GitLab read access # 5. Generates age keypair at ~/.config/sops/age/keys.txt (as service user) # 6. Prints both public keys + numbered next-step instructions set -euo pipefail SERVICE_USER="beanflows_service" APP_DIR="/opt/materia" DATA_DIR="/data/materia" SSH_DIR="/home/${SERVICE_USER}/.ssh" DEPLOY_KEY="${SSH_DIR}/materia_deploy" SOPS_AGE_DIR="/home/${SERVICE_USER}/.config/sops/age" ROTATE_KEYS="${ROTATE_KEYS:-}" [ "$(id -u)" = "0" ] || { echo "ERROR: Run as root: sudo bash infra/setup_server.sh"; exit 1; } log() { echo "$(date '+%H:%M:%S') ==> $*"; } # ── Service user ────────────────────────────────────────────────────────────── log "Creating service user ${SERVICE_USER}..." if ! id "${SERVICE_USER}" >/dev/null 2>&1; then useradd --system --create-home --shell /usr/sbin/nologin "${SERVICE_USER}" fi usermod -aG docker "${SERVICE_USER}" # ── Directories ─────────────────────────────────────────────────────────────── log "Creating directories..." mkdir -p "${APP_DIR}" "${DATA_DIR}/landing" chown "${SERVICE_USER}:${SERVICE_USER}" "${APP_DIR}" chown -R "${SERVICE_USER}:${SERVICE_USER}" "${DATA_DIR}" # ── System tools ────────────────────────────────────────────────────────────── log "Installing system tools..." apt-get update -q apt-get install -y -q git curl ca-certificates # ── SSH deploy key ──────────────────────────────────────────────────────────── log "Setting up SSH deploy key..." sudo -u "${SERVICE_USER}" mkdir -p "${SSH_DIR}" chmod 700 "${SSH_DIR}" if [ -n "${ROTATE_KEYS}" ] && [ -f "${DEPLOY_KEY}" ]; then log "Rotating SSH deploy key (ROTATE_KEYS set)..." rm -f "${DEPLOY_KEY}" "${DEPLOY_KEY}.pub" fi if [ ! -f "${DEPLOY_KEY}" ]; then sudo -u "${SERVICE_USER}" ssh-keygen -t ed25519 \ -f "${DEPLOY_KEY}" -N "" -C "materia-deploy" fi if [ ! -f "${SSH_DIR}/config" ]; then cat > "${SSH_DIR}/config" <> "${SSH_DIR}/known_hosts" 2>/dev/null sort -u "${SSH_DIR}/known_hosts" -o "${SSH_DIR}/known_hosts" chown "${SERVICE_USER}:${SERVICE_USER}" "${SSH_DIR}/known_hosts" chmod 644 "${SSH_DIR}/known_hosts" # ── age ─────────────────────────────────────────────────────────────────────── ARCH=$(uname -m) case "${ARCH}" in x86_64) ARCH_TAG="amd64" ;; aarch64) ARCH_TAG="arm64" ;; *) echo "Unsupported architecture: ${ARCH}"; exit 1 ;; esac if ! command -v age-keygen >/dev/null 2>&1; then log "Installing age..." AGE_VERSION="v1.3.1" curl -fsSL "https://dl.filippo.io/age/${AGE_VERSION}?for=linux/${ARCH_TAG}" -o /tmp/age.tar.gz tar -xzf /tmp/age.tar.gz -C /usr/local/bin --strip-components=1 age/age age/age-keygen chmod +x /usr/local/bin/age /usr/local/bin/age-keygen rm /tmp/age.tar.gz log "age installed." fi # ── sops ────────────────────────────────────────────────────────────────────── if ! command -v sops >/dev/null 2>&1; then log "Installing sops..." SOPS_VERSION="v3.12.1" curl -fsSL \ "https://github.com/getsops/sops/releases/download/${SOPS_VERSION}/sops-${SOPS_VERSION}.linux.${ARCH_TAG}" \ -o /usr/local/bin/sops chmod +x /usr/local/bin/sops log "sops installed." fi # ── rclone ──────────────────────────────────────────────────────────────────── if ! command -v rclone >/dev/null 2>&1; then log "Installing rclone..." curl -fsSL https://rclone.org/install.sh | bash log "rclone installed." fi # ── age keypair (as service user, XDG path auto-discovered by SOPS) ─────────── log "Generating age keypair for ${SERVICE_USER}..." AGE_KEY_FILE="${SOPS_AGE_DIR}/keys.txt" if [ ! -f "${AGE_KEY_FILE}" ]; then sudo -u "${SERVICE_USER}" mkdir -p "${SOPS_AGE_DIR}" sudo -u "${SERVICE_USER}" age-keygen -o "${AGE_KEY_FILE}" chmod 600 "${AGE_KEY_FILE}" log "Age keypair generated at ${AGE_KEY_FILE}" else log "Age keypair already exists — skipping." fi # ── uv (installed as service user) ──────────────────────────────────────────── if [ ! -f "/home/${SERVICE_USER}/.local/bin/uv" ]; then log "Installing uv..." sudo -u "${SERVICE_USER}" bash -c 'curl -LsSf https://astral.sh/uv/install.sh | sh' fi # ── Summary ─────────────────────────────────────────────────────────────────── DEPLOY_PUB=$(cat "${DEPLOY_KEY}.pub") AGE_PUB=$(grep "public key:" "${AGE_KEY_FILE}" | awk '{print $NF}') SERVER_IP=$(hostname -I | awk '{print $1}') echo "" echo "==================================================================" echo "" echo " SSH deploy key (add to GitLab → Settings → Deploy Keys):" echo "" echo " ${DEPLOY_PUB}" echo "" echo " Server age public key (add to .sops.yaml):" echo "" echo " ${AGE_PUB}" echo "" echo "==================================================================" echo "" echo " Next steps (run from your workstation):" echo "" echo " 1. Add the SSH deploy key to GitLab:" echo " → Repository Settings → Deploy Keys → Add key (read-only)" echo "" echo " 2. Add the server age key to .sops.yaml (comma-separated):" echo " age: ,${AGE_PUB}" echo "" echo " 3. Re-encrypt and push:" echo " sops updatekeys .env.prod.sops" echo " git add .sops.yaml .env.prod.sops" echo " git commit -m 'chore: add server age key'" echo " git push" echo "" echo " 4. Run bootstrap:" echo " ssh root@${SERVER_IP} 'bash -s' < infra/bootstrap_supervisor.sh" echo "" echo "=================================================================="