Files
GhostGrid/deploy/proxmox-ghostgrid.sh
Brückner 515052fbda refactor: replace CADDY_MANAGER with DEPLOY_ENV for instance-role awareness
DEPLOY_ENV=production now marks the primary instance globally - used for
Caddy ownership, the Dev/Prod header badge, and Caddy UI gating. Removes
build-time VITE_DEPLOY_ENV/import.meta.env.DEV from the header in favour
of the runtime API response (isProduction field in /api/auth/config).
2026-06-10 14:43:31 +02:00

287 lines
12 KiB
Bash
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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://<main-dns>
# dev -> ghostgrid-dev.service -> 127.0.0.1:3001 -> https://<dev-dns>
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"
run "chmod +x ${APP_DIR}/deploy/deploy.sh ${DEV_DIR}/deploy/deploy.sh"
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"
# Only the production instance owns Caddy and shows "Production" in the UI.
[[ "$d" == "${APP_DIR}" ]] && run "printf 'DEPLOY_ENV=production\n' >> ${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