#!/usr/bin/env bash
# ┌─────────────────────────────────────────────────────────────────────────┐
# │ Hotel Manage — Proxmox Stack Installer │
# │ Provisions: postgres · auth · portal · npm · management · noticeboard │
# │ │
# │ Run on the Proxmox host shell: │
# │ bash install-stack.sh │
# │ │
# │ Or from Forgejo once repos are pushed: │
# │ bash <(curl -fsSL https://git.pterois.co.uk/hotel-manage-stack/stack-init/raw/branch/main/install-stack.sh)
# └─────────────────────────────────────────────────────────────────────────┘
set -euo pipefail
# ── Colour helpers ────────────────────────────────────────────────────────────
YW="\033[33m"; BL="\033[36m"; RD="\033[01;31m"
GN="\033[1;92m"; DGN="\033[32m"; CL="\033[m"
BFR="\\r\\033[K"; CM="${GN}✓${CL}"; CROSS="${RD}✗${CL}"
msg_info() { printf " ◌ ${YW}%-55s${CL}" "$*"; }
msg_ok() { printf "${BFR} ${CM} ${DGN}%s${CL}\n" "$*"; }
msg_error() { printf "${BFR} ${CROSS} ${RD}%s${CL}\n" "$*"; exit 1; }
msg_warn() { printf "\n ${CROSS} ${YW}%s${CL}\n" "$*"; }
msg_step() { printf "\n${BL}── %s ─────────────────────────────${CL}\n" "$*"; }
header_info() {
clear
printf "${BL}"
cat <<'BANNER'
╔══════════════════════════════════════════════════════════════╗
║ HOTEL MANAGE — PROXMOX STACK INSTALLER ║
║ postgres · auth · portal · npm · mgmt · noticeboard · settings ║
╚══════════════════════════════════════════════════════════════╝
BANNER
printf "${CL}\n"
}
# ── Pre-flight ────────────────────────────────────────────────────────────────
[[ $EUID -ne 0 ]] && msg_error "Must run as root on the Proxmox VE host"
command -v pct &>/dev/null || msg_error "pct not found — run this on a Proxmox VE host"
command -v pvesm &>/dev/null || msg_error "pvesm not found — run this on a Proxmox VE host"
command -v whiptail &>/dev/null || { apt-get install -y -qq whiptail &>/dev/null; }
command -v openssl &>/dev/null || { apt-get install -y -qq openssl &>/dev/null; }
# Defensive — when run via `bash <(curl ...)` BASH_SOURCE is a pipe, not a file
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd || echo /tmp)"
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." 2>/dev/null && pwd || echo /tmp)"
header_info
# ── Detect storage pool ───────────────────────────────────────────────────────
detect_storage() {
if pvesm status 2>/dev/null | awk '{print $1}' | grep -q "^local-lvm$"; then
echo "local-lvm"
elif pvesm status 2>/dev/null | awk '{print $1}' | grep -q "^local-zfs$"; then
echo "local-zfs"
else
echo "local"
fi
}
STORAGE=$(detect_storage)
# ── Internal network: bridge + NAT ────────────────────────────────────────────
# The isolated vmbr1 needs outbound NAT so LXCs can apt-install Docker, git
# clone, and pull images. Persisted via an if-up.d hook so it survives reboot.
ensure_nat() {
local wan; wan=$(ip route show default 2>/dev/null | awk '{print $5; exit}'); wan=${wan:-vmbr0}
msg_info "Enabling internal NAT (10.10.10.0/24 → ${wan})"
sysctl -wq net.ipv4.ip_forward=1 2>/dev/null || echo 1 > /proc/sys/net/ipv4/ip_forward
echo 'net.ipv4.ip_forward=1' > /etc/sysctl.d/99-hotel-manage.conf
iptables -t nat -C POSTROUTING -s 10.10.10.0/24 ! -d 10.10.10.0/24 -o "$wan" -j MASQUERADE 2>/dev/null \
|| iptables -t nat -A POSTROUTING -s 10.10.10.0/24 ! -d 10.10.10.0/24 -o "$wan" -j MASQUERADE
cat > /etc/network/if-up.d/hotel-manage-nat <<'HOOK'
#!/bin/sh
[ "$IFACE" = "vmbr1" ] || exit 0
WAN=$(ip route show default | awk '{print $5; exit}'); WAN=${WAN:-vmbr0}
sysctl -w net.ipv4.ip_forward=1 >/dev/null 2>&1
iptables -t nat -C POSTROUTING -s 10.10.10.0/24 ! -d 10.10.10.0/24 -o "$WAN" -j MASQUERADE 2>/dev/null \
|| iptables -t nat -A POSTROUTING -s 10.10.10.0/24 ! -d 10.10.10.0/24 -o "$WAN" -j MASQUERADE
HOOK
chmod +x /etc/network/if-up.d/hotel-manage-nat
msg_ok "Internal NAT enabled (via ${wan})"
}
check_vmbr1() {
if ip link show vmbr1 &>/dev/null; then
ensure_nat # bridge exists — make sure NAT/forwarding is in place
return
fi
if whiptail --title "Create internal bridge vmbr1?" --yesno \
"The internal container bridge vmbr1 (10.10.10.1/24) doesn't exist yet.
Create it now? This adds an ISOLATED bridge with no physical ports,
so it cannot affect your LAN or vmbr0. It appends a stanza to
/etc/network/interfaces, applies it with ifreload -a, and enables
outbound NAT so the containers can reach the internet." 15 68; then
if grep -qE '^\s*iface\s+vmbr1' /etc/network/interfaces 2>/dev/null; then
msg_warn "vmbr1 already defined in /etc/network/interfaces — applying it"
else
cat >> /etc/network/interfaces <<'EOF'
auto vmbr1
iface vmbr1 inet static
address 10.10.10.1/24
bridge-ports none
bridge-stp off
bridge-fd 0
EOF
fi
msg_info "Bringing up vmbr1"
ifreload -a &>/dev/null || systemctl restart networking &>/dev/null || true
sleep 2
if ip link show vmbr1 &>/dev/null; then
msg_ok "vmbr1 up (10.10.10.1/24)"
else
msg_error "vmbr1 still not up — check /etc/network/interfaces and re-run"
fi
ensure_nat
else
whiptail --title "vmbr1 Missing" --msgbox \
"Cancelled. Add this to /etc/network/interfaces, run 'ifreload -a',
then re-run the installer:
auto vmbr1
iface vmbr1 inet static
address 10.10.10.1/24
bridge-ports none
bridge-stp off
bridge-fd 0" 16 66
exit 1
fi
}
# ── Ensure Ubuntu 22.04 template ─────────────────────────────────────────────
ensure_template() {
# NOTE: progress goes to stderr — stdout is captured as the template volid.
local tmpl
tmpl=$(pveam list local 2>/dev/null | awk '/ubuntu-22\.04-standard/{print $1; exit}')
if [[ -z "$tmpl" ]]; then
msg_info "Downloading Ubuntu 22.04 LXC template" >&2
pveam update &>/dev/null || true
# Resolve the exact filename currently offered (pinned names go stale)
local avail
avail=$(pveam available --section system 2>/dev/null \
| awk '/ubuntu-22\.04-standard/{print $2}' | sort -V | tail -1)
[[ -n "$avail" ]] || { msg_error "No ubuntu-22.04 template available via pveam" >&2; exit 1; }
pveam download local "$avail" &>/dev/null || { msg_error "Template download failed" >&2; exit 1; }
msg_ok "Template downloaded" >&2
tmpl="$avail"
fi
# Return full path for pct create
echo "local:vztmpl/${tmpl##*/}"
}
# ── Collect site config ───────────────────────────────────────────────────────
collect_config() {
SITE_NAME=$(whiptail --title "Hotel Manage — Site Config" \
--inputbox "Site name:" 8 52 "Hotel Number Four" 3>&1 1>&2 2>&3) || exit 0
DOMAIN=$(whiptail --title "Hotel Manage — Site Config" \
--inputbox "Public domain (e.g. manage.hotelnumberfour.com):" 8 64 "manage.hotelnumberfour.com" \
3>&1 1>&2 2>&3) || exit 0
NPM_LAN_IP=$(whiptail --title "Hotel Manage — Site Config" \
--inputbox "NPM LXC static LAN IP (from your hotel LAN pool):" 8 64 "10.4.0.50" \
3>&1 1>&2 2>&3) || exit 0
LAN_GW=$(whiptail --title "Hotel Manage — Site Config" \
--inputbox "LAN gateway IP:" 8 52 "10.4.0.1" 3>&1 1>&2 2>&3) || exit 0
OFFICE_IP=$(whiptail --title "Hotel Manage — Site Config" \
--inputbox \
"Onsite matcher(s) for offsite restriction — comma-separated, any match = onsite.
Each can be: an IP, a CIDR, a DDNS hostname, or 'auto' (self-detect the site's
public IP — no DDNS service needed). 'disabled' allows access from anywhere.
Recommended for a dynamic public IP: 10.4.0.0/22,auto
(LAN clients match the CIDR; public-IP/hairpin clients match auto)" \
13 72 "10.4.0.0/22,auto" 3>&1 1>&2 2>&3) || exit 0
ADMIN_EMAIL=$(whiptail --title "Hotel Manage — Admin Account" \
--inputbox "Admin user email:" 8 52 "" 3>&1 1>&2 2>&3) || exit 0
ADMIN_PASS=$(whiptail --title "Hotel Manage — Admin Account" \
--passwordbox "Admin user password:" 8 52 3>&1 1>&2 2>&3) || exit 0
# Deploy source — per-service Forgejo repos (default) or a local copy on this host
FORGEJO_BASE=$(whiptail --title "Hotel Manage — Forgejo" \
--inputbox \
"Forgejo org/base URL hosting the per-service repos.
Each service is cloned from /.git
→ auth portal management noticeboard
Example: https://git.pterois.co.uk/hotel-manage-stack" \
13 66 "https://git.pterois.co.uk/hotel-manage-stack" 3>&1 1>&2 2>&3) || exit 0
FORGEJO_BASE="${FORGEJO_BASE%/}"
FORGEJO_TOKEN=$(whiptail --title "Hotel Manage — Forgejo Token" \
--passwordbox \
"The repos are PUBLIC — leave this blank.
(Only needed if you make them private again: an access token
from Forgejo → Settings → Applications, scope read:repository,
which gets embedded in each LXC's git remote for the updater.)" \
12 66 3>&1 1>&2 2>&3) || exit 0
if whiptail --title "Hotel Manage — Deploy Source" --yesno \
"Deploy services from Forgejo? (recommended)\n\nNo = copy from a local repo at ${REPO_ROOT}\n(only works if you already copied the repo to this host)" \
11 62; then
USE_FORGEJO=true
else
USE_FORGEJO=false
fi
BACKUP_REMOTE=$(whiptail --title "Hotel Manage — Backup" \
--inputbox \
"Backup rsync target (leave blank to skip backup config).
Example: backup@192.168.1.10:/backups/hotel-manage" \
10 64 "" 3>&1 1>&2 2>&3) || exit 0
# Confirm LXC allocation
whiptail --title "Hotel Manage — Confirm" --yesno \
"LXCs to be created (storage: ${STORAGE}):
ID Hostname IP
──────────────────────────────────────────
100 hotel-manage-postgres 10.10.10.100
101 hotel-manage-auth 10.10.10.101
102 hotel-manage-portal 10.10.10.102
103 hotel-manage-npm 10.10.10.103 / ${NPM_LAN_IP} (dual-homed)
105 hotel-manage-management 10.10.10.105
112 hotel-manage-noticeboard 10.10.10.112
116 hotel-manage-settings 10.10.10.116
Domain: ${DOMAIN}
Admin: ${ADMIN_EMAIL}
Proceed?" 26 58 || exit 0
}
# ── Generate secrets ──────────────────────────────────────────────────────────
CREDS_FILE=/root/hotel-manage-credentials.txt
gen_secrets() {
# Resume-safe: if secrets already exist, reuse them so a re-run doesn't
# mismatch an already-initialised Postgres / already-deployed services.
if [[ -f "$CREDS_FILE" ]] && grep -q '^PG_SUPERPASS=' "$CREDS_FILE"; then
msg_info "Reusing existing secrets from $CREDS_FILE"
PG_SUPERPASS=$(sed -n 's/^PG_SUPERPASS=//p' "$CREDS_FILE" | head -1)
AUTH_DB_PASS=$(sed -n 's/^AUTH_DB_PASS=//p' "$CREDS_FILE" | head -1)
NOTICES_DB_PASS=$(sed -n 's/^NOTICES_DB_PASS=//p' "$CREDS_FILE" | head -1)
SETTINGS_DB_PASS=$(sed -n 's/^SETTINGS_DB_PASS=//p' "$CREDS_FILE" | head -1)
CASHUP_DB_PASS=$(sed -n 's/^CASHUP_DB_PASS=//p' "$CREDS_FILE" | head -1)
HK_PLANNER_DB_PASS=$(sed -n 's/^HK_PLANNER_DB_PASS=//p' "$CREDS_FILE" | head -1)
TWIN_OPT_DB_PASS=$(sed -n 's/^TWIN_OPT_DB_PASS=//p' "$CREDS_FILE" | head -1)
ROOM_PLANNER_DB_PASS=$(sed -n 's/^ROOM_PLANNER_DB_PASS=//p' "$CREDS_FILE" | head -1)
CENTRAL_AUTH_SECRET=$(sed -n 's/^CENTRAL_AUTH_SECRET=//p' "$CREDS_FILE" | head -1)
SETTINGS_SECRET=$(sed -n 's/^SETTINGS_SECRET=//p' "$CREDS_FILE" | head -1)
WEBHOOK_SECRET=$(sed -n 's/^WEBHOOK_SECRET=//p' "$CREDS_FILE" | head -1)
NPM_LAN_IP=$(sed -n 's/^NPM_LAN_IP=//p' "$CREDS_FILE" | head -1)
NPM_ADMIN_EMAIL=$(sed -n 's/^NPM_ADMIN_EMAIL=//p' "$CREDS_FILE" | head -1)
NPM_ADMIN_PASS=$(sed -n 's/^NPM_ADMIN_PASS=//p' "$CREDS_FILE" | head -1)
msg_ok "Reusing existing secrets"
return
fi
msg_info "Generating secrets"
PG_SUPERPASS=$(openssl rand -base64 24 | tr -dc 'a-zA-Z0-9' | head -c 24)
AUTH_DB_PASS=$(openssl rand -base64 24 | tr -dc 'a-zA-Z0-9' | head -c 24)
NOTICES_DB_PASS=$(openssl rand -base64 24 | tr -dc 'a-zA-Z0-9' | head -c 24)
SETTINGS_DB_PASS=$(openssl rand -base64 24 | tr -dc 'a-zA-Z0-9' | head -c 24)
CASHUP_DB_PASS=$(openssl rand -base64 24 | tr -dc 'a-zA-Z0-9' | head -c 24)
HK_PLANNER_DB_PASS=$(openssl rand -base64 24 | tr -dc 'a-zA-Z0-9' | head -c 24)
TWIN_OPT_DB_PASS=$(openssl rand -base64 24 | tr -dc 'a-zA-Z0-9' | head -c 24)
ROOM_PLANNER_DB_PASS=$(openssl rand -base64 24 | tr -dc 'a-zA-Z0-9' | head -c 24)
CENTRAL_AUTH_SECRET=$(openssl rand -hex 32)
SETTINGS_SECRET=$(openssl rand -hex 32)
WEBHOOK_SECRET=$(openssl rand -hex 24)
NPM_ADMIN_PASS=$(openssl rand -base64 12 | tr -dc 'a-zA-Z0-9' | head -c 12)
cat > "$CREDS_FILE" </dev/null
msg_ok "SSH keypair generated → /root/.ssh/hotel-manage_deploy"
else
msg_ok "Using existing SSH keypair at /root/.ssh/hotel-manage_deploy"
fi
MGMT_PUBKEY=$(cat /root/.ssh/hotel-manage_deploy.pub)
# record the pubkey once (don't duplicate on re-run)
grep -qF "${MGMT_PUBKEY}" "$CREDS_FILE" 2>/dev/null || echo "${MGMT_PUBKEY}" >> "$CREDS_FILE"
}
# ── LXC lifecycle helpers ─────────────────────────────────────────────────────
TEMPLATE_PATH=""
get_template() {
[[ -n "$TEMPLATE_PATH" ]] && { echo "$TEMPLATE_PATH"; return; }
TEMPLATE_PATH=$(ensure_template)
echo "$TEMPLATE_PATH"
}
lxc_exists() { pct status "$1" &>/dev/null; }
lxc_running() {
pct status "$1" 2>/dev/null | grep -q "running"
}
# Group the stack in a Proxmox resource pool + tag for tidy UI organisation.
POOL="hotel-manage"
POOL_OPT=""
ensure_pool() {
if ! pvesh get "/pools/${POOL}" &>/dev/null; then
msg_info "Creating Proxmox resource pool '${POOL}'"
pvesh create /pools --poolid "${POOL}" --comment "Hotel Manage stack" &>/dev/null || true
pvesh get "/pools/${POOL}" &>/dev/null && msg_ok "Pool '${POOL}' ready" \
|| msg_warn "Could not create pool '${POOL}' — continuing without it"
fi
# Only pass --pool if it actually exists (else pct create would fail)
pvesh get "/pools/${POOL}" &>/dev/null && POOL_OPT="--pool ${POOL}"
}
# Docker-in-LXC: keep the nesting=1 feature (needed by Docker) and let each
# container skip AppArmor via `security_opt: apparmor=unconfined` in its compose.
# (Overriding lxc.apparmor.profile would cancel nesting — don't do that.)
create_lxc() {
local id=$1 ip=$2 name=$3 mem=${4:-512} cores=${5:-1}
if lxc_exists "$id"; then
msg_warn "LXC $id (hotel-manage-${name}) already exists — skipping creation"
lxc_running "$id" || pct start "$id"
return
fi
local tmpl; tmpl=$(get_template)
pct create "$id" "$tmpl" \
--hostname "hotel-manage-${name}" \
--memory "$mem" \
--cores "$cores" \
--rootfs "${STORAGE}:8" \
--net0 "name=eth0,bridge=vmbr1,ip=${ip}/24,gw=10.10.10.1" \
--features nesting=1 \
--unprivileged 0 \
--onboot 1 \
--tags "${POOL}" \
${POOL_OPT} \
--start 1 &>/dev/null
sleep 5
}
create_npm_lxc() {
local id=103
if lxc_exists "$id"; then
msg_warn "LXC $id (hotel-manage-npm) already exists — skipping creation"
lxc_running "$id" || pct start "$id"
return
fi
local tmpl; tmpl=$(get_template)
pct create "$id" "$tmpl" \
--hostname "hotel-manage-npm" \
--memory 512 \
--cores 1 \
--rootfs "${STORAGE}:8" \
--net0 "name=eth0,bridge=vmbr0,ip=${NPM_LAN_IP}/22,gw=${LAN_GW}" \
--net1 "name=eth1,bridge=vmbr1,ip=10.10.10.3/24" \
--features nesting=1 \
--unprivileged 0 \
--onboot 1 \
--tags "${POOL}" \
${POOL_OPT} \
--start 1 &>/dev/null
sleep 5
}
install_docker() {
local id=$1
pct exec "$id" -- bash -s &>/dev/null <<'DOCKER_INSTALL'
export DEBIAN_FRONTEND=noninteractive
# Docker-in-LXC: neutralise AppArmor so Docker never tries to load a profile.
# In a confined LXC that fails ("docker-default ... while confined") for BOTH
# image builds and container runtime. Removing apparmor_parser makes Docker
# run everything unconfined — the container itself is the isolation boundary.
if [ -e /usr/sbin/apparmor_parser ]; then
mv -f /usr/sbin/apparmor_parser /usr/sbin/apparmor_parser.disabled
systemctl is-active --quiet docker && systemctl restart docker
fi
# Idempotent: if Docker's already here, just make sure it's running and bail.
if command -v docker >/dev/null 2>&1; then
systemctl enable --now docker ssh 2>/dev/null
exit 0
fi
apt-get update -qq 2>/dev/null
apt-get install -y -qq ca-certificates curl gnupg git openssh-server 2>/dev/null
install -m 0755 -d /etc/apt/keyrings
rm -f /etc/apt/keyrings/docker.gpg
curl -fsSL https://download.docker.com/linux/ubuntu/gpg \
| gpg --batch --yes --dearmor -o /etc/apt/keyrings/docker.gpg 2>/dev/null
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/docker.gpg] \
https://download.docker.com/linux/ubuntu jammy stable" \
> /etc/apt/sources.list.d/docker.list
apt-get update -qq 2>/dev/null
apt-get install -y -qq docker-ce docker-ce-cli containerd.io docker-compose-plugin 2>/dev/null
systemctl enable --now docker 2>/dev/null
systemctl enable --now ssh 2>/dev/null
DOCKER_INSTALL
}
install_mgmt_key() {
local id=$1
pct exec "$id" -- bash -c "
mkdir -p /root/.ssh
chmod 700 /root/.ssh
grep -qF '${MGMT_PUBKEY}' /root/.ssh/authorized_keys 2>/dev/null || \
echo '${MGMT_PUBKEY}' >> /root/.ssh/authorized_keys
chmod 600 /root/.ssh/authorized_keys
" &>/dev/null
}
push_file() {
# Write content to a temp file, push into LXC, remove temp
local id=$1 dest=$2; shift 2
local tmp; tmp=$(mktemp /tmp/hotel-manage-push-XXXX)
cat > "$tmp" # reads stdin
pct push "$id" "$tmp" "$dest" 2>/dev/null
rm -f "$tmp"
}
push_dir() {
# tar local dir → push tarball → extract in LXC at parent of dest
local id=$1 src=$2 dest=$3
local tmp; tmp=$(mktemp /tmp/hotel-manage-dir-XXXX.tar.gz)
tar czf "$tmp" -C "$(dirname "$src")" "$(basename "$src")" 2>/dev/null
pct push "$id" "$tmp" /tmp/hotel-manage-deploy.tar.gz 2>/dev/null
pct exec "$id" -- bash -c "
mkdir -p '$(dirname "$dest")'
tar xzf /tmp/hotel-manage-deploy.tar.gz -C '$(dirname "$dest")'
mv '$(dirname "$dest")/$(basename "$src")' '${dest}' 2>/dev/null || true
rm -f /tmp/hotel-manage-deploy.tar.gz
" &>/dev/null
rm -f "$tmp"
}
build_clone_url() {
# Inject the Forgejo token into the clone URL so private repos work and the
# updater can pull later without extra credentials.
local repo=$1
local url="${FORGEJO_BASE}/${repo}.git"
if [[ -n "${FORGEJO_TOKEN:-}" ]]; then
url="${url/https:\/\//https://oauth2:${FORGEJO_TOKEN}@}"
url="${url/http:\/\//http://oauth2:${FORGEJO_TOKEN}@}"
fi
echo "$url"
}
deploy_service() {
# Either clone from Forgejo or push from a local repo copy on this host
local id=$1 repo_name=$2 local_src=$3 dest=$4
if [[ "$USE_FORGEJO" == "true" ]]; then
local url; url=$(build_clone_url "$repo_name")
local tmp; tmp=$(mktemp /tmp/hotel-manage-deploy-out-XXXX)
if ! pct exec "$id" -- bash -c "
if [ -d '${dest}/.git' ]; then
cd '${dest}' && git pull
else
git clone '${url}' '${dest}'
fi
" > "$tmp" 2>&1; then
local out; out=$(cat "$tmp"); rm -f "$tmp"
msg_error "Deploy of ${repo_name} to LXC ${id} failed:
${out}
Debug: pct enter ${id} && git clone ${FORGEJO_BASE}/${repo_name}.git /tmp/test-clone"
fi
rm -f "$tmp"
else
push_dir "$id" "$local_src" "$dest"
fi
}
wait_healthy() {
local id=$1 url=$2 max=${3:-40}
local i=0
while ! pct exec "$id" -- curl -sf --max-time 2 "$url" &>/dev/null; do
sleep 3; i=$((i+1))
[[ $i -ge $max ]] && return 1
done
return 0
}
wait_pg() {
local max=30 i=0
while ! pct exec 100 -- bash -c \
"docker exec hotel-manage-postgres pg_isready -U postgres" &>/dev/null; do
sleep 3; i=$((i+1))
[[ $i -ge $max ]] && { msg_warn "Postgres not ready after 90s"; return 1; }
done
}
# ════════════════════════════════════════════════════════════════════════════
# PHASE 1 — POSTGRES LXC 100
# ════════════════════════════════════════════════════════════════════════════
deploy_postgres() {
msg_step "1/7 Postgres (LXC 100 · 10.10.10.100)"
msg_info "Creating LXC 100"
create_lxc 100 "10.10.10.100" "postgres" 512 1
msg_ok "LXC 100 created"
msg_info "Installing Docker"
install_docker 100
msg_ok "Docker installed"
msg_info "Deploying postgres"
pct exec 100 -- mkdir -p /opt/postgres/init
# docker-compose.yml
push_file 100 /opt/postgres/docker-compose.yml <<'EOF'
services:
postgres:
container_name: hotel-manage-postgres
image: postgres:16-alpine
security_opt:
- apparmor=unconfined
environment:
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=${PG_SUPERPASS}
volumes:
- pg_data:/var/lib/postgresql/data
- ./init:/docker-entrypoint-initdb.d:ro
ports:
- "5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
retries: 15
restart: unless-stopped
volumes:
pg_data:
EOF
# .env
push_file 100 /opt/postgres/.env </dev/null
msg_info "Waiting for postgres to be ready"
wait_pg && msg_ok "Postgres running at 10.10.10.100:5432" || msg_warn "Postgres may need extra time — check LXC 100"
install_mgmt_key 100
}
# ════════════════════════════════════════════════════════════════════════════
# PHASE 2 — AUTH SERVICE LXC 101
# ════════════════════════════════════════════════════════════════════════════
deploy_auth() {
msg_step "2/7 Auth service (LXC 101 · 10.10.10.101)"
msg_info "Creating LXC 101"
create_lxc 101 "10.10.10.101" "auth" 1024 1
msg_ok "LXC 101 created"
msg_info "Installing Docker"
install_docker 101
install_mgmt_key 101
msg_ok "Docker + SSH ready"
msg_info "Deploying auth service"
deploy_service 101 "auth" "${REPO_ROOT}/auth" /opt/auth
push_file 101 /opt/auth/.env </dev/null
msg_info "Waiting for auth service"
wait_healthy 101 "http://localhost:3001/health" \
&& msg_ok "Auth service running at 10.10.10.101:3001" \
|| msg_warn "Auth service may need extra time — check LXC 101"
}
# ════════════════════════════════════════════════════════════════════════════
# PHASE 3 — PORTAL LXC 102
# ════════════════════════════════════════════════════════════════════════════
deploy_portal() {
msg_step "3/7 Portal (LXC 102 · 10.10.10.102)"
msg_info "Creating LXC 102"
create_lxc 102 "10.10.10.102" "portal" 1024 2
msg_ok "LXC 102 created"
msg_info "Installing Docker"
install_docker 102
install_mgmt_key 102
msg_ok "Docker + SSH ready"
msg_info "Deploying portal"
deploy_service 102 "portal" "${REPO_ROOT}/portal" /opt/portal
push_file 102 /opt/portal/.env </dev/null
msg_info "Waiting for portal"
wait_healthy 102 "http://localhost:3000/health" \
&& msg_ok "Portal running at 10.10.10.102:3000" \
|| msg_warn "Portal may need extra time — check LXC 102"
}
# ════════════════════════════════════════════════════════════════════════════
# PHASE 4 — NPM LXC 103 (dual-homed)
# ════════════════════════════════════════════════════════════════════════════
deploy_npm() {
msg_step "4/7 Nginx Proxy Manager (LXC 103 · ${NPM_LAN_IP} / 10.10.10.3)"
msg_info "Creating NPM LXC 103 (dual-homed)"
create_npm_lxc
msg_ok "LXC 103 created"
msg_info "Installing Docker"
install_docker 103
msg_ok "Docker installed"
msg_info "Deploying NPM"
pct exec 103 -- mkdir -p /opt/npm
push_file 103 /opt/npm/docker-compose.yml <<'EOF'
services:
npm:
container_name: hotel-manage-npm
image: jc21/nginx-proxy-manager:latest
security_opt:
- apparmor=unconfined
ports:
- "80:80"
- "443:443"
- "81:81"
volumes:
- npm_data:/data
- npm_letsencrypt:/etc/letsencrypt
restart: unless-stopped
volumes:
npm_data:
npm_letsencrypt:
EOF
pct exec 103 -- bash -c "cd /opt/npm && docker compose up -d" &>/dev/null
msg_info "Waiting for NPM admin UI"
# NPM admin API on port 81 — wait up to 60s
local i=0
while ! pct exec 103 -- curl -sf --max-time 3 "http://localhost:81/api/" &>/dev/null; do
sleep 3; i=$((i+1)); [[ $i -ge 20 ]] && break
done
msg_ok "NPM running — admin UI at http://${NPM_LAN_IP}:81"
msg_warn "NPM default login: admin@example.com / changeme (change immediately!)"
}
# ════════════════════════════════════════════════════════════════════════════
# PHASE 5 — MANAGEMENT LXC 105
# ════════════════════════════════════════════════════════════════════════════
deploy_management() {
msg_step "5/7 Management (LXC 105 · 10.10.10.105)"
msg_info "Creating LXC 105"
create_lxc 105 "10.10.10.105" "management" 1024 1
msg_ok "LXC 105 created"
msg_info "Installing Docker"
install_docker 105
msg_ok "Docker installed"
# Copy the management SSH private key into management container
pct exec 105 -- mkdir -p /root/.ssh
pct push 105 /root/.ssh/hotel-manage_deploy /root/.ssh/hotel-manage_deploy &>/dev/null
pct exec 105 -- chmod 600 /root/.ssh/hotel-manage_deploy
msg_info "Deploying management stack"
deploy_service 105 "management" "${REPO_ROOT}/management" /opt/management
push_file 105 /opt/management/.env </dev/null
msg_info "Waiting for Uptime Kuma"
wait_healthy 105 "http://localhost:3002" \
&& msg_ok "Management running — Kuma at 10.10.10.105:3002" \
|| msg_warn "Management may need extra time — check LXC 105"
}
# ════════════════════════════════════════════════════════════════════════════
# PHASE 6 — NOTICEBOARD LXC 112
# ════════════════════════════════════════════════════════════════════════════
deploy_noticeboard() {
msg_step "6/7 Noticeboard (LXC 112 · 10.10.10.112)"
msg_info "Creating LXC 112"
create_lxc 112 "10.10.10.112" "noticeboard" 1024 1
msg_ok "LXC 112 created"
msg_info "Installing Docker"
install_docker 112
install_mgmt_key 112
msg_ok "Docker + SSH ready"
msg_info "Deploying noticeboard"
deploy_service 112 "noticeboard" "${REPO_ROOT}/noticeboard" /opt/noticeboard
push_file 112 /opt/noticeboard/.env </dev/null
msg_info "Waiting for noticeboard"
wait_healthy 112 "http://localhost:3080/notices/health" \
&& msg_ok "Noticeboard running at 10.10.10.112:3080" \
|| msg_warn "Noticeboard may need extra time — check LXC 112"
}
# ════════════════════════════════════════════════════════════════════════════
# PHASE 7 — SETTINGS SERVICE LXC 116
# ════════════════════════════════════════════════════════════════════════════
deploy_settings() {
msg_step "7/7 Settings service (LXC 116 · 10.10.10.116)"
if [[ -z "${SETTINGS_DB_PASS:-}" ]]; then
SETTINGS_DB_PASS=$(openssl rand -base64 24 | tr -dc 'a-zA-Z0-9' | head -c 24)
printf '\nSETTINGS_DB_PASS=%s\n' "$SETTINGS_DB_PASS" >> "$CREDS_FILE"
msg_ok "Generated SETTINGS_DB_PASS → ${CREDS_FILE}"
fi
if [[ -z "${SETTINGS_SECRET:-}" ]]; then
SETTINGS_SECRET=$(openssl rand -hex 32)
printf 'SETTINGS_SECRET=%s\n' "$SETTINGS_SECRET" >> "$CREDS_FILE"
msg_ok "Generated SETTINGS_SECRET → ${CREDS_FILE}"
fi
msg_info "Creating LXC 116"
create_lxc 116 "10.10.10.116" "settings" 512 1
msg_ok "LXC 116 created"
msg_info "Installing Docker"
install_docker 116
install_mgmt_key 116
msg_ok "Docker + SSH ready"
msg_info "Deploying settings service"
deploy_service 116 "settings" "${REPO_ROOT}/settings" /opt/settings
push_file 116 /opt/settings/.env </dev/null
msg_info "Waiting for settings service"
wait_healthy 116 "http://localhost:3080/health" \
&& msg_ok "Settings service running at 10.10.10.116:3080" \
|| msg_warn "Settings may need extra time — check LXC 116"
}
# ════════════════════════════════════════════════════════════════════════════
# PHASE 8 — CASHUP LXC 117
# ════════════════════════════════════════════════════════════════════════════
deploy_cashup() {
msg_step "8/8 Cashup (LXC 117 · 10.10.10.117)"
# Generate DB password if not already in creds file (--only cashup on existing stack)
if [[ -z "${CASHUP_DB_PASS:-}" ]]; then
CASHUP_DB_PASS=$(openssl rand -base64 24 | tr -dc 'a-zA-Z0-9' | head -c 24)
printf '\nCASHUP_DB_PASS=%s\n' "$CASHUP_DB_PASS" >> "$CREDS_FILE"
msg_ok "Generated CASHUP_DB_PASS → ${CREDS_FILE}"
fi
msg_info "Creating LXC 117"
create_lxc 117 "10.10.10.117" "cashup" 1024 1
msg_ok "LXC 117 created"
msg_info "Installing Docker"
install_docker 117
install_mgmt_key 117
msg_ok "Docker + SSH ready"
# Create DB — idempotent via || true, works on fresh installs and re-runs
msg_info "Creating cashup database"
pct exec 100 -- bash -c "
docker exec hotel-manage-postgres psql -U postgres -c \
\"CREATE USER cashup WITH PASSWORD '${CASHUP_DB_PASS}';\" 2>/dev/null || true
docker exec hotel-manage-postgres psql -U postgres -c \
\"CREATE DATABASE cashup_db OWNER cashup;\" 2>/dev/null || true
docker exec hotel-manage-postgres psql -U postgres -d cashup_db -c \
\"GRANT ALL ON SCHEMA public TO cashup;\" 2>/dev/null || true
" &>/dev/null
msg_ok "Database cashup_db ready"
msg_info "Deploying cashup"
deploy_service 117 "cashup" "${REPO_ROOT}/cashup" /opt/cashup
push_file 117 /opt/cashup/.env <&1"); then
msg_error "docker compose build failed in LXC 117:
${build_out}"
fi
msg_info "Waiting for cashup"
wait_healthy 117 "http://localhost:3083/cashup/health" \
&& msg_ok "Cashup running at 10.10.10.117:3083" \
|| msg_warn "Cashup may need extra time — check LXC 117"
# Add /cashup/ location to the existing NPM proxy host (idempotent)
npm_add_location "/cashup/" "10.10.10.117" 3083
}
# ════════════════════════════════════════════════════════════════════════════
# PHASE 9 — HK PLANNER LXC 118
# ════════════════════════════════════════════════════════════════════════════
deploy_hk_planner() {
msg_step "9/10 HK Planner (LXC 118 · 10.10.10.118)"
if [[ -z "${HK_PLANNER_DB_PASS:-}" ]]; then
HK_PLANNER_DB_PASS=$(openssl rand -base64 24 | tr -dc 'a-zA-Z0-9' | head -c 24)
printf '\nHK_PLANNER_DB_PASS=%s\n' "$HK_PLANNER_DB_PASS" >> "$CREDS_FILE"
msg_ok "Generated HK_PLANNER_DB_PASS → ${CREDS_FILE}"
fi
msg_info "Creating LXC 118"
create_lxc 118 "10.10.10.118" "hk-planner" 1024 1
msg_ok "LXC 118 created"
msg_info "Installing Docker"
install_docker 118
install_mgmt_key 118
msg_ok "Docker + SSH ready"
msg_info "Creating hk_planner database"
pct exec 100 -- bash -c "
docker exec hotel-manage-postgres psql -U postgres -c \
\"CREATE USER hk_planner WITH PASSWORD '${HK_PLANNER_DB_PASS}';\" 2>/dev/null || true
docker exec hotel-manage-postgres psql -U postgres -c \
\"CREATE DATABASE hk_planner_db OWNER hk_planner;\" 2>/dev/null || true
docker exec hotel-manage-postgres psql -U postgres -d hk_planner_db -c \
\"GRANT ALL ON SCHEMA public TO hk_planner;\" 2>/dev/null || true
" &>/dev/null
msg_ok "Database hk_planner_db ready"
msg_info "Deploying hk-planner"
deploy_service 118 "hk-planner" "${REPO_ROOT}/hk-planner" /opt/hk-planner
push_file 118 /opt/hk-planner/.env <&1"); then
msg_error "docker compose build failed in LXC 118:
${build_out}"
fi
msg_info "Waiting for hk-planner"
wait_healthy 118 "http://localhost:3080/hk-planner/health" \
&& msg_ok "HK Planner running at 10.10.10.118:3080" \
|| msg_warn "HK Planner may need extra time — check LXC 118"
# Add /hk-planner/ location to NPM proxy host
npm_add_location "/hk-planner/" "10.10.10.118" 3080
}
deploy_twin_optimiser() {
msg_step "10/10 Twin Optimiser (LXC 119 · 10.10.10.119)"
if [[ -z "${TWIN_OPT_DB_PASS:-}" ]]; then
TWIN_OPT_DB_PASS=$(openssl rand -base64 24 | tr -dc 'a-zA-Z0-9' | head -c 24)
printf '\nTWIN_OPT_DB_PASS=%s\n' "$TWIN_OPT_DB_PASS" >> "$CREDS_FILE"
msg_ok "Generated TWIN_OPT_DB_PASS → ${CREDS_FILE}"
fi
msg_info "Creating LXC 119"
create_lxc 119 "10.10.10.119" "twin-optimiser" 1024 1
msg_ok "LXC 119 created"
msg_info "Installing Docker"
install_docker 119
install_mgmt_key 119
msg_ok "Docker + SSH ready"
msg_info "Creating twin_optimiser database"
pct exec 100 -- bash -c "
docker exec hotel-manage-postgres psql -U postgres -c \
\"CREATE USER twin_optimiser WITH PASSWORD '${TWIN_OPT_DB_PASS}';\" 2>/dev/null || true
docker exec hotel-manage-postgres psql -U postgres -c \
\"CREATE DATABASE twin_optimiser_db OWNER twin_optimiser;\" 2>/dev/null || true
docker exec hotel-manage-postgres psql -U postgres -d twin_optimiser_db -c \
\"GRANT ALL ON SCHEMA public TO twin_optimiser;\" 2>/dev/null || true
" &>/dev/null
msg_ok "Database twin_optimiser_db ready"
msg_info "Deploying twin-optimiser"
deploy_service 119 "twin-optimiser" "${REPO_ROOT}/twin-optimiser" /opt/twin-optimiser
push_file 119 /opt/twin-optimiser/.env <&1"); then
msg_error "docker compose build failed in LXC 119:
${build_out}"
fi
msg_info "Waiting for twin-optimiser"
wait_healthy 119 "http://localhost:3080/twin-optimiser/health" \
&& msg_ok "Twin Optimiser running at 10.10.10.119:3080" \
|| msg_warn "Twin Optimiser may need extra time — check LXC 119"
# Add /twin-optimiser/ location to NPM proxy host
npm_add_location "/twin-optimiser/" "10.10.10.119" 3080
}
# ════════════════════════════════════════════════════════════════════════════
# PHASE 11 — ROOM PLANNER LXC 120
# ════════════════════════════════════════════════════════════════════════════
deploy_room_planner() {
msg_step "11/11 Room Planner (LXC 120 · 10.10.10.120)"
if [[ -z "${ROOM_PLANNER_DB_PASS:-}" ]]; then
ROOM_PLANNER_DB_PASS=$(openssl rand -base64 24 | tr -dc 'a-zA-Z0-9' | head -c 24)
printf '\nROOM_PLANNER_DB_PASS=%s\n' "$ROOM_PLANNER_DB_PASS" >> "$CREDS_FILE"
msg_ok "Generated ROOM_PLANNER_DB_PASS → ${CREDS_FILE}"
fi
msg_info "Creating LXC 120"
create_lxc 120 "10.10.10.120" "room-planner" 1024 1
msg_ok "LXC 120 created"
msg_info "Installing Docker"
install_docker 120
install_mgmt_key 120
msg_ok "Docker + SSH ready"
msg_info "Creating room_planner database"
pct exec 100 -- bash -c "
docker exec hotel-manage-postgres psql -U postgres -c \
\"CREATE USER room_planner WITH PASSWORD '${ROOM_PLANNER_DB_PASS}';\" 2>/dev/null || true
docker exec hotel-manage-postgres psql -U postgres -c \
\"CREATE DATABASE room_planner_db OWNER room_planner;\" 2>/dev/null || true
docker exec hotel-manage-postgres psql -U postgres -d room_planner_db -c \
\"GRANT ALL ON SCHEMA public TO room_planner;\" 2>/dev/null || true
" &>/dev/null
msg_ok "Database room_planner_db ready"
msg_info "Deploying room-planner"
deploy_service 120 "room-planner" "${REPO_ROOT}/room-planner" /opt/room-planner
push_file 120 /opt/room-planner/.env <&1"); then
msg_error "docker compose build failed in LXC 120:
${build_out}"
fi
msg_info "Waiting for room-planner"
wait_healthy 120 "http://localhost:3080/room-planner/health" \
&& msg_ok "Room Planner running at 10.10.10.120:3080" \
|| msg_warn "Room Planner may need extra time — check LXC 120"
# Seed app + capabilities into auth DB (psql on LXC 100 — same pattern as add-app.sh)
msg_info "Seeding room-planner into auth DB"
pct exec 100 -- docker exec hotel-manage-postgres psql -U postgres -d auth_db -c "
INSERT INTO apps (slug, name, description, base_path, icon, theme_color, category, internal_host, internal_port)
VALUES ('room-planner', 'Room Planner', 'Daily housekeeping room view with NewBook task management', '/room-planner', 'BedDouble', '#2d6a4f', 'Housekeeping', '10.10.10.120', 3080)
ON CONFLICT (slug) DO UPDATE SET
name=EXCLUDED.name, description=EXCLUDED.description, base_path=EXCLUDED.base_path,
icon=EXCLUDED.icon, theme_color=EXCLUDED.theme_color, category=EXCLUDED.category,
internal_host=EXCLUDED.internal_host, internal_port=EXCLUDED.internal_port;
INSERT INTO app_capabilities (app_id, slug, name, description, sort_order)
SELECT a.id, c.slug, c.name, c.description, c.sort_order
FROM apps a, (VALUES
('view','View Planner','Access the room planner view',1),
('guest_details','Guest Details','View guest names and personal information',2),
('rate_details','Rate Details','View pricing and rate plan information',3),
('view_all_notes','View All Notes','View all booking note types',4),
('complete_tasks','Complete Tasks','Mark NewBook tasks as complete',5),
('update_status','Update Room Status','Mark rooms clean, dirty or inspected',6),
('settings','Settings','Configure room planner settings',7)
) AS c(slug, name, description, sort_order)
WHERE a.slug = 'room-planner'
ON CONFLICT (app_id, slug) DO NOTHING;
" &>/dev/null \
&& msg_ok "room-planner seeded into auth DB" \
|| msg_warn "Seed failed — run the SQL in room-planner/seed-app.js manually via psql on LXC 100"
# Add /room-planner/ location to NPM proxy host
npm_add_location "/room-planner/" "10.10.10.120" 3080
}
deploy_maintenance() {
msg_step "12/12 Maintenance (LXC 121 · 10.10.10.121)"
if [[ -z "${MAINTENANCE_DB_PASS:-}" ]]; then
MAINTENANCE_DB_PASS=$(openssl rand -base64 24 | tr -dc 'a-zA-Z0-9' | head -c 24)
printf '\nMAINTENANCE_DB_PASS=%s\n' "$MAINTENANCE_DB_PASS" >> "$CREDS_FILE"
msg_ok "Generated MAINTENANCE_DB_PASS → ${CREDS_FILE}"
fi
msg_info "Creating LXC 121"
create_lxc 121 "10.10.10.121" "maintenance" 1024 1
msg_ok "LXC 121 created"
msg_info "Installing Docker"
install_docker 121
install_mgmt_key 121
msg_ok "Docker + SSH ready"
msg_info "Creating maintenance database"
pct exec 100 -- bash -c "
docker exec hotel-manage-postgres psql -U postgres -c \
\"CREATE USER maintenance WITH PASSWORD '${MAINTENANCE_DB_PASS}';\" 2>/dev/null || true
docker exec hotel-manage-postgres psql -U postgres -c \
\"CREATE DATABASE maintenance_db OWNER maintenance;\" 2>/dev/null || true
docker exec hotel-manage-postgres psql -U postgres -d maintenance_db -c \
\"GRANT ALL ON SCHEMA public TO maintenance;\" 2>/dev/null || true
" &>/dev/null
msg_ok "Database maintenance_db ready"
msg_info "Deploying maintenance"
deploy_service 121 "maintenance" "${REPO_ROOT}/maintenance" /opt/maintenance
push_file 121 /opt/maintenance/.env <&1"); then
msg_error "docker compose build failed in LXC 121:
${build_out}"
fi
msg_info "Waiting for maintenance"
wait_healthy 121 "http://localhost:3080/maintenance/health" \
&& msg_ok "Maintenance running at 10.10.10.121:3080" \
|| msg_warn "Maintenance may need extra time — check LXC 121"
# Seed app + capabilities into auth DB (psql on LXC 100 — same pattern as add-app.sh)
msg_info "Seeding maintenance into auth DB"
pct exec 100 -- docker exec hotel-manage-postgres psql -U postgres -d auth_db -c "
INSERT INTO apps (slug, name, description, base_path, icon, theme_color, category, internal_host, internal_port)
VALUES ('maintenance', 'Maintenance', 'Maintenance log book — faults, recurring tasks, assets and contractors', '/maintenance', 'Wrench', '#b45309', 'Operations', '10.10.10.121', 3080)
ON CONFLICT (slug) DO UPDATE SET
name=EXCLUDED.name, description=EXCLUDED.description, base_path=EXCLUDED.base_path,
icon=EXCLUDED.icon, theme_color=EXCLUDED.theme_color, category=EXCLUDED.category,
internal_host=EXCLUDED.internal_host, internal_port=EXCLUDED.internal_port;
INSERT INTO app_capabilities (app_id, slug, name, description, sort_order)
SELECT a.id, c.slug, c.name, c.description, c.sort_order
FROM apps a, (VALUES
('view','View Tasks','View the maintenance log and history',1),
('report','Report Faults','Create tasks, add photos and comments',2),
('update','Update Tasks','Change task state, reassign, edit details',3),
('resolve','Resolve Tasks','Mark tasks temporary fixed or fixed',4),
('costs','View Costs','See and enter repair cost values',5),
('manage_locations','Manage Locations','Manage locations, categories and NewBook sync',6),
('manage_assets','Manage Assets','Create and edit the asset register',7),
('manage_contractors','Manage Contractors','Manage contractors and their documents',8),
('manage_templates','Manage Recurring','Create and edit recurring task templates',9),
('settings','Settings','Configure maintenance app settings',10)
) AS c(slug, name, description, sort_order)
WHERE a.slug = 'maintenance'
ON CONFLICT (app_id, slug) DO NOTHING;
" &>/dev/null \
&& msg_ok "maintenance seeded into auth DB" \
|| msg_warn "Seed failed — run maintenance/seed-app.js manually via psql on LXC 100"
# Add /maintenance/ location to NPM proxy host
npm_add_location "/maintenance/" "10.10.10.121" 3080
}
deploy_forecasting() {
msg_step "Forecasting (LXC 113 · 10.10.10.113)"
if [[ -z "${FORECASTING_DB_PASS:-}" ]]; then
FORECASTING_DB_PASS=$(openssl rand -base64 24 | tr -dc 'a-zA-Z0-9' | head -c 24)
printf '\nFORECASTING_DB_PASS=%s\n' "$FORECASTING_DB_PASS" >> "$CREDS_FILE"
msg_ok "Generated FORECASTING_DB_PASS → ${CREDS_FILE}"
fi
# Forecasting needs more RAM (ML models) and more disk (Chromium, Prophet/CmdStan)
msg_info "Creating LXC 113 (4 GB RAM, 2 cores)"
create_lxc 113 "10.10.10.113" "forecasting" 4096 2
pct resize 113 rootfs 20G &>/dev/null || true
msg_ok "LXC 113 created"
msg_info "Installing Docker"
install_docker 113
install_mgmt_key 113
msg_ok "Docker + SSH ready"
msg_info "Creating forecasting database"
pct exec 100 -- bash -c "
docker exec hotel-manage-postgres psql -U postgres -c \
\"CREATE USER forecasting WITH PASSWORD '${FORECASTING_DB_PASS}';\" 2>/dev/null || true
docker exec hotel-manage-postgres psql -U postgres -c \
\"CREATE DATABASE forecasting_db OWNER forecasting;\" 2>/dev/null || true
docker exec hotel-manage-postgres psql -U postgres -d forecasting_db -c \
\"GRANT ALL ON SCHEMA public TO forecasting;\" 2>/dev/null || true
" &>/dev/null
msg_ok "Database forecasting_db ready (schema applied by Python backend on first start)"
msg_info "Deploying forecasting"
deploy_service 113 "forecasting" "${REPO_ROOT}/forecasting" /opt/forecasting
# NewBook credentials come from the central Settings service (SETTINGS_URL).
# RESOS_API_KEY is configured via the app's Settings page after first deploy.
# ANTHROPIC_API_KEY is optional — enables the AI Dashboard feature.
push_file 113 /opt/forecasting/.env <&1"); then
msg_error "docker compose build failed in LXC 113:
${build_out}"
fi
msg_info "Waiting for forecasting (up to 10 min)"
wait_healthy 113 "http://localhost:3080/forecasting/health" 200 \
&& msg_ok "Forecasting running at 10.10.10.113:3080" \
|| msg_warn "Forecasting may need extra time — check LXC 113 logs"
# Seed app + capabilities into auth DB
msg_info "Seeding forecasting into auth DB"
pct exec 100 -- docker exec hotel-manage-postgres psql -U postgres -d auth_db -c "
INSERT INTO apps (slug, name, description, base_path, icon, theme_color, category, internal_host, internal_port)
VALUES ('forecasting', 'Forecasting', 'Revenue forecasting and reporting', '/forecasting', 'TrendingUp', '#0077b6', 'Finance', '10.10.10.113', 3080)
ON CONFLICT (slug) DO UPDATE SET
name=EXCLUDED.name, description=EXCLUDED.description, base_path=EXCLUDED.base_path,
icon=EXCLUDED.icon, theme_color=EXCLUDED.theme_color, category=EXCLUDED.category,
internal_host=EXCLUDED.internal_host, internal_port=EXCLUDED.internal_port;
INSERT INTO app_capabilities (app_id, slug, name, description, sort_order)
SELECT a.id, c.slug, c.name, c.description, c.sort_order
FROM apps a, (VALUES
('view','View Forecasts','View dashboard, forecasts, and history',1),
('view_bookability','View Bookability','View the rate / bookability matrix',2),
('view_competitor_rates','View Competitors','View competitor rate scraping and matrix',3),
('view_accuracy','View Accuracy','View model accuracy metrics and backtesting',4),
('manage_sync','Manage Data Sync','Trigger Newbook / Resos data syncs manually',5),
('settings','Manage Settings','Configure Newbook credentials and sync schedules',6)
) AS c(slug, name, description, sort_order)
WHERE a.slug = 'forecasting'
ON CONFLICT (app_id, slug) DO NOTHING;
" &>/dev/null \
&& msg_ok "forecasting seeded into auth DB" \
|| msg_warn "Seed failed — run forecasting/seed-app.js manually"
npm_add_location "/forecasting/" "10.10.10.113" 3080
}
deploy_rates() {
msg_step "Rate Monitor (LXC 115 · 10.10.10.115)"
if [[ -z "${RATES_DB_PASS:-}" ]]; then
RATES_DB_PASS=$(openssl rand -base64 24 | tr -dc 'a-zA-Z0-9' | head -c 24)
printf '\nRATES_DB_PASS=%s\n' "$RATES_DB_PASS" >> "$CREDS_FILE"
msg_ok "Generated RATES_DB_PASS → ${CREDS_FILE}"
fi
if [[ -z "${RATES_SECRET:-}" ]]; then
RATES_SECRET=$(openssl rand -base64 24 | tr -dc 'a-zA-Z0-9' | head -c 32)
printf '\nRATES_SECRET=%s\n' "$RATES_SECRET" >> "$CREDS_FILE"
msg_ok "Generated RATES_SECRET → ${CREDS_FILE}"
fi
msg_info "Creating LXC 115 (4 GB RAM, 2 cores)"
# 4 GB required: Playwright/Chromium scrapes + image builds wedge a 2 GB LXC
create_lxc 115 "10.10.10.115" "rates" 4096 2
msg_ok "LXC 115 created"
msg_info "Installing Docker"
install_docker 115
install_mgmt_key 115
msg_ok "Docker + SSH ready"
msg_info "Creating rates database"
pct exec 100 -- bash -c "
docker exec hotel-manage-postgres psql -U postgres -c \
\"CREATE USER rates WITH PASSWORD '${RATES_DB_PASS}';\" 2>/dev/null || true
docker exec hotel-manage-postgres psql -U postgres -c \
\"CREATE DATABASE rates_db OWNER rates;\" 2>/dev/null || true
docker exec hotel-manage-postgres psql -U postgres -d rates_db -c \
\"GRANT ALL ON SCHEMA public TO rates;\" 2>/dev/null || true
" &>/dev/null
msg_ok "Database rates_db ready (schema applied by Python backend on first start)"
msg_info "Deploying rates"
deploy_service 115 "rates" "${REPO_ROOT}/rates" /opt/rates
push_file 115 /opt/rates/.env <&1"); then
msg_error "docker compose build failed in LXC 115:
${build_out}"
fi
msg_info "Waiting for rates"
wait_healthy 115 "http://localhost:3080/rates/health" 200 \
&& msg_ok "Rate Monitor running at 10.10.10.115:3080" \
|| msg_warn "Rate Monitor may need extra time — check LXC 115 logs"
# Seed app + capabilities into auth DB
msg_info "Seeding rates into auth DB"
pct exec 100 -- docker exec hotel-manage-postgres psql -U postgres -d auth_db -c "
INSERT INTO apps (slug, name, description, base_path, icon, theme_color, category, internal_host, internal_port)
VALUES ('rates', 'Rate Monitor', 'Competitor rate monitoring and direct booking engine rates', '/rates', 'Tag', '#7b4f00', 'Finance', '10.10.10.115', 3080)
ON CONFLICT (slug) DO UPDATE SET
name=EXCLUDED.name, description=EXCLUDED.description, base_path=EXCLUDED.base_path,
icon=EXCLUDED.icon, theme_color=EXCLUDED.theme_color, category=EXCLUDED.category,
internal_host=EXCLUDED.internal_host, internal_port=EXCLUDED.internal_port;
INSERT INTO app_capabilities (app_id, slug, name, description, sort_order)
SELECT a.id, c.slug, c.name, c.description, c.sort_order
FROM apps a, (VALUES
('view_own_rates','View Bookability','View own hotel rate and tariff availability',1),
('view_competitors','View Market View','View competitor Booking.com rates',2),
('view_direct_rates','View Direct Rates','View competitor direct booking engine rates',3),
('rate_analysis','Rate Analysis','Drill into competitor pricing structure',4),
('manage_scraper','Manage Scraper','Configure scraper, trigger manual scrapes',5),
('manage_hotels','Manage Competitors','Classify and configure competitor hotels',6)
) AS c(slug, name, description, sort_order)
WHERE a.slug = 'rates'
ON CONFLICT (app_id, slug) DO NOTHING;
" &>/dev/null \
&& msg_ok "rates seeded into auth DB" \
|| msg_warn "Seed failed — run auth db.js manually"
npm_add_location "/rates/" "10.10.10.115" 3080
}
deploy_reports() {
msg_step "Reports (LXC 122 · 10.10.10.122)"
if [[ -z "${REPORTS_DB_PASS:-}" ]]; then
REPORTS_DB_PASS=$(openssl rand -base64 24 | tr -dc 'a-zA-Z0-9' | head -c 24)
printf '\nREPORTS_DB_PASS=%s\n' "$REPORTS_DB_PASS" >> "$CREDS_FILE"
msg_ok "Generated REPORTS_DB_PASS → ${CREDS_FILE}"
fi
msg_info "Creating LXC 122"
create_lxc 122 "10.10.10.122" "reports" 1024 1
msg_ok "LXC 122 created"
msg_info "Installing Docker"
install_docker 122
install_mgmt_key 122
msg_ok "Docker + SSH ready"
msg_info "Creating reports database"
pct exec 100 -- bash -c "
docker exec hotel-manage-postgres psql -U postgres -c \
\"CREATE USER reports WITH PASSWORD '${REPORTS_DB_PASS}';\" 2>/dev/null || true
docker exec hotel-manage-postgres psql -U postgres -c \
\"CREATE DATABASE reports_db OWNER reports;\" 2>/dev/null || true
docker exec hotel-manage-postgres psql -U postgres -d reports_db -c \
\"GRANT ALL ON SCHEMA public TO reports;\" 2>/dev/null || true
" &>/dev/null
msg_ok "Database reports_db ready"
msg_info "Deploying reports"
deploy_service 122 "reports" "${REPO_ROOT}/reports" /opt/reports
push_file 122 /opt/reports/.env <&1"); then
msg_error "docker compose build failed in LXC 122:
${build_out}"
fi
msg_info "Waiting for reports"
wait_healthy 122 "http://localhost:3080/reports/health" \
&& msg_ok "Reports running at 10.10.10.122:3080" \
|| msg_warn "Reports may need extra time — check LXC 122"
msg_info "Seeding reports into auth DB"
pct exec 100 -- docker exec hotel-manage-postgres psql -U postgres -d auth_db -c "
INSERT INTO apps (slug, name, description, base_path, icon, theme_color, category, internal_host, internal_port)
VALUES ('reports', 'Reports', 'Custom reports for NewBook, ResOS, SambaPOS and internal data', '/reports', 'BarChart2', '#1d4ed8', 'Management', '10.10.10.122', 3080)
ON CONFLICT (slug) DO UPDATE SET
name=EXCLUDED.name, description=EXCLUDED.description, base_path=EXCLUDED.base_path,
icon=EXCLUDED.icon, theme_color=EXCLUDED.theme_color, category=EXCLUDED.category,
internal_host=EXCLUDED.internal_host, internal_port=EXCLUDED.internal_port;
INSERT INTO app_capabilities (app_id, slug, name, description, sort_order)
SELECT a.id, c.slug, c.name, c.description, c.sort_order
FROM apps a, (VALUES
('view', 'View & Run Reports', 'Browse and run all custom reports', 1),
('export', 'Export to CSV', 'Download report results as a CSV file', 2)
) AS c(slug, name, description, sort_order)
WHERE a.slug = 'reports'
ON CONFLICT (app_id, slug) DO NOTHING;
" &>/dev/null \
&& msg_ok "reports seeded into auth DB" \
|| msg_warn "Seed failed — run auth db.js manually"
npm_add_location "/reports/" "10.10.10.122" 3080
}
# ════════════════════════════════════════════════════════════════════════════
# NPM PROXY HOSTS (via API)
# ════════════════════════════════════════════════════════════════════════════
# Returns a Bearer token for the NPM API, or empty string on failure.
# Robust against a messy creds file: tries the currently-sourced NPM_ADMIN_*
# pair, then EVERY email/pass pair found in the creds file (handles duplicates,
# ordering, and stale placeholders), then the NPM factory default.
npm_get_token() {
local npm_ip="10.10.10.3" # stable internal NPM IP (vmbr1) — API only needs internal reach
_try_token() {
local email=$1 pass=$2 tok
[[ -z "$email" || -z "$pass" ]] && return 1
tok=$(curl -sf -X POST "http://${npm_ip}:81/api/tokens" \
-H "Content-Type: application/json" \
-d "{\"identity\":\"${email}\",\"secret\":\"${pass}\"}" \
2>/dev/null | grep -o '"token":"[^"]*"' | cut -d'"' -f4)
[[ -n "$tok" ]] && { echo "$tok"; return 0; }
return 1
}
# 1. Currently-sourced pair
_try_token "${NPM_ADMIN_EMAIL:-}" "${NPM_ADMIN_PASS:-}" && return
# 2. Every email→pass pair in the creds file (pair each EMAIL with the
# NPM_ADMIN_PASS that follows it)
if [[ -f "$CREDS_FILE" ]]; then
local email="" line key val
while IFS= read -r line; do
case "$line" in
NPM_ADMIN_EMAIL=*) email="${line#*=}" ;;
NPM_ADMIN_PASS=*) _try_token "$email" "${line#*=}" && return; email="" ;;
esac
done < "$CREDS_FILE"
fi
# 3. NPM factory default (fresh, unconfigured install)
_try_token "admin@example.com" "changeme" && return
echo ""
}
# Idempotently add a custom location (path → forward_host:port) to the NPM
# proxy host that serves $DOMAIN. Robust JSON handling via python3 — the proxy
# host is located by matching $DOMAIN inside its domain_names array (field
# order in NPM's JSON is not guaranteed, so grep-based matching is unreliable).
npm_add_location() {
local path=$1 fwd_host=$2 fwd_port=$3
local npm_ip="10.10.10.3"
msg_info "Adding ${path} to NPM proxy"
local token; token=$(npm_get_token)
if [[ -z "$token" ]]; then
msg_warn "NPM API unavailable — add ${path} → ${fwd_host}:${fwd_port} manually in NPM admin"
return
fi
local hosts_json host_id
hosts_json=$(curl -sf "http://${npm_ip}:81/api/nginx/proxy-hosts" \
-H "Authorization: Bearer ${token}" 2>/dev/null) || true
host_id=$(echo "$hosts_json" | python3 -c "
import sys, json
try: hosts = json.load(sys.stdin)
except Exception: sys.exit(0)
dom = '${DOMAIN}'
for h in hosts:
if dom in (h.get('domain_names') or []):
print(h['id']); break
" 2>/dev/null) || true
if [[ -z "$host_id" ]]; then
msg_warn "NPM proxy host for ${DOMAIN} not found — add ${path} → ${fwd_host}:${fwd_port} manually"
return
fi
local existing merged
existing=$(curl -sf "http://${npm_ip}:81/api/nginx/proxy-hosts/${host_id}" \
-H "Authorization: Bearer ${token}" 2>/dev/null) || true
merged=$(echo "$existing" | python3 -c "
import sys, json
try: h = json.load(sys.stdin)
except Exception: sys.exit(0)
path, fh, fp = '${path}', '${fwd_host}', ${fwd_port}
locs = h.get('locations') or []
if any(l.get('path') == path for l in locs):
print('EXISTS'); sys.exit(0)
locs.append({'path':path,'forward_scheme':'http','forward_host':fh,'forward_port':fp,'advanced_config':''})
print(json.dumps(locs))
" 2>/dev/null) || true
if [[ "$merged" == "EXISTS" ]]; then
msg_ok "NPM location ${path} already present"
return
fi
if [[ -z "$merged" ]]; then
msg_warn "NPM merge failed — add ${path} → ${fwd_host}:${fwd_port} manually"
return
fi
if curl -sf -X PUT "http://${npm_ip}:81/api/nginx/proxy-hosts/${host_id}" \
-H "Authorization: Bearer ${token}" \
-H "Content-Type: application/json" \
-d "{\"locations\":${merged}}" &>/dev/null; then
msg_ok "NPM location ${path} added"
else
msg_warn "NPM update failed — add ${path} → ${fwd_host}:${fwd_port} manually"
fi
}
configure_npm_proxy_hosts() {
msg_step "Configuring NPM proxy hosts"
msg_info "Waiting for NPM API to be ready"
local npm_ip="10.10.10.3" # stable internal NPM IP (vmbr1)
local i=0
while ! curl -sf --max-time 3 "http://${npm_ip}:81/api/" &>/dev/null; do
sleep 3; i=$((i+1))
[[ $i -ge 30 ]] && { msg_warn "NPM API not responding — configure proxy hosts manually"; return; }
done
local token
token=$(npm_get_token)
if [[ -z "$token" ]]; then
msg_warn "Could not get NPM token — proxy hosts must be created manually (see summary)"
return
fi
msg_ok "NPM API authenticated"
# NPM's proxy-host endpoint is /api/nginx/proxy-hosts (NOT /api/proxy-hosts).
# NPM already sets X-Real-IP / X-Forwarded-For by default, so no advanced_config.
create_proxy_host() {
local forward_host=$1 forward_port=$2 locations_json=${3:-'[]'}
curl -sf -X POST "http://${npm_ip}:81/api/nginx/proxy-hosts" \
-H "Authorization: Bearer ${token}" \
-H "Content-Type: application/json" \
-d "{
\"domain_names\": [\"${DOMAIN}\"],
\"forward_scheme\": \"http\",
\"forward_host\": \"${forward_host}\",
\"forward_port\": ${forward_port},
\"access_list_id\": 0,
\"certificate_id\": 0,
\"ssl_forced\": false,
\"caching_enabled\": false,
\"block_exploits\": true,
\"allow_websocket_upgrade\": true,
\"http2_support\": false,
\"hsts_enabled\": false,
\"hsts_subdomains\": false,
\"advanced_config\": \"\",
\"meta\": {\"letsencrypt_agree\": false, \"dns_challenge\": false},
\"locations\": ${locations_json}
}" &>/dev/null && echo "created" || echo "failed"
}
# Single proxy host for the domain routed to the portal, with custom
# locations per app path (NPM's "custom locations" feature).
local locations='[
{"path":"/api/auth/","forward_scheme":"http","forward_host":"10.10.10.101","forward_port":3001,"advanced_config":""},
{"path":"/notices/","forward_scheme":"http","forward_host":"10.10.10.112","forward_port":3080,"advanced_config":""},
{"path":"/monitor/","forward_scheme":"http","forward_host":"10.10.10.105","forward_port":3002,"advanced_config":""},
{"path":"/cashup/","forward_scheme":"http","forward_host":"10.10.10.117","forward_port":3083,"advanced_config":""},
{"path":"/hk-planner/","forward_scheme":"http","forward_host":"10.10.10.118","forward_port":3080,"advanced_config":""},
{"path":"/twin-optimiser/","forward_scheme":"http","forward_host":"10.10.10.119","forward_port":3080,"advanced_config":""}
]'
local result; result=$(create_proxy_host "10.10.10.102" 3000 "$locations")
if [[ "$result" == "created" ]]; then
msg_ok "NPM proxy host created for ${DOMAIN}"
msg_warn "SSL certificate: configure in NPM admin UI after DNS is pointed at ${NPM_LAN_IP}"
else
msg_warn "NPM proxy host creation failed — create manually (see summary)"
fi
}
# ════════════════════════════════════════════════════════════════════════════
# SUMMARY
# ════════════════════════════════════════════════════════════════════════════
print_summary() {
printf "\n${GN}"
cat <
# Sources existing credentials and redeploys just the named service.
if [[ "${1:-}" == "--only" ]]; then
ONLY="${2:-}"
VALID="postgres auth portal npm management noticeboard settings cashup hk-planner twin-optimiser room-planner maintenance forecasting rates"
[[ -z "$ONLY" ]] && msg_error "Usage: install-stack.sh --only (one of: ${VALID})"
grep -qw "$ONLY" <<< "$VALID" || msg_error "Unknown service '${ONLY}'. Valid: ${VALID}"
CREDS_FILE=/root/hotel-manage-credentials.txt
[[ -f "$CREDS_FILE" ]] || msg_error "No credentials file at ${CREDS_FILE} — run the full installer first"
# Read key=value pairs safely — handles values that contain spaces (e.g. SITE_NAME)
while IFS= read -r line; do
[[ "$line" =~ ^[A-Z_][A-Z0-9_]*= ]] || continue
key="${line%%=*}"; value="${line#*=}"
export "$key"="$value"
done < "$CREDS_FILE"
# Generate and append any secrets that didn't exist when the stack was first installed
_append_secret() {
local var=$1 val=$2
export "$var"="$val"
echo "${var}=${val}" >> "$CREDS_FILE"
msg_ok "Generated missing secret: ${var}"
}
[[ -z "${SETTINGS_DB_PASS:-}" ]] && _append_secret SETTINGS_DB_PASS \
"$(openssl rand -base64 24 | tr -dc 'a-zA-Z0-9' | head -c 24)"
[[ -z "${SETTINGS_SECRET:-}" ]] && _append_secret SETTINGS_SECRET \
"$(openssl rand -hex 32)"
[[ -z "${HK_PLANNER_DB_PASS:-}" ]] && _append_secret HK_PLANNER_DB_PASS \
"$(openssl rand -base64 24 | tr -dc 'a-zA-Z0-9' | head -c 24)"
[[ -z "${TWIN_OPT_DB_PASS:-}" ]] && _append_secret TWIN_OPT_DB_PASS \
"$(openssl rand -base64 24 | tr -dc 'a-zA-Z0-9' | head -c 24)"
[[ -z "${FORECASTING_DB_PASS:-}" ]] && _append_secret FORECASTING_DB_PASS \
"$(openssl rand -base64 24 | tr -dc 'a-zA-Z0-9' | head -c 24)"
[[ -z "${REPORTS_DB_PASS:-}" ]] && _append_secret REPORTS_DB_PASS \
"$(openssl rand -base64 24 | tr -dc 'a-zA-Z0-9' | head -c 24)"
USE_FORGEJO=true
[[ -f /root/.ssh/hotel-manage_deploy.pub ]] \
&& MGMT_PUBKEY=$(cat /root/.ssh/hotel-manage_deploy.pub) || MGMT_PUBKEY=""
check_vmbr1
ensure_pool
case "$ONLY" in
postgres) deploy_postgres ;;
auth) deploy_auth ;;
portal) deploy_portal ;;
npm) deploy_npm ;;
management) deploy_management ;;
noticeboard) deploy_noticeboard ;;
settings) deploy_settings ;;
cashup) deploy_cashup ;;
hk-planner) deploy_hk_planner ;;
twin-optimiser) deploy_twin_optimiser ;;
room-planner) deploy_room_planner ;;
maintenance) deploy_maintenance ;;
forecasting) deploy_forecasting ;;
rates) deploy_rates ;;
reports) deploy_reports ;;
esac
exit 0
fi
check_vmbr1
collect_config
gen_secrets
gen_mgmt_ssh_key
ensure_pool
deploy_postgres
deploy_auth
deploy_portal
deploy_npm
deploy_management
deploy_noticeboard
deploy_settings
deploy_cashup
deploy_hk_planner
deploy_twin_optimiser
deploy_room_planner
deploy_maintenance
deploy_forecasting
deploy_rates
deploy_reports
configure_npm_proxy_hosts
print_summary