23 KiB
App Integration Guide — HNF Stack
This document is the reference for porting existing apps into the HNF Proxmox stack. Each app gets its own porting session — read this first before starting any port.
Multi-Site Design Principle
This stack is designed to deploy identically across multiple hotel Proxmox hosts.
The internal container network (10.10.10.0/24) is the same at every site — it is
isolated inside each Proxmox host and has no conflict with the hotel's own LAN subnet.
All docker-compose files, DATABASE_URLs, and internal service references use
10.10.10.x addresses and are therefore identical across all sites.
Only these values differ per site, set via env vars at provision time:
| Env var | Example (HNF) | Notes |
|---|---|---|
DOMAIN |
manage.hotelnumberfour.com |
Per-site domain for NPM + auth cookie |
OFFICE_PUBLIC_IP |
x.x.x.x |
Site WAN IP for offsite access restriction |
LAN_SUBNET |
10.4.0.0/22 |
Used when assigning NPM LXC's LAN IP |
Forgejo deploy webhooks: each hotel's management container registers its own webhook
in the shared Forgejo repo. A push to main fires to all registered hotels simultaneously
— all sites update in parallel. To stage a rollout, temporarily disable a site's webhook.
Stack Overview
Hotel LAN (any subnet — e.g. 10.4.0.0/22 at HNF, 192.168.x.x elsewhere)
│
└── LXC: NPM <LAN IP from site pool> ← only container with a LAN IP
│ <DOMAIN> — single SSL cert
│
└── Internal network (vmbr1: 10.10.10.0/24) — SAME at every site
├── 10.10.10.100 PostgreSQL :5432 (internal only)
├── 10.10.10.101 Auth service :3001
├── 10.10.10.102 Portal :3000
├── 10.10.10.105 Management :3002 (Uptime Kuma → /monitor/)
│ :9000 (Forgejo webhook, internal only)
├── 10.10.10.110 Kitchen Flash :3080
├── 10.10.10.114 Housekeeping :3014
└── 10.10.10.1xx (future apps — same IPs at all sites)
NPM LXC is the only container with a LAN IP. All others are on the internal vmbr1
bridge — invisible from the hotel LAN. NPM proxies paths to internal LXC IPs.
External Forgejo (on developer's own server) is the source of truth for all app repos. The management container's update service receives webhooks from it and deploys to app LXCs.
- All apps share a single PostgreSQL instance (LXC .100) — each app gets its own database.
- Auth is enforced on each app independently via a shared httpOnly cookie (
hnf_session). - The portal shell loads app UIs in
<iframe>elements — same origin, no CORS issues.
Central Auth Service
Base URL (internal): http://10.10.10.101:3001 (same at all sites)
Base URL (via NPM): https://<DOMAIN>/api/auth (site-specific, e.g. manage.hotelnumberfour.com)
Endpoints
| Method | Path | Description |
|---|---|---|
| POST | /api/auth/login |
Email + password → sets hnf_session httpOnly cookie |
| POST | /api/auth/logout |
Clears cookie |
| GET | /api/auth/me |
Returns user profile + apps[] permission list |
| GET | /api/auth/verify?app=<slug> |
Validate cookie + check app permission + IP restriction |
/api/auth/verify Response
// 200 OK — proceed
{
"user_id": 1,
"email": "jane@hotelnumberfour.com",
"name": "Jane Smith",
"is_admin": false,
"app": "kitchen"
}
// 401 — no valid session cookie
// 403 — valid session but no permission for this app, OR offsite restriction
Cookie Details
- Name:
hnf_session - Domain:
.manage.hotelnumberfour.com(leading dot = all subpaths) - Flags:
httpOnly,Secure,SameSite=Lax - Payload:
{ sub: "user@email.com", apps: ["kitchen","hk"], offsite_allowed: true, exp: ... } - Algorithm: HS256, secret is
CENTRAL_AUTH_SECRETenv var
JWT Payload Structure
{
"sub": "user@hotelnumberfour.com",
"name": "Jane Smith",
"apps": ["kitchen", "hk", "cashup"],
"offsite_allowed": true,
"iat": 1234567890,
"exp": 1234654290
}
Shared PostgreSQL
Host (internal LAN): 10.10.10.100:5432
Per-app connection string pattern: postgresql://<app>:<password>@10.10.10.100:5432/<app>_db
Each app gets:
- Its own database (e.g.
kitchen_db,cashup_db,hk_db) - Its own postgres user with access only to that database
- Its own schema within that database (matching existing schema if migrating)
To migrate an existing app DB: export from app's current postgres, import into shared PG.
Schema init SQL files live in infrastructure/postgres/init/<app>.sql in this repo.
Per-App Integration Checklist
1. Base Path Configuration
Every app must serve itself under its path prefix (e.g. /kitchen). NPM strips nothing — it
proxies the full path to the app's port. The app must handle the prefix.
React/Vite apps (like Kitchen Flash):
vite.config.ts:
export default defineConfig({
base: '/kitchen/', // <-- add this
plugins: [react()],
})
src/App.tsx (or wherever BrowserRouter is):
<BrowserRouter basename="/kitchen">
Any hardcoded API calls must use a base URL from an env var:
VITE_API_BASE=/kitchen
Python/FastAPI backends:
FastAPI doesn't need changes — the frontend nginx handles path stripping.
Update nginx.conf (see section below).
Next.js apps:
next.config.js:
module.exports = { basePath: '/hk' }
2. Nginx Config (for apps with their own nginx frontend)
Replace the location / block with a path-aware version:
# In the app's nginx.conf — replace root location block
location /kitchen/ {
alias /usr/share/nginx/html/;
try_files $uri $uri/ /kitchen/index.html;
}
location /kitchen/api/ {
proxy_pass http://backend:8000/api/;
# ... existing proxy headers ...
}
location /kitchen/auth/ {
proxy_pass http://backend:8000/auth/;
# ... existing proxy headers ...
}
3. Auth Integration — Python/FastAPI Apps
Add to backend/auth/jwt.py (before the existing Bearer check):
from fastapi import Request, Cookie
from typing import Optional
import os
CENTRAL_AUTH_SECRET = os.getenv("CENTRAL_AUTH_SECRET")
APP_SLUG = os.getenv("APP_SLUG", "kitchen") # set per-app in docker-compose
async def get_current_user(
request: Request,
credentials: Optional[HTTPAuthorizationCredentials] = Depends(HTTPBearer(auto_error=False)),
db: AsyncSession = Depends(get_db)
) -> User:
# 1. Try central auth cookie first
central_token = request.cookies.get("hnf_session")
if central_token and CENTRAL_AUTH_SECRET:
try:
payload = jwt.decode(central_token, CENTRAL_AUTH_SECRET, algorithms=["HS256"])
email = payload.get("sub")
if email and APP_SLUG in payload.get("apps", []):
# Auto-create or find local user by email
result = await db.execute(select(User).where(User.email == email))
user = result.scalar_one_or_none()
if not user:
user = User(
email=email,
name=payload.get("name", email),
password_hash="", # no local password for SSO users
kitchen_id=1, # adapt per app
is_admin=False,
is_active=True
)
db.add(user)
await db.commit()
await db.refresh(user)
if user.is_active:
return user
except Exception:
pass # fall through to Bearer check
# 2. Fall back to existing Bearer token (backwards compat)
if credentials:
token = credentials.credentials
user = await get_current_user_from_token(token, db)
if user:
return user
raise HTTPException(status_code=401, detail="Not authenticated")
Add to docker-compose.yml backend environment:
environment:
- CENTRAL_AUTH_SECRET=${CENTRAL_AUTH_SECRET}
- APP_SLUG=kitchen
- DATABASE_URL=postgresql://kitchen:${KITCHEN_DB_PASS}@10.10.10.100:5432/kitchen_db
Remove the db: service from docker-compose (now uses shared PG).
4. Auth Integration — Node/Express or Fastify Apps
// middleware/central-auth.js
const jwt = require('jsonwebtoken')
function centralAuth(appSlug) {
return (req, res, next) => {
const token = req.cookies?.hnf_session
if (!token) return res.status(401).json({ error: 'Not authenticated' })
try {
const payload = jwt.verify(token, process.env.CENTRAL_AUTH_SECRET)
if (!payload.apps?.includes(appSlug)) {
return res.status(403).json({ error: 'No permission for this app' })
}
req.user = { email: payload.sub, name: payload.name }
next()
} catch {
res.status(401).json({ error: 'Invalid session' })
}
}
}
5. Health Endpoint
Every app must expose GET /health returning { "status": "healthy" } with HTTP 200.
This is what Uptime Kuma polls. For NPM routing it must be at the prefixed path:
GET /kitchen/health should return 200.
Add to nginx.conf:
location /kitchen/health {
proxy_pass http://backend:8000/health;
}
6. Docker Compose Template
services:
backend:
build: ./backend
environment:
- DATABASE_URL=postgresql://appname:${DB_PASS}@10.10.10.100:5432/appname_db
- CENTRAL_AUTH_SECRET=${CENTRAL_AUTH_SECRET}
- APP_SLUG=appname
ports:
- "8000:8000" # internal only, NPM hits the frontend port
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:8000/health || exit 1"]
interval: 10s
retries: 5
restart: unless-stopped
frontend:
build: ./frontend
ports:
- "3080:80" # this port is what NPM proxies to
depends_on:
backend:
condition: service_healthy
restart: unless-stopped
7. Environment Variables (.env on each LXC)
# Required on every app LXC
CENTRAL_AUTH_SECRET=<shared secret from auth service — same value on all LXCs>
DB_PASS=<app-specific postgres password>
# App-specific
APP_SLUG=kitchen # or cashup, hk, etc.
The CENTRAL_AUTH_SECRET value is generated once during auth service setup and copied
to all app LXCs. Store it in the ops notes / password manager.
PWA Multi-Install Architecture
Each app in the stack is independently installable as a PWA while still running through the same auth and backend infrastructure. This lets different staff roles have a focused home-screen app without exposing anything directly.
Manager → installs manage.hotelnumberfour.com → "HNF Manage" (full portal)
Housekeeper → installs manage.hotelnumberfour.com/hk/ → "Housekeeping" (HK only)
Kitchen staff → installs manage.hotelnumberfour.com/kitchen/ → "Kitchen Flash"
All three use the same hnf_session cookie and central auth. The distinction is purely
in which manifest.json the browser fetches when the user chooses "Add to Home Screen".
Per-App Manifest
Each app serves its own manifest.json at its root path. Key fields:
{
"name": "Kitchen Flash",
"short_name": "Kitchen",
"start_url": "/kitchen/",
"scope": "/",
"display": "standalone",
"theme_color": "#e85d04",
"background_color": "#1a1a2e",
"icons": [
{ "src": "/kitchen/icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/kitchen/icons/icon-512.png", "sizes": "512x512", "type": "image/png" }
]
}
Critical: scope must be "/" (not "/kitchen/"). This keeps the login flow and
any cross-app navigation inside the PWA context rather than opening the browser.
start_url controls where the app opens — this is the differentiator between installs.
Each app should have distinct theme_color and icons so installed apps are visually
distinguishable on the home screen.
Suggested colour scheme:
| App | theme_color |
|---|---|
| Portal / HNF Manage | #1e3a5f (navy) |
| Kitchen Flash | #e85d04 (orange) |
| Housekeeping | #2d6a4f (green) |
| Cashup | #6b2d8b (purple) |
| Forecasting | #0077b6 (blue) |
Auth Within PWA Scope (no page-navigation login)
When a sub-app PWA opens and no session cookie exists, we cannot redirect to /login
because that's outside the start_url path — in practice with scope: "/" it's fine,
but the cleaner pattern is in-app auth so the experience stays seamless:
React auth wrapper pattern (add to every app's frontend):
// src/components/AuthGate.tsx
import { useEffect, useState } from 'react'
export function AuthGate({ children }: { children: React.ReactNode }) {
const [state, setState] = useState<'checking' | 'authed' | 'login'>('checking')
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
useEffect(() => {
fetch('/api/auth/verify?app=APPSLUG', { credentials: 'include' })
.then(r => setState(r.ok ? 'authed' : 'login'))
.catch(() => setState('login'))
}, [])
async function login(e: React.FormEvent) {
e.preventDefault()
const res = await fetch('/api/auth/login', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password })
})
if (res.ok) {
// Verify permission for this specific app
const verify = await fetch('/api/auth/verify?app=APPSLUG', { credentials: 'include' })
if (verify.ok) setState('authed')
else setError("You don't have access to this app.")
} else {
setError('Invalid email or password')
}
}
if (state === 'checking') return <div className="auth-loading">Loading…</div>
if (state === 'login') return (
<div className="auth-screen">
<img src="/kitchen/icons/icon-192.png" alt="App icon" />
<h1>Kitchen Flash</h1>
<form onSubmit={login}>
<input type="email" value={email} onChange={e => setEmail(e.target.value)}
placeholder="Email" required />
<input type="password" value={password} onChange={e => setPassword(e.target.value)}
placeholder="Password" required />
{error && <p className="error">{error}</p>}
<button type="submit">Sign in</button>
</form>
</div>
)
return <>{children}</>
}
Wrap the app root: <AuthGate><App /></AuthGate>
fetch calls are XHR — they don't navigate the page, so the PWA scope is never broken.
The cookie is set domain-wide (manage.hotelnumberfour.com), so once the housekeeper
logs in via the HK PWA, they won't need to log in again if they open another app.
Portal "Install" Shortcuts
The portal dashboard shows each permitted app as a tile. Each tile has a secondary
"Install" action that navigates to /<app>/?install=1.
Each app detects this query param and triggers the install prompt:
// In app's main entry (e.g. main.tsx or App.tsx)
useEffect(() => {
const params = new URLSearchParams(window.location.search)
if (params.get('install') === '1') {
window.addEventListener('beforeinstallprompt', (e) => {
e.preventDefault()
;(e as any).prompt() // trigger install banner immediately
}, { once: true })
}
}, [])
The portal tile install button links to the app URL with ?install=1. On mobile, this
triggers the browser's "Add to Home Screen" flow for that specific app manifest.
Portal can also generate a QR code or shareable install link per app for managers to distribute to relevant staff.
Three Install Tiers
| Role | Install URL | PWA Name | Access |
|---|---|---|---|
| Manager | manage.hotelnumberfour.com |
HNF Manage | All permitted apps in portal |
| Dept. head | manage.hotelnumberfour.com/kitchen |
Kitchen Flash | Kitchen only, full UI |
| Operative | manage.hotelnumberfour.com/hk |
Housekeeping | HK tasks only |
All tiers use the same auth cookie — a user who has both Kitchen and HK permissions can install both and they'll both work from the same session.
Service Worker Scope Considerations
Each app's service worker must be registered with its path as scope to avoid conflicts between apps:
// In kitchen app's sw registration
navigator.serviceWorker.register('/kitchen/sw.js', { scope: '/kitchen/' })
The portal registers its service worker at /sw.js with default scope /. This
caches the portal shell. App service workers cache their own assets independently.
WordPress Plugin → Standalone App
For plugins like the housekeeping tools:
-
Identify the data: Find all
$wpdb->query,$wpdb->get_results, custom table creates in the activator class. These become the app's DB schema. -
Identify the API surface: Find all
wp_ajax_*hooks in the AJAX class. These become REST endpoints in the new backend. -
Identify the frontend: PHP views + JS files in
/public/js/describe the UI. Rewrite as React components. -
Newbook integration: If the plugin uses Newbook API, the integration logic is usually in a
class-hhc-newbook-api.phpor similar. Port the HTTP calls to Python requests or Node fetch. -
Auth: Plugins depend on WordPress user auth. In the new stack, auth comes from the central
hnf_sessioncookie — no WordPress needed.
Reference files:
- Hour calculator plugin:
/home/jtr/laptop-archive/hotel-housekeeping-hour-calculator/ - Housekeeping PWA plugin:
/home/jtr/laptop-archive/housekeeping-pwa-app/
Management Container
LXC 105 runs three services:
| Service | Port | Role |
|---|---|---|
| Uptime Kuma | 3002 | Health monitoring (exposed via NPM at /monitor/) |
| Update service | 9000 | Forgejo webhook receiver (internal network only — never via NPM) |
| Backup service | — | Cron-based pg_dump + volume snapshots |
App Updates via Forgejo Webhooks
Each app repo on your Forgejo server has a webhook pointing at the management container:
Webhook URL: http://10.10.10.105:9000/webhook
Secret: <shared secret — set in management container .env>
Events: Push (to main branch only)
The update service maps incoming repo names to target LXC IPs and SSH commands:
// management repo: updater/deploy-map.js
module.exports = {
'kitchen-flash': { ip: '10.10.10.110', path: '/opt/kitchen' },
'housekeeping': { ip: '10.10.10.114', path: '/opt/hk' },
'cashup': { ip: '10.10.10.111', path: '/opt/cashup' },
'hnf-portal': { ip: '10.10.10.102', path: '/opt/portal' },
'hnf-auth': { ip: '10.10.10.101', path: '/opt/auth' },
}
On receiving a valid webhook:
- Validate Forgejo HMAC signature against shared secret
- Look up repo name in deploy map
- SSH to target LXC:
cd <path> && git pull && docker compose up -d --build - Poll
http://<ip>:<port>/healthevery 5s for up to 60s - Log result (success/fail) — optionally notify via portal admin or webhook back to Forgejo commit status
Each app LXC must have the management container's SSH public key in ~/.ssh/authorized_keys.
The management container's SSH key is generated at provisioning time and distributed to all LXCs.
Per-app Forgejo repo setup: Each app lives in its own repo on your Forgejo server.
The main branch is production. The app LXC clones it at provision time; updates pull from it.
# On app LXC at provision time
git clone https://forgejo.yourserver.com/hnf/kitchen-flash.git /opt/kitchen
Backup Service
Runs as a cron container (docker-compose service with restart: unless-stopped and cron inside).
Schedule:
- Daily at 02:00:
pg_dumpeach database → compressed → rotate (keep 7 days) - Weekly Sunday at 03:00: full
pg_dumpall+ data volume snapshot → rotate (keep 4 weeks) - After each backup: rsync to remote destination
PostgreSQL backup (per database):
PGPASSWORD=$PG_PASS pg_dump -h 10.10.10.100 -U <app_user> <db_name> \
| gzip > /backups/postgres/<db_name>_$(date +%Y%m%d).sql.gz
Volume backup (for apps with persistent file data, e.g. kitchen invoice PDFs):
docker run --rm \
-v kitchen_invoice_data:/source:ro \
-v /backups/volumes:/backup \
alpine tar czf /backup/kitchen_invoices_$(date +%Y%m%d).tar.gz -C /source .
Rsync to remote (your own server, same place as Forgejo):
rsync -az --delete /backups/ user@yourserver.com:/backups/hnf-proxmox/
Retention cleanup (run after each backup cycle):
find /backups/postgres -name "*.gz" -mtime +7 -delete # daily: keep 7
find /backups/postgres -name "*_weekly_*.gz" -mtime +28 -delete # weekly: keep 4
Backup .env on management LXC:
PG_PASS=<postgres superuser password>
BACKUP_REMOTE_HOST=user@yourserver.com
BACKUP_REMOTE_PATH=/backups/hnf-proxmox
BACKUP_DATABASES=kitchen_db cashup_db hk_db forecast_db auth_db
BACKUP_VOLUMES=kitchen_invoice_data # space-separated docker volume names
Backup status is reported to Uptime Kuma via a heartbeat push URL — if the backup script fails to complete, Kuma marks it down. Add a monitor of type "Push" in Kuma and paste the push URL into the backup script as the last step.
NPM LXC Config
NPM runs as a dual-homed LXC container — the only container with a LAN IP.
Proxmox LXC network config (in Proxmox UI or /etc/pve/lxc/<id>.conf):
net0: name=eth0,bridge=vmbr0,ip=10.4.X.X/22,gw=10.4.0.1 # LAN-facing — assign static IP from your pool
net1: name=eth1,bridge=vmbr1,ip=10.10.10.2/24 # internal
NPM proxy host config (one entry per app, all on same domain):
| Location | Forward to | Port | Notes |
|---|---|---|---|
/ |
10.10.10.102 |
3000 | Portal |
/api/auth/ |
10.10.10.101 |
3001 | Auth service |
/kitchen/ |
10.10.10.110 |
3080 | Kitchen (enable WS support — uses SSE for KDS) |
/hk/ |
10.10.10.114 |
3014 | Housekeeping |
/monitor/ |
10.10.10.105 |
3002 | Uptime Kuma (restrict to admin users via auth) |
All under domain manage.hotelnumberfour.com. Single Let's Encrypt cert via NPM's built-in ACME.
Required NPM custom nginx snippet (add to each proxy host's Advanced tab):
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
The X-Real-IP header is what the auth service uses for offsite access restriction.
Portal Registration
When a new app is ported and running, register it in the auth service DB:
INSERT INTO auth.apps (slug, name, description, base_path, port)
VALUES ('kitchen', 'Kitchen Flash', 'Invoice processing and GP tracking', '/kitchen', 3080);
Then grant access to users via the portal admin UI (or directly in the DB during early setup):
INSERT INTO auth.user_app_perms (user_id, app_id)
SELECT u.id, a.id FROM auth.users u, auth.apps a
WHERE u.email = 'jane@hotelnumberfour.com' AND a.slug = 'kitchen';
LXC Provisioning
Each app LXC is provisioned with:
# Run infrastructure/lxc-templates/provision.sh <lxc-id> <ip> <app-name>
./infrastructure/lxc-templates/provision.sh 110 10.10.10.110 kitchen
This installs: Ubuntu 22.04, Docker, Docker Compose, copies .env template.
Then git clone or rsync the app directory and docker-compose up -d.