#!/usr/bin/env sh # f/stop · install.sh — one-stop self-host installer # # This is the END-USER installer (not the dev-side build.sh). Hosted at # https://git.n0agi.com/products/fstop/install.sh # so a fresh Alpine VM can become an f/stop instance with one command: # # curl -fsSL https://git.n0agi.com/products/fstop/install.sh | sudo sh # # Or, for the cautious — download, read, then run: # # curl -fsSL https://git.n0agi.com/products/fstop/install.sh -o install.sh # less install.sh # sudo sh install.sh # # What it does: # 1. Verifies it's running as root (or under sudo) # 2. Detects OS (Alpine / Debian / Ubuntu / RHEL-family) and installs # Docker + docker-compose-plugin if missing, plus curl/unzip/openssl # 3. Downloads the latest f/stop release zip # 4. Unzips into /opt/fstop # 5. Prompts for the few config bits a fresh deployment needs: # - ALM_URL (default: https://alm.n0agi.com) # - First-admin email / name / password (used once on first run) # - JWT_SECRET (auto-generated via openssl rand) # 6. Loads the Docker image, runs `docker compose up -d` # 7. Polls /api/health until the container reports ready # 8. Prints the URL to visit + next-step guidance (license key vs trial) # # Re-running this script on an existing install detects the prior state # and offers Upgrade mode (preserves all volumes — your photos and DB # stay intact, image is replaced with the latest version). # # All prompts can be skipped via env vars for automation: # FSTOP_ALM_URL=https://alm.n0agi.com \ # FSTOP_ADMIN_EMAIL=you@example.com \ # FSTOP_ADMIN_NAME=YourName \ # FSTOP_ADMIN_PASSWORD='choose-a-strong-one' \ # curl -fsSL https://git.n0agi.com/products/fstop/install.sh | sudo sh set -eu # ─── Config (override via env) ────────────────────────────────────────────── FSTOP_DOWNLOAD_URL="${FSTOP_DOWNLOAD_URL:-https://git.n0agi.com/products/fstop/fstop-latest.zip}" FSTOP_INSTALL_DIR="${FSTOP_INSTALL_DIR:-/opt/fstop}" FSTOP_ALM_URL="${FSTOP_ALM_URL:-https://alm.n0agi.com}" FSTOP_PORT="${FSTOP_PORT:-8080}" # ─── Helpers ──────────────────────────────────────────────────────────────── c_red() { printf '\033[31m%s\033[0m' "$1"; } c_green() { printf '\033[32m%s\033[0m' "$1"; } c_yellow() { printf '\033[33m%s\033[0m' "$1"; } c_dim() { printf '\033[2m%s\033[0m' "$1"; } step() { printf '\n%s %s\n' "$(c_yellow '▸')" "$1"; } ok() { printf '%s %s\n' "$(c_green '✓')" "$1"; } fail() { printf '%s %s\n' "$(c_red '✗')" "$1" >&2; exit 1; } info() { printf ' %s\n' "$(c_dim "$1")"; } prompt() { # prompt "Question text" "default-value" → echoes the answer label="$1"; default="${2:-}" if [ -n "$default" ]; then printf ' %s [%s]: ' "$label" "$default" > /dev/tty else printf ' %s: ' "$label" > /dev/tty fi IFS= read -r answer < /dev/tty || answer="" [ -n "$answer" ] || answer="$default" printf '%s' "$answer" } prompt_secret() { # Like prompt but doesn't echo input. No default — secrets must be typed. label="$1" printf ' %s: ' "$label" > /dev/tty stty -echo < /dev/tty 2>/dev/null || true IFS= read -r answer < /dev/tty || answer="" stty echo < /dev/tty 2>/dev/null || true printf '\n' > /dev/tty printf '%s' "$answer" } # ─── Banner ───────────────────────────────────────────────────────────────── cat <<'EOF' ┌─────────────────────────────────────────────────────────┐ │ │ │ f/stop · self-host installer │ │ │ │ A private, invite-only photo blogging platform │ │ for individuals, hobbyists, and pros who want │ │ their portfolio on their own server. │ │ │ └─────────────────────────────────────────────────────────┘ EOF # ─── Pre-flight ───────────────────────────────────────────────────────────── step "Pre-flight checks" if [ "$(id -u)" -ne 0 ]; then fail "This script must run as root (or via sudo). Try: sudo sh install.sh" fi ok "running as root" # OS detection — used to pick the right package manager. if [ -r /etc/os-release ]; then # shellcheck disable=SC1091 . /etc/os-release OS_ID="${ID:-unknown}" OS_LIKE="${ID_LIKE:-}" else OS_ID="unknown" OS_LIKE="" fi info "OS detected: $OS_ID${OS_LIKE:+ (like $OS_LIKE)}" case "$OS_ID" in alpine) PKG_MGR="apk" ;; debian|ubuntu) PKG_MGR="apt" ;; fedora|rhel|centos|rocky|almalinux) PKG_MGR="dnf" ;; *) case " $OS_LIKE " in *debian*|*ubuntu*) PKG_MGR="apt" ;; *rhel*|*fedora*) PKG_MGR="dnf" ;; *) PKG_MGR="" ;; esac ;; esac if [ -z "$PKG_MGR" ]; then fail "Unsupported OS. Install Docker + docker-compose manually, then re-run with FSTOP_SKIP_DEPS=1." fi ok "package manager: $PKG_MGR" # ─── Install dependencies (Docker + curl/unzip/openssl) ──────────────────── step "Installing dependencies" # A small set of tools the script + deploy.sh rely on. Most distros have # curl + openssl by default but Alpine minimal images don't. install_pkgs() { case "$PKG_MGR" in apk) apk add --no-cache curl unzip openssl docker docker-cli-compose ;; apt) export DEBIAN_FRONTEND=noninteractive apt-get update -y >/dev/null apt-get install -y curl unzip openssl ca-certificates >/dev/null # Docker via official convenience script — handles repo setup # cleanly across Debian/Ubuntu versions. if ! command -v docker >/dev/null 2>&1; then curl -fsSL https://get.docker.com | sh fi ;; dnf) dnf install -y curl unzip openssl ca-certificates >/dev/null if ! command -v docker >/dev/null 2>&1; then curl -fsSL https://get.docker.com | sh fi ;; esac } if [ "${FSTOP_SKIP_DEPS:-0}" = "1" ]; then info "FSTOP_SKIP_DEPS=1 — skipping dependency install" else install_pkgs ok "deps installed (curl unzip openssl docker)" fi # Start Docker if not running. Alpine uses OpenRC; Debian/Ubuntu/RHEL use systemd. case "$OS_ID" in alpine) rc-update add docker boot >/dev/null 2>&1 || true rc-service docker start >/dev/null 2>&1 || true ;; *) systemctl enable --now docker >/dev/null 2>&1 || true ;; esac # Sanity-check Docker is responsive. if ! docker info >/dev/null 2>&1; then fail "Docker is installed but not running. Start it manually then re-run this script." fi ok "docker daemon responsive" # ─── Download release ─────────────────────────────────────────────────────── step "Downloading f/stop release" mkdir -p "$FSTOP_INSTALL_DIR" cd "$FSTOP_INSTALL_DIR" # If a prior install exists, snapshot the .env so we don't clobber it. PRIOR_ENV="" if [ -f "$FSTOP_INSTALL_DIR/.env" ]; then PRIOR_ENV="$(mktemp)" cp "$FSTOP_INSTALL_DIR/.env" "$PRIOR_ENV" info "existing install detected — preserving $FSTOP_INSTALL_DIR/.env" fi ZIP_PATH="$FSTOP_INSTALL_DIR/fstop-release.zip" curl -fsSL "$FSTOP_DOWNLOAD_URL" -o "$ZIP_PATH" \ || fail "download failed from $FSTOP_DOWNLOAD_URL" ok "downloaded $(du -h "$ZIP_PATH" | cut -f1)" unzip -o -q "$ZIP_PATH" -d "$FSTOP_INSTALL_DIR" rm -f "$ZIP_PATH" ok "unzipped to $FSTOP_INSTALL_DIR" VERSION="$(cat "$FSTOP_INSTALL_DIR/VERSION" 2>/dev/null || echo unknown)" info "version: $VERSION" # Restore preserved .env if there was one. if [ -n "$PRIOR_ENV" ]; then cp "$PRIOR_ENV" "$FSTOP_INSTALL_DIR/.env" rm -f "$PRIOR_ENV" ok "preserved .env restored — running in Upgrade mode" UPGRADE=1 else UPGRADE=0 fi # ─── First-time configuration ─────────────────────────────────────────────── if [ "$UPGRADE" = "0" ]; then step "Configuration" info "Quick prompts — press Enter to accept defaults in [brackets]." printf '\n' ALM_URL="$(prompt 'License Manager URL (ALM_URL)' "$FSTOP_ALM_URL")" if [ -z "${FSTOP_ADMIN_EMAIL:-}" ]; then ADMIN_EMAIL="$(prompt 'First-admin email')" else ADMIN_EMAIL="$FSTOP_ADMIN_EMAIL" info "first-admin email: $ADMIN_EMAIL (from env)" fi if [ -z "${FSTOP_ADMIN_NAME:-}" ]; then ADMIN_NAME="$(prompt 'First-admin display name')" else ADMIN_NAME="$FSTOP_ADMIN_NAME" info "first-admin display name: $ADMIN_NAME (from env)" fi if [ -z "${FSTOP_ADMIN_PASSWORD:-}" ]; then ADMIN_PASSWORD="$(prompt_secret 'First-admin password (min 10 chars, will not echo)')" if [ "${#ADMIN_PASSWORD}" -lt 10 ]; then fail "Password must be at least 10 characters." fi else ADMIN_PASSWORD="$FSTOP_ADMIN_PASSWORD" info "first-admin password (from env)" fi JWT_SECRET="$(openssl rand -base64 48 | tr -d '\n')" info "JWT_SECRET generated" # Generate the .env file. Keep it readable for operators who SSH in # later (mode 0600 because it contains secrets). cat > "$FSTOP_INSTALL_DIR/.env" </dev/null ok "image loaded" # Up via the bundled compose file. docker compose up -d ok "container started" # ─── Health-check ────────────────────────────────────────────────────────── step "Waiting for f/stop to come ready" WAITED=0 MAX_WAIT=90 while [ "$WAITED" -lt "$MAX_WAIT" ]; do if curl -fs "http://127.0.0.1:${FSTOP_PORT}/api/health" >/dev/null 2>&1; then ok "responding on port $FSTOP_PORT" break fi sleep 2 WAITED=$((WAITED + 2)) done if [ "$WAITED" -ge "$MAX_WAIT" ]; then printf '\n%s f/stop did not become ready within %ds.\n' "$(c_red '✗')" "$MAX_WAIT" >&2 printf ' Check the container logs:\n %s\n\n' "docker compose -p fstop logs --tail=100" >&2 exit 1 fi # ─── Done ────────────────────────────────────────────────────────────────── HOST_IP="$(hostname -I 2>/dev/null | awk '{print $1}' || echo 127.0.0.1)" cat <