#!/usr/bin/env bash # Buuck installer — interactive setup for a VPS. # # Usage: # curl -fsSL https://get.buuck.io/install.sh | sudo bash # sudo bash install.sh # # Non-interactive (all required vars must be set): # sudo BUUCK_NONINTERACTIVE=1 \ # BUUCK_REGISTRY_USER='robot$buuck+acme' \ # BUUCK_LICENSE_KEY='...' \ # BUUCK_VERSION='v0.0.1' \ # BUUCK_API_DOMAIN='api.example.com' \ # BUUCK_DASHBOARD_DOMAIN='admin.example.com' \ # BUUCK_BOOKING_DOMAIN='book.example.com' \ # BUUCK_ACME_EMAIL='you@example.com' \ # BUUCK_EMAIL_FROM='Your Business ' \ # BUUCK_SMTP_HOST='smtp.example.com' \ # BUUCK_SMTP_USER='...' \ # BUUCK_SMTP_PASS='...' \ # bash install.sh # # Optional env: # BUUCK_INSTALL_DIR default /opt/buuck # BUUCK_INSTALL_BASE_URL fetch templates from URL (for curl | bash) # BUUCK_INSTALL_PUBLIC_URL default template host when no local templates (https://get.buuck.io) # BUUCK_SKIP_DNS_CHECK=1 skip dig checks # BUUCK_SKIP_DOCKER_INSTALL=1 # BUUCK_WRITE_FILES_ONLY=1 write compose/Caddyfile/.env only (no Docker) # BUUCK_RESET_SECRETS=1 regenerate DB/cache/auth secrets (breaks existing volumes) # BUUCK_CONFIGURE_UFW=1 open 22/80/443 with ufw (noninteractive; interactive still prompts) # BUUCK_EMAIL_PROVIDER default smtp # BUUCK_SMTP_PORT default 587 # BUUCK_SMTP_SECURE default false # BUUCK_REGISTRY registry host for login + image pulls (default registry.buuck.io) # # Tests may source this file; main runs only when executed directly. set -euo pipefail readonly REGISTRY_DEFAULT='registry.buuck.io' readonly INSTALL_DIR_DEFAULT='/opt/buuck' readonly COMPOSE_MIN_MAJOR=2 readonly COMPOSE_MIN_MINOR=20 readonly DOCKER_INSTALL_URL='https://get.docker.com' REGISTRY="${BUUCK_REGISTRY:-$REGISTRY_DEFAULT}" INSTALL_DIR="${BUUCK_INSTALL_DIR:-$INSTALL_DIR_DEFAULT}" NONINTERACTIVE="${BUUCK_NONINTERACTIVE:-0}" SKIP_DNS_CHECK="${BUUCK_SKIP_DNS_CHECK:-0}" SKIP_DOCKER_INSTALL="${BUUCK_SKIP_DOCKER_INSTALL:-0}" WRITE_FILES_ONLY="${BUUCK_WRITE_FILES_ONLY:-0}" RESET_SECRETS="${BUUCK_RESET_SECRETS:-0}" CONFIGURE_UFW="${BUUCK_CONFIGURE_UFW:-0}" # Resolve directory of this script when run from a file (not when piped). SCRIPT_DIR='' if [[ -n "${BASH_SOURCE[0]:-}" && -f "${BASH_SOURCE[0]}" ]]; then SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" fi TEMPLATE_DIR="${SCRIPT_DIR:+$SCRIPT_DIR/templates}" BASE_URL="${BUUCK_INSTALL_BASE_URL:-}" # Prefer adjacent templates/; otherwise fetch from the public installer host # (set when curling install.sh so R2-hosted templates stay in sync). if [[ -z "$BASE_URL" ]]; then if [[ -z "$TEMPLATE_DIR" || ! -f "${TEMPLATE_DIR}/compose.yaml" ]]; then BASE_URL="${BUUCK_INSTALL_PUBLIC_URL:-https://get.buuck.io}" fi fi # Collected / generated config REGISTRY_USER='' LICENSE_KEY='' # May be provided via environment for non-interactive installs — do not blank it. BUUCK_VERSION="${BUUCK_VERSION:-}" API_DOMAIN='' DASHBOARD_DOMAIN='' BOOKING_DOMAIN='' ACME_EMAIL='' EMAIL_PROVIDER='smtp' EMAIL_FROM='' SMTP_HOST='' SMTP_PORT='587' SMTP_USER='' SMTP_PASS='' SMTP_SECURE='false' POSTGRES_PASSWORD='' APP_DB_PASSWORD='' NOTIFICATION_WORKER_PASSWORD='' CACHE_PASSWORD='' BETTER_AUTH_SECRET='' log() { printf '%s\n' "$*"; } info() { printf '==> %s\n' "$*"; } warn() { printf 'WARNING: %s\n' "$*" >&2; } die() { printf 'ERROR: %s\n' "$*" >&2; exit 1; } need_cmd() { command -v "$1" >/dev/null 2>&1 || die "Required command not found: $1" } is_root() { [[ "$(id -u)" -eq 0 ]] } prompt() { # prompt VAR "Question" [default] # Empty default means the value is required (no default to fall back to). local var="$1" local question="$2" local default="${3:-}" local value='' if [[ -n "${!var:-}" ]]; then return 0 fi if [[ "$NONINTERACTIVE" == '1' ]]; then [[ -n "$default" ]] || die "Missing required value: $var" printf -v "$var" '%s' "$default" return 0 fi if [[ -n "$default" ]]; then read -r -p "$question [$default]: " value || true value="${value:-$default}" else while [[ -z "$value" ]]; do read -r -p "$question: " value || true [[ -n "$value" ]] || warn 'Value required.' done fi printf -v "$var" '%s' "$value" } prompt_optional() { # prompt_optional VAR "Question" [default] # Allows empty answers. In noninteractive mode, keeps current value or default # (including an empty default). local var="$1" local question="$2" local default="${3:-}" local value='' # Already set (including intentionally empty via env) — leave as-is when # the nameref is non-empty. Empty means "not provided yet" for optional fields # loaded from unset env vars; accept default / blank. if [[ -n "${!var:-}" ]]; then return 0 fi if [[ "$NONINTERACTIVE" == '1' ]]; then printf -v "$var" '%s' "$default" return 0 fi if [[ -n "$default" ]]; then read -r -p "$question (optional) [$default]: " value || true value="${value:-$default}" else read -r -p "$question (optional): " value || true fi printf -v "$var" '%s' "$value" } prompt_secret() { # prompt_secret VAR "Question" local var="$1" local question="$2" local value='' if [[ -n "${!var:-}" ]]; then return 0 fi if [[ "$NONINTERACTIVE" == '1' ]]; then die "Missing required secret: $var" fi while [[ -z "$value" ]]; do read -r -s -p "$question: " value || true printf '\n' [[ -n "$value" ]] || warn 'Value required.' done printf -v "$var" '%s' "$value" } confirm() { # confirm "Question" — requires yes, otherwise aborts. local question="$1" local answer='' if [[ "$NONINTERACTIVE" == '1' ]]; then return 0 fi read -r -p "$question [y/N]: " answer || true case "$answer" in y|Y|yes|YES) return 0 ;; *) die 'Aborted.' ;; esac } ask_yes() { # ask_yes "Question" — returns 0 for yes, 1 for no. Default no. # Noninteractive defaults to no (same as pressing Enter). local question="$1" local answer='' if [[ "$NONINTERACTIVE" == '1' ]]; then return 1 fi read -r -p "$question [y/N]: " answer || true case "$answer" in y|Y|yes|YES) return 0 ;; *) return 1 ;; esac } env_quote() { # Single-quote a value for .env (escape embedded single quotes). local value="$1" value="${value//\'/\'\\\'\'}" printf "'%s'" "$value" } rand_hex() { if command -v openssl >/dev/null 2>&1; then openssl rand -hex 24 return fi # Fallback when openssl is missing head -c 24 /dev/urandom | od -An -tx1 | tr -d ' \n' } version_ge() { # Compare dotted versions: version_ge 2.20.0 2.20 local have="$1" local need="$2" local IFS=. # shellcheck disable=SC2206 local have_parts=($have) need_parts=($need) local i for ((i = 0; i < ${#need_parts[@]}; i++)); do local h="${have_parts[i]:-0}" local n="${need_parts[i]:-0}" h="${h%%[^0-9]*}" n="${n%%[^0-9]*}" [[ -n "$h" ]] || h=0 [[ -n "$n" ]] || n=0 if ((h > n)); then return 0; fi if ((h < n)); then return 1; fi done return 0 } compose_cmd() { if docker compose version >/dev/null 2>&1; then docker compose "$@" else die 'Docker Compose plugin not found. Install Docker Engine with the Compose plugin.' fi } check_arch() { local arch arch="$(uname -m)" case "$arch" in x86_64|amd64) ;; *) die "Buuck images require a 64-bit x86 (amd64) CPU. This machine is: $arch" ;; esac } check_os() { [[ "$(uname -s)" == 'Linux' ]] || die 'Buuck installer only supports Linux (Ubuntu 24.04 recommended).' } ensure_docker() { if command -v docker >/dev/null 2>&1; then docker --version >/dev/null || die 'docker is installed but not working.' return 0 fi if [[ "$SKIP_DOCKER_INSTALL" == '1' ]]; then die 'Docker is not installed. Install Docker Engine, then re-run this installer.' fi info 'Docker not found. Installing Docker Engine...' need_cmd curl curl -fsSL "$DOCKER_INSTALL_URL" | sh command -v docker >/dev/null 2>&1 || die 'Docker install finished but docker is still not available.' } ensure_compose() { local ver raw raw="$(docker compose version --short 2>/dev/null || true)" [[ -n "$raw" ]] || die 'Docker Compose plugin missing. Install Docker Compose v2.20 or newer.' ver="${raw#v}" version_ge "$ver" "${COMPOSE_MIN_MAJOR}.${COMPOSE_MIN_MINOR}" \ || die "Docker Compose $ver is too old. Need ${COMPOSE_MIN_MAJOR}.${COMPOSE_MIN_MINOR}+." info "Docker Compose $ver OK" } maybe_configure_firewall() { if ! command -v ufw >/dev/null 2>&1; then warn 'ufw not found. Open ports 22, 80, and 443 in your firewall if needed.' return 0 fi local do_ufw=0 if [[ "$CONFIGURE_UFW" == '1' ]]; then do_ufw=1 elif [[ "$NONINTERACTIVE" == '1' ]]; then warn 'Skipped ufw (noninteractive). Set BUUCK_CONFIGURE_UFW=1 to open ports 22, 80, and 443.' return 0 elif ask_yes 'Configure ufw to allow ports 22, 80, and 443?'; then do_ufw=1 fi if [[ "$do_ufw" -eq 1 ]]; then info 'Configuring ufw for ports 22, 80, 443...' ufw allow 22/tcp >/dev/null ufw allow 80/tcp >/dev/null ufw allow 443/tcp >/dev/null ufw --force enable >/dev/null || true else warn 'Skipped ufw. Open ports 22, 80, and 443 yourself if needed.' fi } check_dns() { local domain="$1" local resolved='' if [[ "$SKIP_DNS_CHECK" == '1' ]]; then return 0 fi if ! command -v dig >/dev/null 2>&1; then warn "dig not installed; skipping DNS check for $domain" return 0 fi resolved="$(dig +short "$domain" A | head -n1 || true)" if [[ -z "$resolved" ]]; then warn "DNS A record for $domain not found yet. TLS may fail until DNS propagates." if [[ "$NONINTERACTIVE" != '1' ]]; then confirm 'Continue anyway?' fi else info "$domain → $resolved" fi } fetch_or_copy_template() { # fetch_or_copy_template NAME DEST local name="$1" local dest="$2" local local_path remote_url if [[ -n "$TEMPLATE_DIR" && -f "$TEMPLATE_DIR/$name" ]]; then cp "$TEMPLATE_DIR/$name" "$dest" return 0 fi if [[ -n "$BASE_URL" ]]; then need_cmd curl remote_url="${BASE_URL%/}/templates/$name" info "Downloading $name from $remote_url" curl -fsSL "$remote_url" -o "$dest" return 0 fi # Embedded fallback so `curl | bash` works before CDN is configured. case "$name" in compose.yaml) write_embedded_compose "$dest" ;; Caddyfile) write_embedded_caddyfile "$dest" ;; *) die "Unknown template: $name (set BUUCK_INSTALL_BASE_URL or run from the install package)" ;; esac } write_embedded_compose() { cat >"$1" <<'EOF' name: buuck services: postgres: image: postgres:16-alpine container_name: buuck-postgres environment: POSTGRES_DB: ${POSTGRES_DB} POSTGRES_USER: ${POSTGRES_USER} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} volumes: - postgres_data:/var/lib/postgresql/data healthcheck: test: ['CMD-SHELL', 'pg_isready -U ${POSTGRES_USER}'] interval: 10s timeout: 5s retries: 5 start_period: 10s restart: unless-stopped networks: - buuck cache: image: valkey/valkey:9.0.0-alpine3.22 container_name: buuck-cache command: - valkey-server - --requirepass - ${CACHE_PASSWORD} - --dir - /data - --save - '900 1' - --maxmemory - 256mb - --maxmemory-policy - allkeys-lru environment: CACHE_PASSWORD: ${CACHE_PASSWORD} volumes: - cache_data:/data healthcheck: test: ['CMD-SHELL', 'valkey-cli --no-auth-warning -a "$$CACHE_PASSWORD" ping | grep -q PONG'] interval: 10s timeout: 5s retries: 5 start_period: 10s restart: unless-stopped networks: - buuck db-migrate: image: ${BUUCK_REGISTRY:-registry.buuck.io}/buuck/db-migrate:${BUUCK_VERSION} container_name: buuck-db-migrate environment: DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB} POSTGRES_DB: ${POSTGRES_DB} POSTGRES_USER: ${POSTGRES_USER} APP_DB_USER: ${APP_DB_USER} APP_DB_PASSWORD: ${APP_DB_PASSWORD} NOTIFICATION_WORKER_USER: ${NOTIFICATION_WORKER_USER} NOTIFICATION_WORKER_PASSWORD: ${NOTIFICATION_WORKER_PASSWORD} depends_on: postgres: condition: service_healthy restart: 'no' networks: - buuck server: image: ${BUUCK_REGISTRY:-registry.buuck.io}/buuck/server:${BUUCK_VERSION} container_name: buuck-server environment: NODE_ENV: production DATABASE_URL: postgresql://${APP_DB_USER}:${APP_DB_PASSWORD}@postgres:5432/${POSTGRES_DB} DATABASE_NOTIFICATION_WORKER_URL: postgresql://${NOTIFICATION_WORKER_USER}:${NOTIFICATION_WORKER_PASSWORD}@postgres:5432/${POSTGRES_DB} CACHE_URL: valkey://:${CACHE_PASSWORD}@cache:6379 BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET} BETTER_AUTH_URL: https://${API_DOMAIN} CORS_ORIGIN: https://${DASHBOARD_DOMAIN},https://${BOOKING_DOMAIN} EMAIL_PROVIDER: ${EMAIL_PROVIDER} EMAIL_FROM: ${EMAIL_FROM} SMTP_HOST: ${SMTP_HOST} SMTP_PORT: ${SMTP_PORT} SMTP_USER: ${SMTP_USER} SMTP_PASS: ${SMTP_PASS} SMTP_SECURE: ${SMTP_SECURE} NOTIFICATION_POLL_INTERVAL_MS: ${NOTIFICATION_POLL_INTERVAL_MS} depends_on: postgres: condition: service_healthy cache: condition: service_healthy db-migrate: condition: service_completed_successfully restart: unless-stopped networks: - buuck dashboard: image: ${BUUCK_REGISTRY:-registry.buuck.io}/buuck/dashboard:${BUUCK_VERSION} container_name: buuck-dashboard environment: NODE_ENV: production ORIGIN: https://${DASHBOARD_DOMAIN} PUBLIC_SERVER_URL: https://${API_DOMAIN} depends_on: server: condition: service_healthy restart: unless-stopped networks: - buuck booking: image: ${BUUCK_REGISTRY:-registry.buuck.io}/buuck/booking:${BUUCK_VERSION} container_name: buuck-booking environment: NODE_ENV: production ORIGIN: https://${BOOKING_DOMAIN} PUBLIC_SERVER_URL: https://${API_DOMAIN} depends_on: server: condition: service_healthy restart: unless-stopped networks: - buuck caddy: image: caddy:2-alpine container_name: buuck-caddy environment: ACME_EMAIL: ${ACME_EMAIL} API_DOMAIN: ${API_DOMAIN} DASHBOARD_DOMAIN: ${DASHBOARD_DOMAIN} BOOKING_DOMAIN: ${BOOKING_DOMAIN} ports: - '80:80' - '443:443' volumes: - ./Caddyfile:/etc/caddy/Caddyfile:ro - caddy_data:/data - caddy_config:/config depends_on: - server - dashboard - booking restart: unless-stopped networks: - buuck volumes: postgres_data: driver: local cache_data: driver: local caddy_data: driver: local caddy_config: driver: local networks: buuck: driver: bridge EOF } write_embedded_caddyfile() { cat >"$1" <<'EOF' { email {$ACME_EMAIL} } {$API_DOMAIN} { reverse_proxy server:3000 } {$DASHBOARD_DOMAIN} { reverse_proxy dashboard:3001 } {$BOOKING_DOMAIN} { reverse_proxy booking:3002 } EOF } write_env_file() { local dest="$1" local q_from q_smtp_user q_smtp_pass q_from="$(env_quote "$EMAIL_FROM")" q_smtp_user="$(env_quote "$SMTP_USER")" q_smtp_pass="$(env_quote "$SMTP_PASS")" umask 077 cat >"$dest" <)' if [[ "$EMAIL_PROVIDER" == 'smtp' ]]; then prompt SMTP_HOST 'SMTP host' prompt SMTP_PORT 'SMTP port' '587' prompt_optional SMTP_USER 'SMTP user' prompt_secret SMTP_PASS 'SMTP password' prompt SMTP_SECURE 'SMTP secure (true|false)' 'false' else # API-key providers: reuse SMTP_PASS field as the API key storage hint in .env # Matching install docs: put API key in .env and drop unused SMTP_ lines later if needed. prompt_secret SMTP_PASS "API key for $EMAIL_PROVIDER" SMTP_HOST="${SMTP_HOST:-}" SMTP_PORT="${SMTP_PORT:-587}" SMTP_USER="${SMTP_USER:-}" SMTP_SECURE="${SMTP_SECURE:-false}" fi } print_summary() { log '' info 'Install summary' log " Install dir: $INSTALL_DIR" log " Registry: $REGISTRY" log " Registry user: $REGISTRY_USER" log " Version: $BUUCK_VERSION" log " API: https://$API_DOMAIN" log " Dashboard: https://$DASHBOARD_DOMAIN" log " Booking: https://$BOOKING_DOMAIN" log " ACME email: $ACME_EMAIL" log " Email provider: $EMAIL_PROVIDER" log " Email from: $EMAIL_FROM" if [[ "$EMAIL_PROVIDER" == 'smtp' ]]; then log " SMTP: $SMTP_HOST:$SMTP_PORT" fi log '' log 'Secrets (database, cache, auth) will be generated automatically.' log '' } registry_login() { info "Logging in to $REGISTRY..." # Username may contain '$' — always quote. printf '%s' "$LICENSE_KEY" | docker login "$REGISTRY" -u "$REGISTRY_USER" --password-stdin } start_stack() { info 'Pulling images...' ( cd "$INSTALL_DIR" compose_cmd pull ) info 'Starting Buuck...' ( cd "$INSTALL_DIR" compose_cmd up -d ) } wait_for_healthy() { local attempts=36 local i=1 if ! command -v curl >/dev/null 2>&1; then warn 'curl not found; skip API health check. Run: docker compose -f '"$INSTALL_DIR"'/compose.yaml ps' return 0 fi info 'Waiting for API (up to ~3 minutes)...' while ((i <= attempts)); do if curl -fsS --max-time 5 "https://${API_DOMAIN}" >/dev/null 2>&1; then info "API healthy at https://${API_DOMAIN}" return 0 fi sleep 5 ((i++)) || true done warn "Timed out waiting for https://${API_DOMAIN}. Check: cd ${INSTALL_DIR} && docker compose ps && docker compose logs" } print_done() { log '' info 'Buuck is installed.' log '' log " Dashboard: https://${DASHBOARD_DOMAIN}" log " Booking: https://${BOOKING_DOMAIN}" log " API: https://${API_DOMAIN}" log '' log 'Next steps:' log " 1. Open https://${DASHBOARD_DOMAIN} and create the first account (Sign up)." log ' 2. Create your organization, venues, resources, and offerings.' log " 3. Keep a backup of ${INSTALL_DIR}/.env off this server." log '' log "Config lives in ${INSTALL_DIR}" } generate_secrets() { POSTGRES_PASSWORD="$(rand_hex)" APP_DB_PASSWORD="$(rand_hex)" NOTIFICATION_WORKER_PASSWORD="$(rand_hex)" CACHE_PASSWORD="$(rand_hex)" BETTER_AUTH_SECRET="$(rand_hex)" } env_file_get() { # env_file_get FILE KEY → prints value (unquoted). Empty if missing. local file="$1" local key="$2" local line value line="$(grep -E "^${key}=" "$file" 2>/dev/null | tail -n1 || true)" [[ -n "$line" ]] || return 0 value="${line#"${key}="}" # Strip one layer of single quotes written by env_quote. if [[ "$value" == \'*\' ]]; then value="${value:1:${#value}-2}" value="${value//\'\\\'\'/\'}" fi printf '%s' "$value" } load_secrets_from_env_file() { local file="$1" local missing=0 local key value for key in POSTGRES_PASSWORD APP_DB_PASSWORD NOTIFICATION_WORKER_PASSWORD CACHE_PASSWORD BETTER_AUTH_SECRET; do value="$(env_file_get "$file" "$key")" if [[ -z "$value" ]]; then warn "Existing .env is missing $key" missing=1 continue fi printf -v "$key" '%s' "$value" done [[ "$missing" -eq 0 ]] || die "Existing .env is incomplete. Fix it, or re-run with BUUCK_RESET_SECRETS=1 (destructive)." info "Preserving secrets from existing .env" } prepare_secrets() { # Prefer keeping secrets that match existing Docker volumes. if [[ -f "$INSTALL_DIR/.env" && "$RESET_SECRETS" != '1' ]]; then if [[ "$NONINTERACTIVE" == '1' ]]; then load_secrets_from_env_file "$INSTALL_DIR/.env" return 0 fi if ask_yes 'Regenerate secrets? This breaks an existing database/cache (destructive).'; then warn 'Generating new secrets. Existing postgres_data/cache_data volumes will not match.' generate_secrets else load_secrets_from_env_file "$INSTALL_DIR/.env" fi return 0 fi if [[ "$RESET_SECRETS" == '1' && -f "$INSTALL_DIR/.env" ]]; then warn 'BUUCK_RESET_SECRETS=1: generating new secrets. Existing volumes will not match unless you also remove them.' fi generate_secrets } write_install_files() { info "Creating $INSTALL_DIR" mkdir -p "$INSTALL_DIR" if [[ -f "$INSTALL_DIR/.env" ]]; then warn "$INSTALL_DIR/.env already exists." confirm 'Overwrite existing installation files (compose, Caddyfile, .env)?' fi fetch_or_copy_template compose.yaml "$INSTALL_DIR/compose.yaml" fetch_or_copy_template Caddyfile "$INSTALL_DIR/Caddyfile" write_env_file "$INSTALL_DIR/.env" } main() { info 'Buuck installer' load_env_defaults if [[ "$WRITE_FILES_ONLY" == '1' ]]; then collect_input print_summary confirm 'Write install files with these settings (no Docker)?' prepare_secrets write_install_files info "Wrote files to ${INSTALL_DIR} (WRITE_FILES_ONLY)." return 0 fi is_root || die 'Run as root (or: sudo bash install.sh).' check_os check_arch collect_input print_summary confirm 'Write files and start Buuck with these settings?' ensure_docker ensure_compose maybe_configure_firewall check_dns "$API_DOMAIN" check_dns "$DASHBOARD_DOMAIN" check_dns "$BOOKING_DOMAIN" prepare_secrets write_install_files registry_login start_stack wait_for_healthy print_done } # Run main only when executed directly (not when sourced by tests). if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then main "$@" fi