#!/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)
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)
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)
msg_info "Adding /cashup/ to NPM proxy"
local npm_token
npm_token=$(npm_get_token)
local npm_ip="${NPM_LAN_IP:-10.10.10.103}"
if [[ -n "$npm_token" ]]; then
# Find the proxy host for our domain
local host_id
host_id=$(curl -sf "http://${npm_ip}:81/api/nginx/proxy-hosts" \
-H "Authorization: Bearer ${npm_token}" 2>/dev/null \
| grep -o '"id":[0-9]*,"domain_names":\["'"${DOMAIN}"'"\]' \
| grep -o '"id":[0-9]*' | cut -d: -f2) || true
if [[ -n "$host_id" ]]; then
# Fetch existing host, merge /cashup/ location, PUT back
local existing
existing=$(curl -sf "http://${npm_ip}:81/api/nginx/proxy-hosts/${host_id}" \
-H "Authorization: Bearer ${npm_token}" 2>/dev/null) || true
if [[ -n "$existing" ]] && ! echo "$existing" | grep -q '"/cashup/"'; then
# Build merged locations by appending cashup entry
local merged_locations
merged_locations=$(echo "$existing" | python3 -c "
import sys, json
d = json.load(sys.stdin)
locs = d.get('locations') or []
locs.append({'path':'/cashup/','forward_scheme':'http','forward_host':'10.10.10.117','forward_port':3083,'advanced_config':''})
print(json.dumps(locs))
" 2>/dev/null) || true
if [[ -n "$merged_locations" ]]; then
curl -sf -X PUT "http://${npm_ip}:81/api/nginx/proxy-hosts/${host_id}" \
-H "Authorization: Bearer ${npm_token}" \
-H "Content-Type: application/json" \
-d "{\"locations\":${merged_locations}}" &>/dev/null \
&& msg_ok "NPM location /cashup/ added" \
|| msg_warn "NPM update failed — add /cashup/ → 10.10.10.117:3083 manually in NPM admin"
fi
else
msg_ok "NPM location /cashup/ already present"
fi
else
msg_warn "NPM proxy host for ${DOMAIN} not found — add /cashup/ → 10.10.10.117:3083 manually"
fi
else
msg_warn "NPM API unavailable — add /cashup/ → 10.10.10.117:3083 manually in NPM admin"
fi
}
# ════════════════════════════════════════════════════════════════════════════
# PHASE 9 — HK PLANNER LXC 118
# ════════════════════════════════════════════════════════════════════════════
deploy_hk_planner() {
msg_step "9/9 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
msg_info "Adding /hk-planner/ to NPM proxy"
local npm_token; npm_token=$(npm_get_token)
local npm_ip="${NPM_LAN_IP:-10.10.10.103}"
if [[ -n "$npm_token" ]]; then
local host_id
host_id=$(curl -sf "http://${npm_ip}:81/api/nginx/proxy-hosts" \
-H "Authorization: Bearer ${npm_token}" 2>/dev/null \
| grep -o '"id":[0-9]*,"domain_names":\["'"${DOMAIN}"'"\]' \
| grep -o '"id":[0-9]*' | cut -d: -f2) || true
if [[ -n "$host_id" ]]; then
local existing
existing=$(curl -sf "http://${npm_ip}:81/api/nginx/proxy-hosts/${host_id}" \
-H "Authorization: Bearer ${npm_token}" 2>/dev/null) || true
if [[ -n "$existing" ]] && ! echo "$existing" | grep -q '"/hk-planner/"'; then
local merged_locations
merged_locations=$(echo "$existing" | python3 -c "
import sys, json
d = json.load(sys.stdin)
locs = d.get('locations') or []
locs.append({'path':'/hk-planner/','forward_scheme':'http','forward_host':'10.10.10.118','forward_port':3080,'advanced_config':''})
print(json.dumps(locs))
" 2>/dev/null) || true
if [[ -n "$merged_locations" ]]; then
curl -sf -X PUT "http://${npm_ip}:81/api/nginx/proxy-hosts/${host_id}" \
-H "Authorization: Bearer ${npm_token}" \
-H "Content-Type: application/json" \
-d "{\"locations\":${merged_locations}}" &>/dev/null \
&& msg_ok "NPM location /hk-planner/ added" \
|| msg_warn "NPM update failed — add /hk-planner/ → 10.10.10.118:3080 manually"
fi
else
msg_ok "NPM location /hk-planner/ already present"
fi
else
msg_warn "NPM proxy host for ${DOMAIN} not found — add /hk-planner/ → 10.10.10.118:3080 manually"
fi
else
msg_warn "NPM API unavailable — add /hk-planner/ → 10.10.10.118:3080 manually in NPM admin"
fi
}
# ════════════════════════════════════════════════════════════════════════════
# NPM PROXY HOSTS (via API)
# ════════════════════════════════════════════════════════════════════════════
# Returns a Bearer token for the NPM API, or empty string on failure.
npm_get_token() {
local npm_ip="${NPM_LAN_IP:-10.10.10.103}"
local email="${NPM_ADMIN_EMAIL:-manage@hotel.com}"
local pass="${NPM_ADMIN_PASS:-}"
[[ -z "$pass" ]] && { echo ""; return; }
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 || echo ""
}
configure_npm_proxy_hosts() {
msg_step "Configuring NPM proxy hosts"
msg_info "Waiting for NPM API to be ready"
local i=0
while ! curl -sf --max-time 3 "http://${NPM_LAN_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_LAN_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":""}
]'
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"
[[ -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)"
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 ;;
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
configure_npm_proxy_hosts
print_summary