#!/usr/bin/env bash # GhostGrid - Proxmox LXC Installer # # Run on the Proxmox host as root: # bash proxmox-ghostgrid.sh # # Creates an unprivileged Debian 12 LXC, installs Node.js 20 and Caddy, # deploys two GhostGrid instances and exposes them through Caddy with # internally generated TLS certificates: # main -> ghostgrid.service -> 127.0.0.1:3000 -> https:// # dev -> ghostgrid-dev.service -> 127.0.0.1:3001 -> https:// set -euo pipefail # ── Defaults ─────────────────────────────────────────────────────────── APP="GhostGrid" REPO_URL_DEFAULT="https://git.airit.rocks/jbrueckner/GhostGrid.git" REPO_BRANCH="main" DEV_BRANCH="dev" HOSTNAME_DEFAULT="ghostgrid" DISK_DEFAULT="8" CPU_DEFAULT="2" RAM_DEFAULT="1024" BRIDGE_DEFAULT="vmbr0" ROOTFS_STORAGE_DEFAULT="local-lvm" TEMPLATE_STORAGE_DEFAULT="local" APP_PORT="3000" DEV_PORT="3001" APP_DIR="/opt/ghostgrid" DEV_DIR="/opt/ghostgrid-dev" PROD_DNS_DEFAULT="ghostgrid.local" DEV_DNS_DEFAULT="ghostgrid-dev.local" DNS_RESOLVER_DEFAULT="1.1.1.1" # ── Output helpers ───────────────────────────────────────────────────── YW=$'\033[33m'; GN=$'\033[1;92m'; RD=$'\033[01;31m'; BL=$'\033[36m'; CL=$'\033[m' CM="${GN}✔${CL}"; CROSS="${RD}✗${CL}"; INFO="${BL}ℹ${CL}" msg_info() { echo -e " ${YW}➤${CL} $1"; } msg_ok() { echo -e " ${CM} $1"; } msg_err() { echo -e " ${CROSS} ${RD}$1${CL}"; } header() { echo -e "${BL}" echo " ____ _ _ ____ _ _ " echo " / ___| |__ ___ ___| |_ / ___|_ __(_) __| |" echo " | | _| '_ \\ / _ \\/ __| __| | _| '__| |/ _\` |" echo " | |_| | | | | (_) \\__ \\ |_| |_| | | | | (_| |" echo " \\____|_| |_|\\___/|___/\\__|\\____|_| |_|\\__,_|" echo -e " Proxmox LXC Installer${CL}\n" } trap 'msg_err "Abort in line $LINENO."; exit 1' ERR ask_input() { local title="$1" local prompt="$2" local default_value="${3:-}" whiptail --inputbox "$prompt" 8 72 "$default_value" --title "$title" 3>&1 1>&2 2>&3 } ask_password() { local title="$1" local prompt="$2" whiptail --passwordbox "$prompt" 9 72 "" --title "$title" 3>&1 1>&2 2>&3 || true } require_value() { local name="$1" local value="$2" [[ -n "$value" ]] || { msg_err "$name must not be empty."; exit 1; } } # ── Prerequisites ────────────────────────────────────────────────────── header [[ "$(id -u)" -eq 0 ]] || { msg_err "Please run this script as root on the Proxmox host."; exit 1; } command -v pct >/dev/null 2>&1 || { msg_err "'pct' was not found. Are you running this on a Proxmox VE host?"; exit 1; } command -v whiptail >/dev/null 2>&1 || { msg_err "'whiptail' was not found."; exit 1; } # ── Interactive configuration ────────────────────────────────────────── NEXTID="$(pvesh get /cluster/nextid 2>/dev/null || echo 100)" CTID=$(ask_input "$APP - LXC" "Container-ID (CTID)" "$NEXTID") CT_HOSTNAME=$(ask_input "$APP - LXC" "LXC hostname" "$HOSTNAME_DEFAULT") CPU=$(ask_input "$APP - Resources" "vCPU cores" "$CPU_DEFAULT") RAM=$(ask_input "$APP - Resources" "RAM in MB" "$RAM_DEFAULT") DISK=$(ask_input "$APP - Resources" "Disk in GB" "$DISK_DEFAULT") ROOTFS_STORAGE=$(ask_input "$APP - Storage" "rootfs-Storage" "$ROOTFS_STORAGE_DEFAULT") TEMPLATE_STORAGE=$(ask_input "$APP - Storage" "Template-Storage" "$TEMPLATE_STORAGE_DEFAULT") BRIDGE=$(ask_input "$APP - Network" "Network bridge" "$BRIDGE_DEFAULT") if whiptail --title "$APP - Network" --yesno "Should the container use DHCP?\n\nNo = configure a static IP address during installation." 10 72; then NET="dhcp" DNS_RESOLVER="" else NET="static" IPADDR=$(ask_input "$APP - Network" "Static IP in CIDR notation, for example 172.16.66.50/24" "") GATEWAY=$(ask_input "$APP - Network" "Gateway, for example 172.16.66.1" "") DNS_RESOLVER=$(ask_input "$APP - Network" "DNS resolver for the container" "$DNS_RESOLVER_DEFAULT") require_value "Static IP" "$IPADDR" require_value "Gateway" "$GATEWAY" fi PROD_DNS=$(ask_input "$APP - Caddy" "DNS name for main / production" "$PROD_DNS_DEFAULT") DEV_DNS=$(ask_input "$APP - Caddy" "DNS name for dev / staging" "$DEV_DNS_DEFAULT") require_value "Production DNS-Name" "$PROD_DNS" require_value "Development DNS-Name" "$DEV_DNS" if [[ "$PROD_DNS" == "$DEV_DNS" ]]; then msg_err "Production and development DNS names must not be identical." exit 1 fi REPO_URL=$(ask_input "$APP - Git" "Git-Repository (HTTPS)" "$REPO_URL_DEFAULT") ACCESS_TOKEN=$(ask_password "$APP - Git" "Gitea read-only access token\nLeave empty if the repository is public.") ROOT_PW=$(ask_password "$APP - LXC" "Root password for the container\nEmpty = no password; access only through 'pct enter ${CTID}'.") require_value "Git-Repository" "$REPO_URL" # ── Ensure template ──────────────────────────────────────────────────── msg_info "Updating template list" pveam update >/dev/null 2>&1 || true TEMPLATE="$(pveam available --section system 2>/dev/null | awk '/debian-12-standard/{print $2}' | sort -V | tail -n1)" [[ -n "$TEMPLATE" ]] || { msg_err "No Debian 12 standard template found."; exit 1; } if ! pveam list "$TEMPLATE_STORAGE" 2>/dev/null | grep -q "$TEMPLATE"; then msg_info "Downloading template $TEMPLATE" pveam download "$TEMPLATE_STORAGE" "$TEMPLATE" >/dev/null fi msg_ok "Template ready: $TEMPLATE" # ── Create container ─────────────────────────────────────────────────── if [[ "$NET" == "dhcp" ]]; then NETCONF="name=eth0,bridge=${BRIDGE},ip=dhcp" else NETCONF="name=eth0,bridge=${BRIDGE},ip=${IPADDR},gw=${GATEWAY}" fi msg_info "Creating LXC $CTID ($CT_HOSTNAME)" CREATE_OPTS=( --hostname "$CT_HOSTNAME" --cores "$CPU" --memory "$RAM" --rootfs "${ROOTFS_STORAGE}:${DISK}" --net0 "$NETCONF" --unprivileged 1 --features keyctl=1,nesting=1 --onboot 1 ) [[ -n "$ROOT_PW" ]] && CREATE_OPTS+=(--password "$ROOT_PW") [[ "$NET" == "static" && -n "$DNS_RESOLVER" ]] && CREATE_OPTS+=(--nameserver "$DNS_RESOLVER") pct create "$CTID" "${TEMPLATE_STORAGE}:vztmpl/${TEMPLATE}" "${CREATE_OPTS[@]}" >/dev/null msg_ok "Container created" msg_info "Starting container" pct start "$CTID" >/dev/null # Wait for basic network/DNS availability. for _ in $(seq 1 45); do pct exec "$CTID" -- getent hosts deb.nodesource.com >/dev/null 2>&1 && break sleep 2 done msg_ok "Container is running" # ── In-container helper ──────────────────────────────────────────────── run() { pct exec "$CTID" -- bash -c "export LANG=C.UTF-8 LC_ALL=C.UTF-8; $1"; } msg_info "Installing base packages, Node.js 20 LTS, and Caddy" run "export DEBIAN_FRONTEND=noninteractive; apt-get update -qq && apt-get install -y -qq curl git ca-certificates build-essential python3 sudo gnupg debian-keyring debian-archive-keyring apt-transport-https caddy >/dev/null" run "curl -fsSL https://deb.nodesource.com/setup_20.x | bash - >/dev/null 2>&1 && apt-get install -y -qq nodejs >/dev/null" msg_ok "Node.js $(run 'node --version') installed" msg_ok "Caddy installed" msg_info "Creating service user and cloning both branches" run "id ghostgrid >/dev/null 2>&1 || useradd --system --home-dir ${APP_DIR} --shell /usr/sbin/nologin ghostgrid" run "mkdir -p ${APP_DIR} ${DEV_DIR} && chown ghostgrid:ghostgrid ${APP_DIR} ${DEV_DIR}" if [[ -n "$ACCESS_TOKEN" ]]; then CLONE_URL="$(echo "$REPO_URL" | sed -E "s#https://#https://oauth2:${ACCESS_TOKEN}@#")" else CLONE_URL="$REPO_URL" fi run "sudo -u ghostgrid git clone --branch ${REPO_BRANCH} '${CLONE_URL}' ${APP_DIR}" run "sudo -u ghostgrid git clone --branch ${DEV_BRANCH} '${CLONE_URL}' ${DEV_DIR}" run "chmod 600 ${APP_DIR}/.git/config ${DEV_DIR}/.git/config" msg_ok "Repositories cloned (main + dev)" msg_info "Creating .env file for each instance" for d in "${APP_DIR}" "${DEV_DIR}"; do SECRET="$(openssl rand -hex 32)" run "printf 'JWT_SECRET=\"%s\"\n' '${SECRET}' > ${d}/.env && chown ghostgrid:ghostgrid ${d}/.env && chmod 600 ${d}/.env" done msg_ok ".env files created (main + dev)" msg_info "Installing dependencies and building both instances" for d in "${APP_DIR}" "${DEV_DIR}"; do run "cd ${d} && sudo -u ghostgrid npm ci --no-audit --no-fund >/dev/null 2>&1 && sudo -u ghostgrid npm run build >/dev/null 2>&1" done msg_ok "Build completed (main + dev)" msg_info "Configuring systemd services" run "cp ${APP_DIR}/deploy/ghostgrid.service /etc/systemd/system/ghostgrid.service" run "cp ${DEV_DIR}/deploy/ghostgrid-dev.service /etc/systemd/system/ghostgrid-dev.service" run "printf 'ghostgrid ALL=(root) NOPASSWD: /usr/bin/systemctl restart ghostgrid, /usr/bin/systemctl restart ghostgrid-dev\n' > /etc/sudoers.d/ghostgrid && chmod 440 /etc/sudoers.d/ghostgrid" run "systemctl daemon-reload && systemctl enable --now ghostgrid ghostgrid-dev >/dev/null 2>&1" msg_ok "Services are active (ghostgrid + ghostgrid-dev)" msg_info "Configuring Caddy with internally generated TLS certificates" run "cat > /etc/caddy/Caddyfile <<'CADDYEOF' { local_certs } ${PROD_DNS} { encode zstd gzip tls internal reverse_proxy 127.0.0.1:${APP_PORT} } ${DEV_DNS} { encode zstd gzip tls internal reverse_proxy 127.0.0.1:${DEV_PORT} } CADDYEOF caddy fmt --overwrite /etc/caddy/Caddyfile >/dev/null caddy validate --config /etc/caddy/Caddyfile >/dev/null systemctl enable --now caddy >/dev/null 2>&1 systemctl reload caddy >/dev/null 2>&1" msg_ok "Caddy is active (${PROD_DNS}, ${DEV_DNS})" # ── Final output ─────────────────────────────────────────────────────── IP="$(pct exec "$CTID" -- ip -4 addr show eth0 2>/dev/null | awk '/inet /{print $2}' | cut -d/ -f1 | head -n1)" msg_info "Writing Proxmox notes" pct set "$CTID" --description "# ${APP} This container runs two GhostGrid instances in parallel: main (production) and dev (staging), each with its own SQLite database. ## Caddy HTTPS - Production: https://${PROD_DNS} - Development: https://${DEV_DNS} - Caddy uses internally generated certificates through 'tls internal'. - Both DNS names must internally resolve to ${IP}. - Caddy root CA inside the container: /var/lib/caddy/.local/share/caddy/pki/authorities/local/root.crt ## Production (main) - Branch: ${REPO_BRANCH} - Directory: ${APP_DIR} - Service: ghostgrid - Internal upstream: 127.0.0.1:${APP_PORT} ## Development (dev) - Branch: ${DEV_BRANCH} - Directory: ${DEV_DIR} - Service: ghostgrid-dev - Internal upstream: 127.0.0.1:${DEV_PORT} ## Gitea - Repo: ${REPO_URL} ## Update from Proxmox host - Prod: pct exec ${CTID} -- sudo -u ghostgrid ${APP_DIR}/deploy/deploy.sh main - Dev: pct exec ${CTID} -- sudo -u ghostgrid ${DEV_DIR}/deploy/deploy.sh dev Installed via deploy/proxmox-ghostgrid.sh" >/dev/null msg_ok "Notes written" echo msg_ok "${GN}${APP} has been installed!${CL}" echo -e " ${INFO} CTID: ${BL}${CTID}${CL}" echo -e " ${INFO} LXC IP: ${BL}${IP}${CL}" echo -e " ${INFO} Prod (main): ${BL}https://${PROD_DNS}${CL}" echo -e " ${INFO} Dev (dev): ${BL}https://${DEV_DNS}${CL}" echo -e " ${INFO} DNS note: ${BL}${PROD_DNS} and ${DEV_DNS} must resolve to ${IP}.${CL}" echo -e " ${INFO} Caddy Root CA: ${BL}/var/lib/caddy/.local/share/caddy/pki/authorities/local/root.crt${CL}" echo -e " ${INFO} Update prod: ${BL}pct exec ${CTID} -- sudo -u ghostgrid ${APP_DIR}/deploy/deploy.sh main${CL}" echo -e " ${INFO} Update dev: ${BL}pct exec ${CTID} -- sudo -u ghostgrid ${DEV_DIR}/deploy/deploy.sh dev${CL}" echo