Management UI
CI / ci (push) Canceled after 0s

Attempt 2 of a management ui.
This commit is contained in:
Stefan Lazic committed 2026-09-05 13:39:55 +01:00
1 parent 9fe5b4f038
commit a0d39026c0
54 files changed
+8804 -267

No files matched your search

+7
View File
@@ -3,6 +3,13 @@ node_modules
generated generated
.git .git
.gitea .gitea
# A fully separate app (own package.json, own Dockerfile) — has no
# business being copied into the main app's build context at all, and
# its own @/* import alias resolving against the main app's tsconfig
# (via the include glob's **/*.ts) broke a plain `docker build` here
# before this line existed.
manager
stacks
.env .env
.env.* .env.*
!.env.example !.env.example
+14 -15
View File
@@ -1,10 +1,11 @@
# One business's config on the shared multi-business VPS setup — see # One business's config on the shared multi-business VPS setup — see
# docker-compose.infra.yaml for the one-time setup this depends on # stacks/infra/compose.yaml for the one-time setup this depends on
# (creating this business's database on the shared MySQL server) and # (creating this business's database on the shared MySQL server) and
# docker-compose.<business>.yaml for how this file is used. Copy this to # stacks/<slug>/compose.yaml for how this file is used. Copy this to
# `.env.business.<slug>` (e.g. `.env.business.blake-heritage` — kept out # `stacks/<slug>/.env` (e.g. `stacks/blake-heritage/.env` — kept out of
# of git, see .gitignore) per business and fill in real values. Nothing # git, see .gitignore) per business and fill in real values. Nothing here
# here is a real credential. # is a real credential. Normally the management UI (manager/) generates
# this file for you — this template is for the by-hand setup path.
# #
# The app starts and runs a complete first-install flow with ONLY the # The app starts and runs a complete first-install flow with ONLY the
# "Required for basic startup" section below filled in — every other # "Required for basic startup" section below filled in — every other
@@ -12,17 +13,16 @@
# disabled state rather than crashing when its variables are left blank. # disabled state rather than crashing when its variables are left blank.
# ── Required for basic startup ───────────────────────────────────────── # ── Required for basic startup ─────────────────────────────────────────
# The public domain this business is served on — used by Traefik's # The public domain this business is served on — must match the .caddy
# routing label (see docker-compose.<business>.yaml) to decide which # site file's own domain (see stacks/infra/sites/.gitkeep) and that
# container handles requests for it, so this must be set and must match # domain's real DNS (pointed at the VPS) before Caddy can issue a
# the domain's real DNS (pointed at the VPS) before Traefik can issue a
# certificate for it. # certificate for it.
DOMAIN="blake-heritage.example.com" DOMAIN="blake-heritage.example.com"
# The app reaches MySQL via the shared infra's service hostname # The app reaches MySQL via the shared infra's service hostname
# `next-chair-mysql`, never `localhost` — see docker-compose.infra.yaml. # `next-chair-mysql`, never `localhost` — see stacks/infra/compose.yaml.
# DATABASE_NAME/USER/PASSWORD must match exactly what you created for # DATABASE_NAME/USER/PASSWORD must match exactly what you created for
# this business on the shared server (see docker-compose.infra.yaml's # this business on the shared server (see stacks/infra/compose.yaml's
# "CREATE DATABASE ..." step) — this file doesn't create them, it only # "CREATE DATABASE ..." step) — this file doesn't create them, it only
# tells the app what to connect as. # tells the app what to connect as.
DATABASE_HOST="next-chair-mysql" DATABASE_HOST="next-chair-mysql"
@@ -44,10 +44,9 @@ INSTALLATION_TOKEN="replace-me"
CRON_SECRET="replace-me" CRON_SECRET="replace-me"
# ── Super Administrator (optional — see scripts/ensure-super-admin.ts) ── # ── Super Administrator (optional — see scripts/ensure-super-admin.ts) ──
# Platform-level account with every Owner permission. Not run # Platform-level account with every Owner permission for THIS business.
# automatically by this compose file (that's a Docker-entrypoint-only # Not run automatically by this compose file (that's a Docker-entrypoint-
# behaviour, see docker/entrypoint.sh) — run it manually, once, the same # only behaviour, see docker/entrypoint.sh) — run it manually, once:
# way migrations were run for the Vercel/Hostinger deployment earlier:
# docker exec -it next-chair-blake-heritage npx tsx scripts/ensure-super-admin.ts # docker exec -it next-chair-blake-heritage npx tsx scripts/ensure-super-admin.ts
# Leave SUPER_ADMIN_EMAIL blank to skip entirely and use the /install # Leave SUPER_ADMIN_EMAIL blank to skip entirely and use the /install
# wizard's own administrator account instead. # wizard's own administrator account instead.
+22 -6
View File
@@ -1,12 +1,12 @@
# Shared infrastructure config — see docker-compose.infra.yaml for the # Shared infrastructure config — see stacks/infra/compose.yaml for the
# full setup sequence. Copy this to `.env.infra` (kept out of git — see # full setup sequence. Copy this to `stacks/infra/.env` (kept out of git
# .gitignore) and fill in real values. # — see .gitignore) and fill in real values.
# #
# docker network create next-chair-edge # docker network create next-chair-edge
# docker compose -f docker-compose.infra.yaml --env-file .env.infra up -d # docker compose -f stacks/infra/compose.yaml --env-file stacks/infra/.env up -d
# ── MySQL (shared server — one database per business, created manually # ── MySQL (shared server — one database per business, created manually
# per business, see docker-compose.infra.yaml's own header) ──────────── # per business, see stacks/infra/compose.yaml's own header) ────────────
MYSQL_ROOT_PASSWORD="replace-me" MYSQL_ROOT_PASSWORD="replace-me"
# Host-machine port MySQL is reachable on, e.g. for a GUI client over an # Host-machine port MySQL is reachable on, e.g. for a GUI client over an
# SSH tunnel — every business's app container always reaches it via the # SSH tunnel — every business's app container always reaches it via the
@@ -14,7 +14,23 @@ MYSQL_ROOT_PASSWORD="replace-me"
# this. # this.
MYSQL_HOST_PORT=3306 MYSQL_HOST_PORT=3306
# ── Traefik (reverse proxy + automatic TLS via Let's Encrypt) ────────── # ── Caddy (reverse proxy + automatic TLS via Let's Encrypt) ────────────
# Let's Encrypt sends renewal/expiry notices here — use a real address # Let's Encrypt sends renewal/expiry notices here — use a real address
# you actually check, not a placeholder. # you actually check, not a placeholder.
ACME_EMAIL="you@example.com" ACME_EMAIL="you@example.com"
# Leave these at 80/443 on a real deployment — only override for local
# testing on a machine already using those ports for something else.
CADDY_HTTP_PORT=80
CADDY_HTTPS_PORT=443
# ── Dockge (stack management UI — http://<vps>:5001) ───────────────────
# This repo's absolute path on the VPS, e.g. "/opt/nextchair/stacks" — NOT
# a relative path. Dockge (and the management UI in manager/) shell
# `docker compose` out to the HOST's dockerd via the mounted Docker
# socket, so a compose-file path only resolves to the same file on both
# sides if the bind-mount doesn't change it — meaning this must be
# mounted at the identical path inside the container too (see
# stacks/infra/compose.yaml's dockge service). Get this wrong and Dockge
# will either fail to find your stacks or manage the wrong path entirely.
STACKS_DIR="/opt/nextchair/stacks"
DOCKGE_HOST_PORT=5001
+13 -12
View File
@@ -26,14 +26,19 @@ dist/
.env.production .env.production
# per-deployment production env files (multi-business VPS setup — see # per-deployment production env files (multi-business VPS setup — see
# docker-compose.infra.yaml / docker-compose.<business>.yaml). Named # stacks/infra/compose.yaml / stacks/<business>/compose.yaml). Every real
# .env.business.* (not .env.<business> directly) specifically so this one # stack's .env lives at stacks/<name>/.env (Dockge's own directory
# wildcard rule keeps covering every future business without another # convention) — this one wildcard rule covers every current and future
# .gitignore edit. The template itself (.env.business.example) stays # stack without another .gitignore edit. .env.infra.example and
# tracked, same as .env.local.example above. # .env.business.example (the templates you copy into each stacks/<name>/
.env.infra # directory) stay tracked, same as .env.local.example above.
.env.business.* stacks/*/.env
!.env.business.example
# Real per-business Caddy site files (deployment-specific domains, not
# something to hand-author into the repo) — only the placeholder stays
# tracked.
stacks/infra/sites/*.caddy
!stacks/infra/sites/.gitkeep
# prisma # prisma
prisma/dev.db prisma/dev.db
@@ -57,10 +62,6 @@ yarn-error.log*
.idea/ .idea/
/generated/prisma /generated/prisma
.env.example
.env.local.example
.env.example
.env.example
.agents/ .agents/
.claude/ .claude/
+49 -1
View File
@@ -1,3 +1,51 @@
# NextChair # NextChair
Booking app as an alternative to Booksy Booking app as an alternative to Booksy
## VPS deployment (multi-business)
One-time setup on a fresh VPS with Docker and the Compose plugin already
installed. Run everything from the repo's checkout directory.
```bash
# 1. Clone the repo at the path you intend to keep it at — STACKS_DIR
# below must be this exact absolute path, since containers shell
# `docker compose` back out to the HOST's dockerd via a mounted
# socket (see stacks/infra/compose.yaml and manager/README.md for why).
git clone <your-repo-url> /opt/nextchair
cd /opt/nextchair
# 2. Shared Docker network every stack attaches to.
docker network create next-chair-edge
# 3. Shared infrastructure: MySQL, Caddy (reverse proxy + automatic TLS),
# Dockge (stack management UI, http://<vps-ip>:5001).
cp .env.infra.example stacks/infra/.env
# edit stacks/infra/.env — MYSQL_ROOT_PASSWORD, ACME_EMAIL, and set
# STACKS_DIR to this exact clone path, e.g. /opt/nextchair/stacks
docker compose -f stacks/infra/compose.yaml --env-file stacks/infra/.env up -d
# 4. Build the shared business image (re-run this after every code
# update — see stacks/blake-heritage/compose.yaml's own note).
docker build -t nextchair-app:latest .
# 5. The management UI (manager/) — this is what you'll use to add
# businesses from here on, not the by-hand steps in stacks/infra/
# compose.yaml's header (those exist only as a fallback/reference).
cp manager/.env.example stacks/manager/.env
# edit stacks/manager/.env — MANAGER_EMAIL/PASSWORD_HASH, MANAGER_SESSION_SECRET,
# MYSQL_ROOT_PASSWORD (same value as step 3), and STACKS_DIR (same as step 3)
docker compose -f stacks/manager/compose.yaml --env-file stacks/manager/.env up -d --build
```
Then, per business, point its domain's DNS at the VPS and use the
management UI (`https://<your-manager-domain>` — add a Caddy site for it
the same way as any business, see `stacks/infra/sites/.gitkeep`) to add
it: name, slug, domain — it creates the database, generates secrets,
starts the container, and runs migrations for you. Finish setup by
visiting `https://<business-domain>/install`.
To ship a code update to every business: `git pull`, rebuild the image
(step 4), then use each business's **Update** button in the management UI
(or `docker compose up -d --force-recreate` in its `stacks/<slug>/`
directory) to recreate it against the new image.
-63
View File
@@ -1,63 +0,0 @@
name: next-chair-blake-heritage
# One business's app container on the shared multi-business VPS setup —
# see docker-compose.infra.yaml for the shared MySQL/Traefik this attaches
# to, and that file's header for the exact one-time setup steps (create
# this business's database, copy .env.business.example) needed before
# this file can start.
#
# docker compose -f docker-compose.blake-heritage.yaml --env-file .env.business.blake-heritage up -d --build
#
# `docker-compose.marios.yaml` is the identical shape for the second
# business — copy this file's pattern again for any future business.
services:
next-chair-blake-heritage:
build:
context: .
dockerfile: Dockerfile
# Builds the image fresh here. Once there are more than a couple of
# businesses, it's worth building once (`docker build -t
# nextchair-app:latest .`) and swapping this `build:` block for
# `image: nextchair-app:latest` in every business's file instead, so
# a code update doesn't mean rebuilding the identical image N times —
# not necessary to start with.
container_name: next-chair-blake-heritage
restart: unless-stopped
env_file:
- .env.business.blake-heritage
volumes:
- next-chair-media-blake-heritage:/app/storage/media
healthcheck:
# No curl/wget in the node:22-alpine base image — a plain Node
# request avoids adding a package just for this. See
# src/app/api/health/route.ts: healthy as soon as the database is
# reachable, even before installation completes.
test:
[
"CMD",
"node",
"-e",
"require('http').get('http://localhost:3000/api/health', r => process.exit(r.statusCode === 200 ? 0 : 1)).on('error', () => process.exit(1))",
]
interval: 10s
timeout: 5s
retries: 10
start_period: 20s
labels:
- "traefik.enable=true"
- "traefik.http.routers.blake-heritage.rule=Host(`${DOMAIN}`)"
- "traefik.http.routers.blake-heritage.entrypoints=websecure"
- "traefik.http.routers.blake-heritage.tls.certresolver=letsencrypt"
- "traefik.http.services.blake-heritage.loadbalancer.server.port=3000"
networks:
- next-chair-edge
volumes:
next-chair-media-blake-heritage:
name: next-chair-media-blake-heritage
networks:
next-chair-edge:
name: next-chair-edge
external: true
-103
View File
@@ -1,103 +0,0 @@
name: next-chair-infra
# Shared infrastructure for a multi-business VPS deployment: one Traefik
# reverse proxy (routes each business's domain to its own app container,
# handles TLS via Let's Encrypt) and one MySQL server shared by every
# business — a separate database + user per business, NOT one shared
# schema (see step 1 below).
#
# Deliberately separate from docker-compose.local.yaml, which is the
# single-business local/Docker-Desktop setup and stays untouched by this.
#
# ── One-time setup ───────────────────────────────────────────────────────
# docker network create next-chair-edge
# cp .env.infra.example .env.infra (then fill in real values)
# docker compose -f docker-compose.infra.yaml --env-file .env.infra up -d
#
# ── Per business, after infra is running (see docker-compose.<business>
# .yaml — this repo ships one per business, e.g. docker-compose.blake-
# heritage.yaml) ────────────────────────────────────────────────────────
# 1. Create that business's database + user on the shared MySQL server.
# There's no automatic way to do this per business via env vars —
# the mysql:8.0 image's MYSQL_DATABASE/USER/PASSWORD vars only
# provision ONE database at first boot, and /docker-entrypoint-
# initdb.d scripts only ever run once against an empty data
# directory — so this one step is manual, run once per business:
# docker exec -it next-chair-mysql mysql -uroot -p
# CREATE DATABASE blake_heritage CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
# CREATE USER 'blake_heritage'@'%' IDENTIFIED BY 'choose-a-strong-password';
# GRANT ALL PRIVILEGES ON blake_heritage.* TO 'blake_heritage'@'%';
# FLUSH PRIVILEGES;
# 2. Copy .env.business.example to .env.business.<slug>, fill it in
# with the credentials just created and that business's domain.
# 3. docker compose -f docker-compose.<business>.yaml --env-file .env.business.<slug> up -d --build
services:
traefik:
image: traefik:v3.2
container_name: next-chair-traefik
restart: unless-stopped
command:
- "--providers.docker=true"
# Only containers with an explicit traefik.enable=true label are
# ever routed to — nothing becomes reachable by accident just for
# sitting on the shared network.
- "--providers.docker.exposedbydefault=false"
- "--entrypoints.web.address=:80"
- "--entrypoints.websecure.address=:443"
# Plain HTTP always redirects to HTTPS — no business is ever served
# unencrypted, even before you've thought about it per-business.
- "--entrypoints.web.http.redirections.entrypoint.to=websecure"
- "--entrypoints.web.http.redirections.entrypoint.scheme=https"
- "--certificatesresolvers.letsencrypt.acme.httpchallenge=true"
- "--certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web"
- "--certificatesresolvers.letsencrypt.acme.email=${ACME_EMAIL}"
- "--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json"
ports:
- "80:80"
- "443:443"
volumes:
# Read-only — Traefik only needs to observe container labels/state
# to discover routes, never control the daemon.
- /var/run/docker.sock:/var/run/docker.sock:ro
- next-chair-traefik-certs:/letsencrypt
networks:
- next-chair-edge
next-chair-mysql:
image: mysql:8.0
container_name: next-chair-mysql
restart: unless-stopped
env_file:
- .env.infra
# No MYSQL_DATABASE/MYSQL_USER/MYSQL_PASSWORD here deliberately — this
# server hosts one database per business (see step 1 above), not one
# shared schema. Only MYSQL_ROOT_PASSWORD (in .env.infra) is required
# for the image to boot.
command: --character-set-server=utf8mb4 --collation-server=utf8mb4_0900_ai_ci
ports:
# Host-machine access (e.g. a GUI client over an SSH tunnel) — every
# business's app container reaches this over the shared Docker
# network via the service name `next-chair-mysql`, never this.
- "${MYSQL_HOST_PORT:-3306}:3306"
volumes:
- next-chair-mysql-data:/var/lib/mysql
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-u", "root", "-p${MYSQL_ROOT_PASSWORD:-change-me-too}"]
interval: 5s
timeout: 5s
retries: 20
start_period: 10s
networks:
- next-chair-edge
volumes:
next-chair-mysql-data:
name: next-chair-mysql-data
next-chair-traefik-certs:
name: next-chair-traefik-certs
networks:
next-chair-edge:
name: next-chair-edge
external: true
-63
View File
@@ -1,63 +0,0 @@
name: next-chair-marios
# One business's app container on the shared multi-business VPS setup —
# see docker-compose.infra.yaml for the shared MySQL/Traefik this attaches
# to, and that file's header for the exact one-time setup steps (create
# this business's database, copy .env.business.example) needed before
# this file can start.
#
# docker compose -f docker-compose.marios.yaml --env-file .env.business.marios up -d --build
#
# `docker-compose.blake-heritage.yaml` is the identical shape for the
# first business — copy this file's pattern again for any future business.
services:
next-chair-marios:
build:
context: .
dockerfile: Dockerfile
# Builds the image fresh here. Once there are more than a couple of
# businesses, it's worth building once (`docker build -t
# nextchair-app:latest .`) and swapping this `build:` block for
# `image: nextchair-app:latest` in every business's file instead, so
# a code update doesn't mean rebuilding the identical image N times —
# not necessary to start with.
container_name: next-chair-marios
restart: unless-stopped
env_file:
- .env.business.marios
volumes:
- next-chair-media-marios:/app/storage/media
healthcheck:
# No curl/wget in the node:22-alpine base image — a plain Node
# request avoids adding a package just for this. See
# src/app/api/health/route.ts: healthy as soon as the database is
# reachable, even before installation completes.
test:
[
"CMD",
"node",
"-e",
"require('http').get('http://localhost:3000/api/health', r => process.exit(r.statusCode === 200 ? 0 : 1)).on('error', () => process.exit(1))",
]
interval: 10s
timeout: 5s
retries: 10
start_period: 20s
labels:
- "traefik.enable=true"
- "traefik.http.routers.marios.rule=Host(`${DOMAIN}`)"
- "traefik.http.routers.marios.entrypoints=websecure"
- "traefik.http.routers.marios.tls.certresolver=letsencrypt"
- "traefik.http.services.marios.loadbalancer.server.port=3000"
networks:
- next-chair-edge
volumes:
next-chair-media-marios:
name: next-chair-media-marios
networks:
next-chair-edge:
name: next-chair-edge
external: true
+1 -1
View File
@@ -3,7 +3,7 @@ import nextTypescript from "eslint-config-next/typescript";
/** @type {import('eslint').Linter.Config[]} */ /** @type {import('eslint').Linter.Config[]} */
const eslintConfig = [ const eslintConfig = [
{ ignores: ["templates/**", "docs/**", "generated/**", ".next/**", "node_modules/**"] }, { ignores: ["templates/**", "docs/**", "generated/**", ".next/**", "node_modules/**", "manager/**"] },
...nextCoreWebVitals, ...nextCoreWebVitals,
...nextTypescript, ...nextTypescript,
{ {
+8
View File
@@ -0,0 +1,8 @@
node_modules
.next
.git
.env
.env.*
!.env.example
.DS_Store
npm-debug.log*
+38
View File
@@ -0,0 +1,38 @@
# Copy to stacks/manager/.env (kept out of git — see the repo root's
# .gitignore) and fill in real values.
# ── Platform-operator login ─────────────────────────────────────────────
MANAGER_EMAIL="you@example.com"
# bcrypt hash (cost 12) of your chosen password — generate one with:
# node -e "console.log(require('bcryptjs').hashSync('your-password', 12))"
# (run from the manager/ directory so it resolves the bcryptjs dependency)
MANAGER_PASSWORD_HASH="replace-me"
# Signs the session cookie — generate with: openssl rand -hex 32
MANAGER_SESSION_SECRET="replace-me"
# The URL this app is actually served at (used to decide whether the
# session cookie needs its Secure attribute) — e.g. "https://manager.example.com".
MANAGER_PUBLIC_URL="https://manager.example.com"
# ── Shared MySQL (see stacks/infra/compose.yaml) ────────────────────────
MYSQL_HOST="next-chair-mysql"
MYSQL_PORT="3306"
# Same value as stacks/infra/.env's MYSQL_ROOT_PASSWORD — needed here too
# to create each new business's database/user. Two copies of the same
# value, deliberately not derived from one another — see stacks/infra/
# compose.yaml's own note on why MYSQL_*/DATABASE_* aren't cross-wired.
MYSQL_ROOT_PASSWORD="replace-me"
# ── Docker / stacks ──────────────────────────────────────────────────────
# Must be the identical absolute path as stacks/infra/.env's STACKS_DIR —
# see that file's comment for why.
STACKS_DIR="/opt/nextchair/stacks"
# Every business's compose.yaml (see src/lib/compose-template.ts) uses
# this image tag — rebuild with `docker build -t nextchair-app:latest .`
# from the repo root after a code update, then use each business's
# "Update" button (or `docker compose up -d --force-recreate`) to pick it
# up.
NEXTCHAIR_IMAGE="nextchair-app:latest"
# ── Optional ─────────────────────────────────────────────────────────────
# Shown as a link in the dashboard header if set.
DOCKGE_URL="http://your-vps-ip:5001"
+9
View File
@@ -0,0 +1,9 @@
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
<!-- END:nextjs-agent-rules -->
+1
View File
@@ -0,0 +1 @@
@AGENTS.md
+52
View File
@@ -0,0 +1,52 @@
# Standalone image for the management UI — separate from the main app's
# Dockerfile (see ../Dockerfile) since this needs the Docker CLI itself
# baked in (it shells out to `docker compose`/`docker exec` against the
# host's dockerd via the mounted socket — see src/lib/docker.ts), which
# the main business app never needs.
#
# Only one instance of this ever runs (unlike the business image, which
# is built once and reused across every business — see
# stacks/blake-heritage/compose.yaml's own note) so a per-deploy `build:`
# in stacks/manager/compose.yaml, rather than a separately tagged image,
# is the simpler choice here.
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm install
FROM node:22-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
# The Docker CLI + compose plugin — this container never runs its own
# Docker daemon, it only talks to the HOST's via the socket mounted in
# stacks/infra/compose.yaml's manager service (docker.sock).
RUN apk add --no-cache docker-cli docker-cli-compose
RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 manager
COPY --from=builder --chown=manager:nodejs /app/node_modules ./node_modules
COPY --from=builder --chown=manager:nodejs /app/.next ./.next
COPY --from=builder --chown=manager:nodejs /app/public ./public
COPY --from=builder --chown=manager:nodejs /app/package.json ./package.json
COPY --from=builder --chown=manager:nodejs /app/next.config.ts ./next.config.ts
# Deliberately still root at runtime, not the `manager` user created
# above — this container's whole job is calling the Docker CLI against
# the host's socket, and on most VPS setups only root (or a user in the
# `docker` group, which would need to be created with a matching host
# GID) can use that socket. Worth being explicit about: root inside this
# container plus the mounted socket effectively means root on the host —
# this app must stay behind its own login (see src/lib/auth.ts) and never
# be exposed without that gate.
EXPOSE 3000
ENV PORT=3000
ENTRYPOINT ["node_modules/.bin/next", "start", "-p", "3000"]
+89
View File
@@ -0,0 +1,89 @@
# NextChair Manager
Standalone platform-operator tool for provisioning and managing NextChair
business deployments on the shared multi-business VPS setup (see
`../stacks/`). Not part of the main app — a genuinely separate Next.js
app, own `package.json`, own build, own container.
## Why it's a separate app
An earlier attempt at this exact feature was built as routes inside the
main app (`src/app/manager/...`). It was reverted after repeated,
never-fully-resolved bugs: the main app's `src/proxy.ts`, root layout,
root page, and global `SessionProvider` all statically import Prisma /
Auth.js / business branding — code a Manager (which has no business
database of its own, by definition) kept transitively pulling in, causing
crashes in "Manager mode."
This app avoids that class of bug structurally rather than by careful
avoidance: it never imports anything from the main app's `src/` at all.
A few small pieces (bcrypt convention, the signed-cookie primitive) were
copied and adapted, not imported — see `src/lib/auth.ts` and
`src/lib/session.ts`.
## What it does
- Lists provisioned businesses and their running/stopped state.
- **Add Business**: collects a name/slug/domain, then generates secrets,
creates that business's database + user on the shared MySQL server,
writes its `stacks/<slug>/compose.yaml` + `.env` + Caddy site file,
reloads Caddy, starts the container, and runs its migrations. See
`src/lib/provisioning.ts` for the exact sequence.
- Per business: start / stop / restart / update (recreate the container
against the current shared image tag — not a source rebuild) / view
recent logs.
- Does **not** duplicate the `/install` wizard — after provisioning, the
operator is linked to `https://<domain>/install` to finish that
business's own setup.
## How auth works
A single dedicated platform-operator credential (`MANAGER_EMAIL` /
`MANAGER_PASSWORD_HASH`, bcrypt cost 12) — not tied to any one business's
own Super Administrator, since this tool spans every business's separate
database and has no "primary" one of its own. A signed (HMAC, not
encrypted) cookie holds the session; see `src/lib/session.ts`.
## The Docker-socket trust boundary
This container has the host's Docker socket mounted (`docker.sock`) so it
can run `docker compose`/`docker exec` against the host's dockerd — see
`src/lib/docker.ts`. Root inside this container plus that socket
effectively means root on the host. Consequences:
- It must stay behind the login above at all times — never expose it
without that gate, and don't reuse `MANAGER_PASSWORD_HASH` anywhere
else.
- `STACKS_DIR` must be bind-mounted at the *identical absolute path* on
both host and container (see `.env.example`) — a `docker compose -f
<path>` command run from inside this container is executed by the
*host's* dockerd, so the path only means the same file on both sides if
the mount doesn't change it. This is the same constraint Dockge itself
has.
## Setup
```bash
cp manager/.env.example stacks/manager/.env # fill in real values
docker compose -f stacks/manager/compose.yaml --env-file stacks/manager/.env up -d --build
```
Requires `stacks/infra/compose.yaml` already running (shared MySQL,
Caddy, the `next-chair-edge` network). Add a Caddy site for this app the
same way as any business (see `stacks/infra/sites/.gitkeep`), pointed at
`next-chair-manager:3000`.
## Local development
```bash
cd manager
npm install
cp .env.example .env.local
npm run dev
```
Requires network access to a real MySQL server and `docker`/`docker
compose` on the machine you run it from for the provisioning/stack
actions to actually work — the dashboard's read-only parts (listing
stacks) will also just show nothing if `STACKS_DIR` doesn't point at a
real directory yet.
+16
View File
@@ -0,0 +1,16 @@
import nextCoreWebVitals from "eslint-config-next/core-web-vitals";
import nextTypescript from "eslint-config-next/typescript";
/** @type {import('eslint').Linter.Config[]} */
const eslintConfig = [
{ ignores: [".next/**", "node_modules/**"] },
...nextCoreWebVitals,
...nextTypescript,
{
rules: {
"@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }],
},
},
];
export default eslintConfig;
+19
View File
@@ -0,0 +1,19 @@
import path from "node:path";
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
// Server Actions here run `docker`/`docker compose`/`mariadb` calls, not
// page rendering — nothing about this app benefits from static export or
// the standalone output trimming the main app also opts out of (see
// ../Dockerfile's own header for the same reasoning).
// This directory has its own package-lock.json alongside the main
// app's at the repo root — without this, Next infers the wrong
// workspace root from the nearest lockfile and warns about it on every
// build.
turbopack: {
root: path.join(__dirname),
},
};
export default nextConfig;
+6975
View File
File diff suppressed because it is too large. Load diff
+31
View File
@@ -0,0 +1,31 @@
{
"name": "nextchair-manager",
"version": "0.1.0",
"private": true,
"description": "Standalone platform-operator tool for provisioning and managing NextChair business deployments. Deliberately separate from the main app (own package.json, own build, own container) — an earlier in-app attempt at this repeatedly broke from Prisma/Auth.js/business-data imports leaking into a mode with no business database.",
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint .",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"bcryptjs": "^3.0.3",
"mariadb": "^3.5.3",
"next": "^16.2.12",
"react": "^19.2.8",
"react-dom": "^19.2.8"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.3.3",
"@types/bcryptjs": "^2.4.6",
"@types/node": "^26.1.2",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.4",
"eslint": "^9.39.5",
"eslint-config-next": "^16.2.12",
"tailwindcss": "^4.3.3",
"typescript": "^6.0.3"
}
}
+7
View File
@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
View File
Whitespace-only changes.
@@ -0,0 +1,42 @@
"use server";
import { revalidatePath } from "next/cache";
import { composeDown, composeLogs, composeRestart, composeUp } from "@/lib/docker";
import { isValidSlug, stackDir, stackExists } from "@/lib/stacks";
async function assertStack(slug: string): Promise<string> {
if (!isValidSlug(slug) || !(await stackExists(slug))) {
throw new Error(`No such stack: ${slug}`);
}
return stackDir(slug);
}
export async function startStackAction(slug: string): Promise<void> {
const dir = await assertStack(slug);
await composeUp(dir);
revalidatePath(`/dashboard/${slug}`);
}
export async function stopStackAction(slug: string): Promise<void> {
const dir = await assertStack(slug);
await composeDown(dir);
revalidatePath(`/dashboard/${slug}`);
}
export async function restartStackAction(slug: string): Promise<void> {
const dir = await assertStack(slug);
await composeRestart(dir);
revalidatePath(`/dashboard/${slug}`);
}
/** Recreates the container against whatever the shared image tag currently points to (--force-recreate) — not a source rebuild. See manager/.env.example's NEXTCHAIR_IMAGE comment for the actual rebuild step. */
export async function updateStackAction(slug: string): Promise<void> {
const dir = await assertStack(slug);
await composeUp(dir, { forceRecreate: true });
revalidatePath(`/dashboard/${slug}`);
}
export async function fetchLogsAction(slug: string): Promise<string> {
const dir = await assertStack(slug);
return composeLogs(dir, 200);
}
@@ -0,0 +1,94 @@
"use client";
import { useState, useTransition } from "react";
import {
fetchLogsAction,
restartStackAction,
startStackAction,
stopStackAction,
updateStackAction,
} from "./actions";
export function BusinessActions({ slug }: { slug: string }) {
const [isPending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
const [logs, setLogs] = useState<string | null>(null);
const run = (action: (slug: string) => Promise<void>) => {
setError(null);
startTransition(async () => {
try {
await action(slug);
} catch (e) {
setError(e instanceof Error ? e.message : "Action failed.");
}
});
};
const loadLogs = () => {
setError(null);
startTransition(async () => {
try {
setLogs(await fetchLogsAction(slug));
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to load logs.");
}
});
};
return (
<div className="space-y-4">
<div className="flex flex-wrap gap-2">
<button
type="button"
disabled={isPending}
onClick={() => run(startStackAction)}
className="rounded-md border border-line px-3 py-1.5 text-sm font-medium disabled:opacity-60"
>
Start
</button>
<button
type="button"
disabled={isPending}
onClick={() => run(stopStackAction)}
className="rounded-md border border-line px-3 py-1.5 text-sm font-medium disabled:opacity-60"
>
Stop
</button>
<button
type="button"
disabled={isPending}
onClick={() => run(restartStackAction)}
className="rounded-md border border-line px-3 py-1.5 text-sm font-medium disabled:opacity-60"
>
Restart
</button>
<button
type="button"
disabled={isPending}
onClick={() => run(updateStackAction)}
className="rounded-md border border-line px-3 py-1.5 text-sm font-medium disabled:opacity-60"
title="Recreates the container against the current shared image tag — not a source rebuild."
>
Update
</button>
<button
type="button"
disabled={isPending}
onClick={loadLogs}
className="rounded-md border border-line px-3 py-1.5 text-sm font-medium disabled:opacity-60"
>
View logs
</button>
</div>
{error && (
<p role="alert" className="text-sm font-medium text-danger">
{error}
</p>
)}
{logs !== null && (
<pre className="max-h-96 overflow-auto rounded-md bg-ink p-4 text-xs text-white">{logs}</pre>
)}
</div>
);
}
+41
View File
@@ -0,0 +1,41 @@
import { notFound } from "next/navigation";
import { listBusinessStacks } from "@/lib/stacks";
import { BusinessActions } from "./business-actions";
export const dynamic = "force-dynamic";
export default async function BusinessDetailPage({
params,
searchParams,
}: {
params: Promise<{ slug: string }>;
searchParams: Promise<{ justCreated?: string }>;
}) {
const { slug } = await params;
const { justCreated } = await searchParams;
const stacks = await listBusinessStacks();
const stack = stacks.find((s) => s.slug === slug);
if (!stack) notFound();
return (
<div className="space-y-6">
{justCreated && (
<div className="rounded-md border border-accent/30 bg-accent/10 px-4 py-3 text-sm text-accent">
Business created and started. Visit{" "}
<a href={`https://${stack.domain}/install`} target="_blank" rel="noreferrer" className="font-semibold underline">
https://{stack.domain}/install
</a>{" "}
to finish setting it up.
</div>
)}
<div>
<h1 className="text-xl font-bold">{stack.slug}</h1>
<p className="text-sm text-muted">{stack.domain ?? "no domain set"}</p>
</div>
<BusinessActions slug={stack.slug} />
</div>
);
}
+29
View File
@@ -0,0 +1,29 @@
import Link from "next/link";
import { logoutAction } from "./logout-action";
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
const dockgeUrl = process.env.DOCKGE_URL;
return (
<div className="min-h-screen">
<header className="flex items-center justify-between border-b border-line bg-surface px-6 py-4">
<Link href="/dashboard" className="text-sm font-bold">
NextChair Manager
</Link>
<div className="flex items-center gap-4 text-sm">
{dockgeUrl && (
<a href={dockgeUrl} target="_blank" rel="noreferrer" className="text-muted hover:text-ink">
Dockge ↗
</a>
)}
<form action={logoutAction}>
<button type="submit" className="text-muted hover:text-ink">
Sign out
</button>
</form>
</div>
</header>
<main className="mx-auto max-w-4xl px-6 py-8">{children}</main>
</div>
);
}
@@ -0,0 +1,11 @@
"use server";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { SESSION_COOKIE_NAME } from "@/lib/session";
export async function logoutAction(): Promise<void> {
const cookieStore = await cookies();
cookieStore.delete(SESSION_COOKIE_NAME);
redirect("/login");
}
+22
View File
@@ -0,0 +1,22 @@
"use server";
import { redirect } from "next/navigation";
import { provisionBusiness } from "@/lib/provisioning";
export interface AddBusinessState {
error: string | null;
}
export async function addBusinessAction(_prevState: AddBusinessState, formData: FormData): Promise<AddBusinessState> {
const slug = String(formData.get("slug") ?? "");
const domain = String(formData.get("domain") ?? "");
let result: { slug: string };
try {
result = await provisionBusiness({ slug, domain });
} catch (error) {
return { error: error instanceof Error ? error.message : "Failed to provision this business." };
}
redirect(`/dashboard/${result.slug}?justCreated=1`);
}
@@ -0,0 +1,82 @@
"use client";
import { useActionState, useState } from "react";
import { suggestSlug } from "@/lib/slug";
import { addBusinessAction, type AddBusinessState } from "./actions";
const initialState: AddBusinessState = { error: null };
export function AddBusinessForm() {
const [state, formAction, isPending] = useActionState(addBusinessAction, initialState);
const [slug, setSlug] = useState("");
const [slugTouched, setSlugTouched] = useState(false);
return (
<form action={formAction} className="space-y-4">
<div>
<label htmlFor="name" className="block text-sm font-medium text-muted">
Business name
</label>
<input
id="name"
name="name"
type="text"
required
onChange={(e) => {
if (!slugTouched) setSlug(suggestSlug(e.target.value));
}}
className="mt-1 w-full rounded-md border border-line px-3 py-2 text-sm"
/>
</div>
<div>
<label htmlFor="slug" className="block text-sm font-medium text-muted">
Slug
</label>
<input
id="slug"
name="slug"
type="text"
required
value={slug}
onChange={(e) => {
setSlugTouched(true);
setSlug(e.target.value);
}}
className="mt-1 w-full rounded-md border border-line px-3 py-2 font-mono text-sm"
/>
<p className="mt-1 text-xs text-muted">
Used for the container name, database name, and stack directory — lowercase letters, numbers, and hyphens
only.
</p>
</div>
<div>
<label htmlFor="domain" className="block text-sm font-medium text-muted">
Domain
</label>
<input
id="domain"
name="domain"
type="text"
required
placeholder="business.example.com"
className="mt-1 w-full rounded-md border border-line px-3 py-2 text-sm"
/>
<p className="mt-1 text-xs text-muted">
Must already point at this server&rsquo;s IP before Caddy can issue a certificate for it.
</p>
</div>
{state.error && (
<p role="alert" className="text-sm font-medium text-danger">
{state.error}
</p>
)}
<button
type="submit"
disabled={isPending}
className="w-full rounded-md bg-ink py-2 text-sm font-semibold text-white disabled:opacity-60"
>
{isPending ? "Provisioning…" : "Create Business"}
</button>
</form>
);
}
+16
View File
@@ -0,0 +1,16 @@
import { AddBusinessForm } from "./add-business-form";
export default function NewBusinessPage() {
return (
<div className="max-w-lg space-y-6">
<div>
<h1 className="text-xl font-bold">Add Business</h1>
<p className="text-sm text-muted">
Creates the database, generates secrets, writes the compose stack and Caddy site, and starts the
container. Takes a few seconds — the button stays disabled until it&rsquo;s done.
</p>
</div>
<AddBusinessForm />
</div>
);
}
+52
View File
@@ -0,0 +1,52 @@
import Link from "next/link";
import { listBusinessStacks } from "@/lib/stacks";
export const dynamic = "force-dynamic";
function StatusPill({ status }: { status: "running" | "stopped" | "unknown" }) {
const styles = {
running: "bg-accent/10 text-accent",
stopped: "bg-danger/10 text-danger",
unknown: "bg-line text-muted",
} as const;
return <span className={`rounded-full px-2 py-0.5 text-xs font-semibold ${styles[status]}`}>{status}</span>;
}
export default async function DashboardPage() {
const stacks = await listBusinessStacks();
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-xl font-bold">Businesses</h1>
<Link
href="/dashboard/new"
className="rounded-md bg-ink px-4 py-2 text-sm font-semibold text-white"
>
Add Business
</Link>
</div>
{stacks.length === 0 ? (
<p className="text-sm text-muted">No businesses provisioned yet.</p>
) : (
<ul className="divide-y divide-line rounded-lg border border-line bg-surface">
{stacks.map((stack) => (
<li key={stack.slug}>
<Link
href={`/dashboard/${stack.slug}`}
className="flex items-center justify-between px-4 py-3 hover:bg-stone"
>
<div>
<p className="text-sm font-semibold">{stack.slug}</p>
<p className="text-xs text-muted">{stack.domain ?? "no domain set"}</p>
</div>
<StatusPill status={stack.status} />
</Link>
</li>
))}
</ul>
)}
</div>
);
}
+16
View File
@@ -0,0 +1,16 @@
@import "tailwindcss";
@theme {
--color-ink: #111b15;
--color-stone: #f4f6f4;
--color-surface: #ffffff;
--color-muted: #5b665f;
--color-line: #dfe3df;
--color-accent: #1f7a4d;
--color-danger: #b3261e;
}
body {
background-color: var(--color-stone);
color: var(--color-ink);
}
+15
View File
@@ -0,0 +1,15 @@
import type { Metadata } from "next";
import "./globals.css";
export const metadata: Metadata = {
title: "NextChair Manager",
description: "Platform-operator tool for provisioning NextChair business deployments.",
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>{children}</body>
</html>
);
}
+35
View File
@@ -0,0 +1,35 @@
"use server";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { verifyManagerCredentials } from "@/lib/auth";
import { createSessionToken, isSecureDeployment, SESSION_COOKIE_NAME } from "@/lib/session";
export interface LoginState {
error: string | null;
}
export async function loginAction(_prevState: LoginState, formData: FormData): Promise<LoginState> {
const email = String(formData.get("email") ?? "");
const password = String(formData.get("password") ?? "");
if (!email || !password) {
return { error: "Email and password are required." };
}
const valid = await verifyManagerCredentials(email, password);
if (!valid) {
return { error: "Incorrect email or password." };
}
const cookieStore = await cookies();
cookieStore.set(SESSION_COOKIE_NAME, createSessionToken(email), {
httpOnly: true,
secure: isSecureDeployment(),
sameSite: "lax",
path: "/",
maxAge: 7 * 24 * 60 * 60,
});
redirect("/dashboard");
}
+53
View File
@@ -0,0 +1,53 @@
"use client";
import { useActionState } from "react";
import { loginAction, type LoginState } from "./actions";
const initialState: LoginState = { error: null };
export function LoginForm() {
const [state, formAction, isPending] = useActionState(loginAction, initialState);
return (
<form action={formAction} className="space-y-4">
<div>
<label htmlFor="email" className="block text-sm font-medium text-muted">
Email
</label>
<input
id="email"
name="email"
type="email"
required
autoComplete="username"
className="mt-1 w-full rounded-md border border-line px-3 py-2 text-sm"
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-muted">
Password
</label>
<input
id="password"
name="password"
type="password"
required
autoComplete="current-password"
className="mt-1 w-full rounded-md border border-line px-3 py-2 text-sm"
/>
</div>
{state.error && (
<p role="alert" className="text-sm font-medium text-danger">
{state.error}
</p>
)}
<button
type="submit"
disabled={isPending}
className="w-full rounded-md bg-ink py-2 text-sm font-semibold text-white disabled:opacity-60"
>
{isPending ? "Signing in…" : "Sign in"}
</button>
</form>
);
}
+15
View File
@@ -0,0 +1,15 @@
import { LoginForm } from "./login-form";
export default function LoginPage() {
return (
<main className="flex min-h-screen items-center justify-center px-4">
<div className="w-full max-w-sm space-y-6 rounded-lg border border-line bg-surface p-8 shadow-sm">
<div>
<h1 className="text-lg font-bold">NextChair Manager</h1>
<p className="text-sm text-muted">Platform-operator sign in.</p>
</div>
<LoginForm />
</div>
</main>
);
}
+5
View File
@@ -0,0 +1,5 @@
import { redirect } from "next/navigation";
export default function RootPage() {
redirect("/dashboard");
}
+30
View File
@@ -0,0 +1,30 @@
import bcrypt from "bcryptjs";
/**
* A single, dedicated platform-operator credential via env vars — not
* tied to any one business's own Super Administrator (each business has
* its own separate database; this tool has no "primary" one of its own,
* so there's no single business account it could sensibly check against).
* Bcrypt cost 12 and the dummy-hash timing-safe comparison mirror the
* main app's own convention (see scripts/ensure-super-admin.ts and
* src/auth.ts) — copied, not imported: this app never imports from the
* main app's src/ at all (see session.ts's header for why).
*/
// A real bcrypt hash of an unguessable value, used only so a request for
// a wrong/missing MANAGER_EMAIL still pays the same bcrypt.compare cost
// as a wrong password — otherwise "no such email" and "wrong password"
// are distinguishable by response time.
const DUMMY_HASH = bcrypt.hashSync("nextchair-manager-dummy-hash", 12);
export async function verifyManagerCredentials(email: string, password: string): Promise<boolean> {
const expectedEmail = process.env.MANAGER_EMAIL?.trim().toLowerCase();
const expectedHash = process.env.MANAGER_PASSWORD_HASH;
if (!expectedEmail || !expectedHash) {
throw new Error("MANAGER_EMAIL and MANAGER_PASSWORD_HASH must both be set.");
}
const matchesEmail = email.trim().toLowerCase() === expectedEmail;
const valid = await bcrypt.compare(password, matchesEmail ? expectedHash : DUMMY_HASH);
return matchesEmail && valid;
}
+113
View File
@@ -0,0 +1,113 @@
/**
* Generates the exact same shape as stacks/blake-heritage/compose.yaml
* and stacks/marios/compose.yaml (the two hand-written example stacks) —
* keep these in sync if that shape changes.
*/
export function generateComposeYaml(slug: string): string {
const containerName = `next-chair-${slug}`;
const volumeName = `next-chair-media-${slug}`;
return `name: ${containerName}
# Generated by the management UI — see stacks/blake-heritage/compose.yaml
# for the hand-written original this mirrors.
services:
${containerName}:
image: \${NEXTCHAIR_IMAGE:-nextchair-app:latest}
container_name: ${containerName}
restart: unless-stopped
env_file:
- .env
volumes:
- ${volumeName}:/app/storage/media
healthcheck:
test:
[
"CMD",
"node",
"-e",
"require('http').get('http://localhost:3000/api/health', r => process.exit(r.statusCode === 200 ? 0 : 1)).on('error', () => process.exit(1))",
]
interval: 10s
timeout: 5s
retries: 10
start_period: 20s
networks:
- next-chair-edge
volumes:
${volumeName}:
name: ${volumeName}
networks:
next-chair-edge:
name: next-chair-edge
external: true
`;
}
export interface BusinessEnvValues {
domain: string;
databaseName: string;
databaseUser: string;
databasePassword: string;
authSecret: string;
installationToken: string;
cronSecret: string;
}
/** Same shape as .env.business.example, filled in with generated/collected values instead of placeholders. */
export function generateBusinessEnv(values: BusinessEnvValues): string {
return `# Generated by the management UI. See .env.business.example for what
# each of these does.
DOMAIN="${values.domain}"
DATABASE_HOST="next-chair-mysql"
DATABASE_PORT="3306"
DATABASE_NAME="${values.databaseName}"
DATABASE_USER="${values.databaseUser}"
DATABASE_PASSWORD="${values.databasePassword}"
AUTH_SECRET="${values.authSecret}"
AUTH_TRUST_HOST="true"
NEXT_PUBLIC_APP_URL="https://${values.domain}"
INSTALLATION_TOKEN="${values.installationToken}"
CRON_SECRET="${values.cronSecret}"
SUPER_ADMIN_EMAIL=""
SUPER_ADMIN_PASSWORD=""
SUPER_ADMIN_FIRST_NAME=""
SUPER_ADMIN_LAST_NAME=""
AUTH_GOOGLE_ID=""
AUTH_GOOGLE_SECRET=""
GOOGLE_CALENDAR_CLIENT_ID=""
GOOGLE_CALENDAR_CLIENT_SECRET=""
GOOGLE_CALENDAR_REDIRECT_URI="https://${values.domain}/api/staff/calendar/google/callback"
CALENDAR_TOKEN_ENCRYPTION_KEY=""
MEDIA_MAX_UPLOAD_BYTES="5000000"
GOOGLE_PLACES_API_KEY=""
EMAIL_PROVIDER="console"
EMAIL_SMTP_HOST="smtp-relay.brevo.com"
EMAIL_SMTP_PORT="587"
EMAIL_SMTP_USERNAME=""
EMAIL_SMTP_PASSWORD=""
EMAIL_FROM_ADDRESS=""
EMAIL_FROM_NAME=""
PUSH_PROVIDER="console"
VAPID_PUBLIC_KEY=""
NEXT_PUBLIC_VAPID_PUBLIC_KEY=""
VAPID_PRIVATE_KEY=""
VAPID_SUBJECT="mailto:owner@example.com"
`;
}
export function generateCaddySite(slug: string, domain: string): string {
return `${domain} {
reverse_proxy next-chair-${slug}:3000
}
`;
}
+90
View File
@@ -0,0 +1,90 @@
import { execFile } from "node:child_process";
import { promisify } from "node:util";
const execFileAsync = promisify(execFile);
/**
* Every call here runs `docker compose` with `cwd` set to a stack's own
* directory (stacks/<slug>/) rather than passing explicit -f/--env-file
* flags — compose already auto-loads `compose.yaml` and `.env` from the
* current directory, and this is the same convention Dockge itself uses,
* so a stack behaves identically whether it's started by this app, by
* Dockge, or by hand from the command line.
*
* This app's own container needs the Docker socket mounted and the
* `docker compose` CLI installed (see manager/Dockerfile) — these calls
* are executed by the HOST's dockerd via that socket, not by a nested
* Docker daemon inside this container.
*/
export interface DockerCommandResult {
stdout: string;
stderr: string;
}
async function runDockerCompose(stackDir: string, args: string[]): Promise<DockerCommandResult> {
const result = await execFileAsync("docker", ["compose", ...args], {
cwd: stackDir,
maxBuffer: 10 * 1024 * 1024,
});
return result;
}
export async function composeUp(stackDir: string, options: { forceRecreate?: boolean } = {}): Promise<void> {
const args = ["up", "-d"];
if (options.forceRecreate) args.push("--force-recreate");
await runDockerCompose(stackDir, args);
}
export async function composeDown(stackDir: string): Promise<void> {
await runDockerCompose(stackDir, ["down"]);
}
export async function composeRestart(stackDir: string): Promise<void> {
await runDockerCompose(stackDir, ["restart"]);
}
export async function composeLogs(stackDir: string, tailLines = 200): Promise<string> {
const result = await runDockerCompose(stackDir, ["logs", "--no-color", "--tail", String(tailLines)]);
// Compose writes container logs to stderr, not stdout — this is normal
// (docker's own `logs` command does the same), not a failure signal.
return result.stdout + result.stderr;
}
export type StackRunState = "running" | "stopped" | "unknown";
/** Best-effort — a stack that's never been started at all reports "stopped", not an error. */
export async function composeStatus(stackDir: string): Promise<StackRunState> {
try {
const result = await runDockerCompose(stackDir, ["ps", "--status", "running", "--format", "json"]);
return result.stdout.trim().length > 0 ? "running" : "stopped";
} catch {
return "unknown";
}
}
export async function composeExec(stackDir: string, serviceName: string, command: string[]): Promise<void> {
await runDockerCompose(stackDir, ["exec", "-T", serviceName, ...command]);
}
/** Applies pending Prisma migrations inside a freshly-started business container — retries briefly since the container's Node process may take a moment to come up after `composeUp`. */
export async function runMigrations(stackDir: string, serviceName: string): Promise<void> {
const maxAttempts = 6;
let lastError: unknown;
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
try {
await composeExec(stackDir, serviceName, ["npx", "prisma", "migrate", "deploy"]);
return;
} catch (error) {
lastError = error;
await new Promise((resolve) => setTimeout(resolve, 2000));
}
}
throw lastError instanceof Error ? lastError : new Error("Migrations failed after retries.");
}
/** Caddy doesn't watch its sites/ directory — adding or removing a business's .caddy file needs this to take effect. */
export async function reloadCaddy(): Promise<void> {
const containerName = process.env.CADDY_CONTAINER_NAME ?? "next-chair-caddy";
await execFileAsync("docker", ["exec", containerName, "caddy", "reload", "--config", "/etc/caddy/Caddyfile"]);
}
+117
View File
@@ -0,0 +1,117 @@
import { randomBytes } from "node:crypto";
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import mariadb from "mariadb";
import { composeUp, reloadCaddy, runMigrations } from "./docker";
import { generateBusinessEnv, generateCaddySite, generateComposeYaml } from "./compose-template";
import { isValidSlug, stackDir, stackExists, stacksRoot } from "./stacks";
/** Hard safety check on top of isValidSlug (which already constrains the slug itself) — this is the one place that string ends up inside a raw SQL statement, where an identifier can't be parameter-bound the way a value can. */
const SAFE_DB_IDENTIFIER = /^[a-z][a-z0-9_]{1,48}[a-z0-9]$/;
/** Generated via randomBytes/hex below — always matches this, checked anyway as defense in depth before it goes into a SQL string literal. */
const SAFE_GENERATED_SECRET = /^[a-f0-9]+$/;
function randomHex(bytes: number): string {
return randomBytes(bytes).toString("hex");
}
export interface ProvisionBusinessInput {
slug: string;
domain: string;
}
export interface ProvisionBusinessResult {
slug: string;
domain: string;
installUrl: string;
}
async function createDatabaseAndUser(databaseName: string, databaseUser: string, databasePassword: string): Promise<void> {
if (!SAFE_DB_IDENTIFIER.test(databaseName) || !SAFE_DB_IDENTIFIER.test(databaseUser)) {
throw new Error("Refusing to create a database/user from an unsafe identifier.");
}
if (!SAFE_GENERATED_SECRET.test(databasePassword)) {
throw new Error("Refusing to create a user with an unexpected password format.");
}
const host = process.env.MYSQL_HOST ?? "next-chair-mysql";
const port = process.env.MYSQL_PORT ? Number(process.env.MYSQL_PORT) : 3306;
const rootPassword = process.env.MYSQL_ROOT_PASSWORD;
if (!rootPassword) throw new Error("MYSQL_ROOT_PASSWORD must be set.");
const conn = await mariadb.createConnection({
host,
port,
user: "root",
password: rootPassword,
// MySQL 8's default auth plugin (caching_sha2_password) needs to send
// the client its RSA public key to encrypt the password, and the
// mariadb driver refuses to fetch it over a plain connection unless
// told to — see the main app's src/lib/db/mysql-pool-config.ts for
// the full explanation of this exact error. Confirmed by hitting it
// live: creating a business failed with "RSA public key is not
// available client side" before this option was added here.
allowPublicKeyRetrieval: true,
});
try {
// Identifiers are backtick-quoted AND already validated against
// SAFE_DB_IDENTIFIER above — belt and braces, since a SQL identifier
// (unlike a value) can't be parameter-bound.
await conn.query(`CREATE DATABASE \`${databaseName}\` CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci`);
await conn.query(`CREATE USER '${databaseUser}'@'%' IDENTIFIED BY '${databasePassword}'`);
await conn.query(`GRANT ALL PRIVILEGES ON \`${databaseName}\`.* TO '${databaseUser}'@'%'`);
await conn.query("FLUSH PRIVILEGES");
} finally {
await conn.end();
}
}
export async function provisionBusiness(input: ProvisionBusinessInput): Promise<ProvisionBusinessResult> {
const slug = input.slug.trim().toLowerCase();
const domain = input.domain.trim().toLowerCase();
if (!isValidSlug(slug)) {
throw new Error("Slug must be lowercase letters, numbers, and hyphens, 3-50 characters, not a reserved name.");
}
if (!domain || domain.includes(" ")) {
throw new Error("A valid domain is required.");
}
if (await stackExists(slug)) {
throw new Error(`A stack named "${slug}" already exists.`);
}
const databaseIdentifier = slug.replace(/-/g, "_");
const databasePassword = randomHex(24);
const authSecret = randomHex(32);
const installationToken = randomHex(32);
const cronSecret = randomHex(32);
await createDatabaseAndUser(databaseIdentifier, databaseIdentifier, databasePassword);
const dir = stackDir(slug);
await mkdir(dir, { recursive: true });
await writeFile(path.join(dir, "compose.yaml"), generateComposeYaml(slug), "utf8");
await writeFile(
path.join(dir, ".env"),
generateBusinessEnv({
domain,
databaseName: databaseIdentifier,
databaseUser: databaseIdentifier,
databasePassword,
authSecret,
installationToken,
cronSecret,
}),
"utf8",
);
const sitesDir = path.join(stacksRoot(), "infra", "sites");
await mkdir(sitesDir, { recursive: true });
await writeFile(path.join(sitesDir, `${slug}.caddy`), generateCaddySite(slug, domain), "utf8");
await reloadCaddy();
await composeUp(dir);
await runMigrations(dir, `next-chair-${slug}`);
return { slug, domain, installUrl: `https://${domain}/install` };
}
+98
View File
@@ -0,0 +1,98 @@
import { createHmac, timingSafeEqual } from "node:crypto";
/**
* Signed-cookie session for the manager app — adapted from (not imported
* from) the main NextChair app's src/lib/booking-draft/sign.ts and
* src/lib/security/secrets-match.ts. Deliberately copied rather than
* shared: this app must never import anything from the main app's src/ —
* a previous attempt at this exact tool was built as routes inside the
* main app and repeatedly broke from transitively importing Prisma/
* Auth.js/business data into a mode that has no business database at
* all — see ../../README.md's "Why it's a separate app" for the full
* history. Keeping this app's module graph fully separate is what makes
* that failure structurally impossible to repeat here.
*
* HMAC-signed (tamper-evident), not encrypted — the payload is just an
* email and an expiry, nothing sensitive to disclose to the browser
* holding its own cookie. Session length: 7 days.
*/
const CONTEXT = "nextchair-manager-session-v1";
const SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000;
export interface ManagerSession {
email: string;
exp: number;
}
function getSigningKey(): Buffer {
const secret = process.env.MANAGER_SESSION_SECRET;
if (!secret) {
throw new Error("MANAGER_SESSION_SECRET must be set.");
}
return createHmac("sha256", secret).update(CONTEXT).digest();
}
function sign(payload: string): string {
const mac = createHmac("sha256", getSigningKey()).update(payload).digest("base64url");
const encodedPayload = Buffer.from(payload, "utf8").toString("base64url");
return `${encodedPayload}.${mac}`;
}
function verifyToken(token: string): string | null {
const separatorIndex = token.indexOf(".");
if (separatorIndex === -1) return null;
const encodedPayload = token.slice(0, separatorIndex);
const mac = token.slice(separatorIndex + 1);
if (!encodedPayload || !mac) return null;
let payload: string;
try {
payload = Buffer.from(encodedPayload, "base64url").toString("utf8");
} catch {
return null;
}
const expectedMac = createHmac("sha256", getSigningKey()).update(payload).digest("base64url");
const macBuffer = Buffer.from(mac);
const expectedBuffer = Buffer.from(expectedMac);
if (macBuffer.length !== expectedBuffer.length) return null;
if (!timingSafeEqual(macBuffer, expectedBuffer)) return null;
return payload;
}
export function createSessionToken(email: string): string {
const payload: ManagerSession = { email, exp: Date.now() + SESSION_TTL_MS };
return sign(JSON.stringify(payload));
}
/** Returns the session if the token is validly signed and not expired, otherwise null. Never throws on malformed input. */
export function verifySessionToken(token: string | undefined): ManagerSession | null {
if (!token) return null;
const payload = verifyToken(token);
if (!payload) return null;
let session: ManagerSession;
try {
session = JSON.parse(payload);
} catch {
return null;
}
if (typeof session.email !== "string" || typeof session.exp !== "number") return null;
if (Date.now() > session.exp) return null;
return session;
}
/**
* Same signal/reasoning as the main app's src/lib/security/cookie-
* security.ts's isSecureDeployment() — NODE_ENV=production doesn't mean
* TLS is actually in front of this app, so the cookie's Secure attribute
* is derived from the deployer-declared public URL instead.
*/
export function isSecureDeployment(): boolean {
return (process.env.MANAGER_PUBLIC_URL ?? "").startsWith("https://");
}
export const SESSION_COOKIE_NAME = "manager_session";
+24
View File
@@ -0,0 +1,24 @@
/**
* Pure string logic, deliberately kept free of any Node-only import
* (fs, child_process) so it's safe to use from both server code
* (stacks.ts, provisioning.ts) and client components (the Add Business
* form's live slug suggestion) without pulling Node APIs into a browser
* bundle.
*/
export const RESERVED_SLUGS = new Set(["infra", "manager"]);
const SLUG_PATTERN = /^[a-z][a-z0-9-]{1,48}[a-z0-9]$/;
export function isValidSlug(slug: string): boolean {
return SLUG_PATTERN.test(slug) && !RESERVED_SLUGS.has(slug);
}
export function suggestSlug(businessName: string): string {
return businessName
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 48);
}
+74
View File
@@ -0,0 +1,74 @@
import { readdir, readFile, access } from "node:fs/promises";
import path from "node:path";
import { composeStatus, type StackRunState } from "./docker";
import { RESERVED_SLUGS } from "./slug";
export { isValidSlug, suggestSlug } from "./slug";
export interface BusinessStackSummary {
slug: string;
domain: string | null;
containerName: string;
status: StackRunState;
}
export function stacksRoot(): string {
const dir = process.env.STACKS_DIR;
if (!dir) throw new Error("STACKS_DIR must be set.");
return dir;
}
export function stackDir(slug: string): string {
return path.join(stacksRoot(), slug);
}
/** Only ever reads the DOMAIN line — deliberately never returns the rest of a business's .env (database password, AUTH_SECRET, etc.) to the UI. */
async function readDomain(envPath: string): Promise<string | null> {
try {
const content = await readFile(envPath, "utf8");
for (const line of content.split("\n")) {
const match = /^DOMAIN\s*=\s*"?([^"\n]*)"?\s*$/.exec(line.trim());
if (match?.[1]) return match[1];
}
return null;
} catch {
return null;
}
}
export async function listBusinessStacks(): Promise<BusinessStackSummary[]> {
const root = stacksRoot();
let entries: string[];
try {
entries = await readdir(root);
} catch {
return [];
}
const stacks: BusinessStackSummary[] = [];
for (const slug of entries) {
if (RESERVED_SLUGS.has(slug)) continue;
const dir = path.join(root, slug);
const composePath = path.join(dir, "compose.yaml");
try {
await access(composePath);
} catch {
continue;
}
const domain = await readDomain(path.join(dir, ".env"));
const status = await composeStatus(dir);
stacks.push({ slug, domain, containerName: `next-chair-${slug}`, status });
}
return stacks.sort((a, b) => a.slug.localeCompare(b.slug));
}
export async function stackExists(slug: string): Promise<boolean> {
try {
await access(path.join(stackDir(slug), "compose.yaml"));
return true;
} catch {
return false;
}
}
+30
View File
@@ -0,0 +1,30 @@
import { NextResponse, type NextRequest } from "next/server";
import { SESSION_COOKIE_NAME, verifySessionToken } from "@/lib/session";
// Next.js 16 renamed the `middleware.ts` file convention to `proxy.ts`
// (https://nextjs.org/docs/messages/middleware-to-proxy) — it now always
// runs on the Node.js runtime, which is why plain `node:crypto` in
// session.ts works fine here without any Edge-runtime workaround.
const PUBLIC_PATHS = ["/login"];
export default function proxy(request: NextRequest) {
const { pathname } = request.nextUrl;
if (PUBLIC_PATHS.some((path) => pathname.startsWith(path))) return NextResponse.next();
const token = request.cookies.get(SESSION_COOKIE_NAME)?.value;
const session = verifySessionToken(token);
if (!session) {
const loginUrl = new URL("/login", request.url);
return NextResponse.redirect(loginUrl);
}
return NextResponse.next();
}
export const config = {
// Runs on every page and API route except static assets — this app has
// no public-facing content at all, unlike the main app's proxy.ts,
// which has to carve out installation/customer-facing exceptions.
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
};
+27
View File
@@ -0,0 +1,27 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": false,
"skipLibCheck": true,
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"noFallthroughCasesInSwitch": true,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", ".next/dev/types/**/*.ts"],
"exclude": ["node_modules"]
}
+55
View File
@@ -0,0 +1,55 @@
name: next-chair-blake-heritage
# One business's app container on the shared multi-business VPS setup —
# see stacks/infra/compose.yaml for the shared MySQL/Caddy/Dockge this
# attaches to, and that file's header for the exact one-time setup steps
# (create this business's database, copy .env.business.example, add a
# Caddy site file) needed before this file can start.
#
# docker compose -f stacks/blake-heritage/compose.yaml --env-file stacks/blake-heritage/.env up -d
#
# `stacks/marios/compose.yaml` is the identical shape for the second
# business — this is also the shape the management UI (manager/)
# generates for every new business it provisions.
#
# Uses the shared prebuilt image (see the repo root's README/Dockerfile —
# `docker build -t nextchair-app:latest .` once per code update) rather
# than building from source per business, so adding or updating a
# business is just starting/recreating a container, not a rebuild.
services:
next-chair-blake-heritage:
image: nextchair-app:latest
container_name: next-chair-blake-heritage
restart: unless-stopped
env_file:
- .env
volumes:
- next-chair-media-blake-heritage:/app/storage/media
healthcheck:
# No curl/wget in the node:22-alpine base image — a plain Node
# request avoids adding a package just for this. See
# src/app/api/health/route.ts: healthy as soon as the database is
# reachable, even before installation completes.
test:
[
"CMD",
"node",
"-e",
"require('http').get('http://localhost:3000/api/health', r => process.exit(r.statusCode === 200 ? 0 : 1)).on('error', () => process.exit(1))",
]
interval: 10s
timeout: 5s
retries: 10
start_period: 20s
networks:
- next-chair-edge
volumes:
next-chair-media-blake-heritage:
name: next-chair-media-blake-heritage
networks:
next-chair-edge:
name: next-chair-edge
external: true
+12
View File
@@ -0,0 +1,12 @@
{
# Let's Encrypt sends renewal/expiry notices here.
email {$ACME_EMAIL}
}
# One file per business in sites/ (see sites/.gitkeep) — Caddy doesn't
# watch this directory for changes on its own, so adding/removing a file
# needs `docker exec next-chair-caddy caddy reload --config
# /etc/caddy/Caddyfile` (see stacks/infra/compose.yaml's header) to take
# effect. The management UI (manager/) automates writing these files and
# reloading.
import sites/*.caddy
+136
View File
@@ -0,0 +1,136 @@
name: next-chair-infra
# Shared infrastructure for the multi-business VPS deployment: Caddy
# (reverse proxy + automatic TLS via Let's Encrypt), one MySQL server
# shared by every business (a separate database + user per business, NOT
# one shared schema — see step 1 below), and Dockge (a web UI for
# starting/stopping/inspecting these compose stacks by hand).
#
# Lives in stacks/infra/ so Dockge (which expects one directory per
# stack, each with its own compose.yaml) can manage this stack too —
# see stacks/blake-heritage/, stacks/marios/ for the same shape per
# business. Deliberately separate from docker-compose.local.yaml at the
# repo root, which is the single-business local/Docker-Desktop setup and
# stays untouched by any of this.
#
# ── One-time setup ───────────────────────────────────────────────────────
# docker network create next-chair-edge
# cp .env.infra.example stacks/infra/.env (then fill in real values —
# in particular STACKS_DIR must be this repo's absolute path on the
# VPS, e.g. /opt/nextchair/stacks — see the STACKS_DIR comment in that
# file for why it can't just be a relative path here)
# docker compose -f stacks/infra/compose.yaml --env-file stacks/infra/.env up -d
#
# ── Per business ──────────────────────────────────────────────────────
# Use the management UI (manager/) once it's running — it automates all
# of the steps below. To do it by hand instead:
# 1. Create that business's database + user on the shared MySQL server.
# There's no automatic way to do this per business via env vars —
# the mysql:8.0 image's MYSQL_DATABASE/USER/PASSWORD vars only
# provision ONE database at first boot, and /docker-entrypoint-
# initdb.d scripts only ever run once against an empty data
# directory — so this one step is manual, run once per business:
# docker exec -it next-chair-mysql mysql -uroot -p
# CREATE DATABASE blake_heritage CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
# CREATE USER 'blake_heritage'@'%' IDENTIFIED BY 'choose-a-strong-password';
# GRANT ALL PRIVILEGES ON blake_heritage.* TO 'blake_heritage'@'%';
# FLUSH PRIVILEGES;
# 2. Copy .env.business.example to stacks/<slug>/.env, fill it in with
# the credentials just created and that business's domain.
# 3. Add stacks/infra/sites/<slug>.caddy (see that directory's own
# note) and reload Caddy:
# docker exec next-chair-caddy caddy reload --config /etc/caddy/Caddyfile
# 4. docker compose -f stacks/<slug>/compose.yaml --env-file stacks/<slug>/.env up -d
services:
caddy:
image: caddy:2-alpine
container_name: next-chair-caddy
restart: unless-stopped
env_file:
- .env
ports:
# Overridable for local testing (a machine already running
# something else on 80/443) — a real deployment leaves these at
# their defaults, since Let's Encrypt's HTTP challenge and normal
# HTTPS traffic both expect the standard ports.
- "${CADDY_HTTP_PORT:-80}:80"
- "${CADDY_HTTPS_PORT:-443}:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
# One .caddy file per business, imported by the Caddyfile — see
# sites/.gitkeep's own note. Adding/removing a file here needs a
# `caddy reload` (see the per-business setup steps above) to take
# effect; Caddy doesn't watch this directory on its own.
- ./sites:/etc/caddy/sites
# Caddy's own automatic-HTTPS certificate storage — must persist
# across restarts or every restart re-issues every certificate
# against Let's Encrypt's rate limits.
- next-chair-caddy-data:/data
- next-chair-caddy-config:/config
networks:
- next-chair-edge
next-chair-mysql:
image: mysql:8.0
container_name: next-chair-mysql
restart: unless-stopped
env_file:
- .env
# No MYSQL_DATABASE/MYSQL_USER/MYSQL_PASSWORD here deliberately — this
# server hosts one database per business (see step 1 above), not one
# shared schema. Only MYSQL_ROOT_PASSWORD (in .env) is required for
# the image to boot.
command: --character-set-server=utf8mb4 --collation-server=utf8mb4_0900_ai_ci
ports:
# Host-machine access (e.g. a GUI client over an SSH tunnel) — every
# business's app container reaches this over the shared Docker
# network via the service name `next-chair-mysql`, never this.
- "${MYSQL_HOST_PORT:-3306}:3306"
volumes:
- next-chair-mysql-data:/var/lib/mysql
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-u", "root", "-p${MYSQL_ROOT_PASSWORD:-change-me-too}"]
interval: 5s
timeout: 5s
retries: 20
start_period: 10s
networks:
- next-chair-edge
dockge:
image: louislam/dockge:1
container_name: next-chair-dockge
restart: unless-stopped
env_file:
- .env
environment:
DOCKGE_STACKS_DIR: "${STACKS_DIR}"
ports:
- "${DOCKGE_HOST_PORT:-5001}:5001"
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- next-chair-dockge-data:/app/data
# MUST be mounted at the identical absolute path on both host and
# container — see the STACKS_DIR comment in .env.infra.example for
# why (Dockge shells `docker compose` out to the HOST's dockerd via
# the socket above, so a compose-file path only means the same
# thing on both sides if the bind-mount doesn't change it).
- "${STACKS_DIR}:${STACKS_DIR}"
networks:
- next-chair-edge
volumes:
next-chair-mysql-data:
name: next-chair-mysql-data
next-chair-caddy-data:
name: next-chair-caddy-data
next-chair-caddy-config:
name: next-chair-caddy-config
next-chair-dockge-data:
name: next-chair-dockge-data
networks:
next-chair-edge:
name: next-chair-edge
external: true
+13
View File
@@ -0,0 +1,13 @@
Keeps this otherwise-empty directory tracked in git.
Each business gets its own <slug>.caddy file here, e.g.:
blake-heritage.example.com {
reverse_proxy next-chair-blake-heritage:3000
}
Generated by the management UI (manager/) when a business is added, or
written by hand for the by-hand setup steps in stacks/infra/compose.yaml's
header comment. Real *.caddy files are gitignored (deployment-specific,
not something to hand-author into the repo) — only this placeholder is
tracked.
+39
View File
@@ -0,0 +1,39 @@
name: next-chair-manager
# The management UI (see manager/ at the repo root — its own README
# explains why it's a fully separate app). Its own stack, separate from
# stacks/infra/compose.yaml, since this is real application code that
# gets rebuilt/updated far more often than the off-the-shelf infra
# images (MySQL, Caddy, Dockge) — a code change here shouldn't require
# touching the infra stack at all.
#
# cp manager/.env.example stacks/manager/.env (then fill in real values)
# docker compose -f stacks/manager/compose.yaml --env-file stacks/manager/.env up -d --build
#
# Requires stacks/infra/compose.yaml to already be running (shared MySQL,
# Caddy, network). Add a Caddy site for it the same way as any business —
# see stacks/infra/sites/.gitkeep — pointed at `next-chair-manager:3000`.
services:
manager:
build:
context: ../../manager
dockerfile: Dockerfile
container_name: next-chair-manager
restart: unless-stopped
env_file:
- .env
volumes:
- /var/run/docker.sock:/var/run/docker.sock
# MUST be mounted at the identical absolute path on both host and
# container — see manager/.env.example's STACKS_DIR comment. This
# is how the manager writes new business stacks and shells `docker
# compose` against them via the socket above.
- "${STACKS_DIR}:${STACKS_DIR}"
networks:
- next-chair-edge
networks:
next-chair-edge:
name: next-chair-edge
external: true
+55
View File
@@ -0,0 +1,55 @@
name: next-chair-marios
# One business's app container on the shared multi-business VPS setup —
# see stacks/infra/compose.yaml for the shared MySQL/Caddy/Dockge this
# attaches to, and that file's header for the exact one-time setup steps
# (create this business's database, copy .env.business.example, add a
# Caddy site file) needed before this file can start.
#
# docker compose -f stacks/marios/compose.yaml --env-file stacks/marios/.env up -d
#
# `stacks/blake-heritage/compose.yaml` is the identical shape for the
# first business — this is also the shape the management UI (manager/)
# generates for every new business it provisions.
#
# Uses the shared prebuilt image (see the repo root's README/Dockerfile —
# `docker build -t nextchair-app:latest .` once per code update) rather
# than building from source per business, so adding or updating a
# business is just starting/recreating a container, not a rebuild.
services:
next-chair-marios:
image: nextchair-app:latest
container_name: next-chair-marios
restart: unless-stopped
env_file:
- .env
volumes:
- next-chair-media-marios:/app/storage/media
healthcheck:
# No curl/wget in the node:22-alpine base image — a plain Node
# request avoids adding a package just for this. See
# src/app/api/health/route.ts: healthy as soon as the database is
# reachable, even before installation completes.
test:
[
"CMD",
"node",
"-e",
"require('http').get('http://localhost:3000/api/health', r => process.exit(r.statusCode === 200 ? 0 : 1)).on('error', () => process.exit(1))",
]
interval: 10s
timeout: 5s
retries: 10
start_period: 20s
networks:
- next-chair-edge
volumes:
next-chair-media-marios:
name: next-chair-media-marios
networks:
next-chair-edge:
name: next-chair-edge
external: true
+2 -1
View File
@@ -44,6 +44,7 @@
], ],
"exclude": [ "exclude": [
"node_modules", "node_modules",
"templates" "templates",
"manager"
] ]
} }
+6 -1
View File
@@ -9,6 +9,11 @@ export default defineConfig({
// (real Postgres required) are named `*.integration.test.ts` and only // (real Postgres required) are named `*.integration.test.ts` and only
// run via `npm run test:integration` — see docs/phase-3-summary.md. // run via `npm run test:integration` — see docs/phase-3-summary.md.
// e2e/ is Playwright's own suite (`npm run test:e2e`), not Vitest's. // e2e/ is Playwright's own suite (`npm run test:e2e`), not Vitest's.
exclude: ["node_modules/**", ".next/**", "templates/**", "e2e/**", "**/*.integration.test.ts"], // "**/node_modules/**" (not "node_modules/**") is deliberate — the
// latter only matches a top-level node_modules, not a nested one like
// manager/node_modules, whose own dependencies (e.g. zod) ship test
// files that would otherwise get picked up and fail for missing
// dev-only deps that were never meant to run here at all.
exclude: ["**/node_modules/**", ".next/**", "templates/**", "e2e/**", "manager/**", "**/*.integration.test.ts"],
}, },
}); });
+4 -1
View File
@@ -6,7 +6,10 @@ export default defineConfig({
environment: "node", environment: "node",
globals: false, globals: false,
include: ["**/*.integration.test.ts"], include: ["**/*.integration.test.ts"],
exclude: ["node_modules/**", ".next/**", "templates/**"], // "**/node_modules/**" (not "node_modules/**") — see vitest.config.mts's
// own comment for why this matters once a nested node_modules
// (manager/node_modules) exists in the repo.
exclude: ["**/node_modules/**", ".next/**", "templates/**", "manager/**"],
// Concurrency tests (double-booking prevention) need real, unshared // Concurrency tests (double-booking prevention) need real, unshared
// DB connections and predictable ordering — run integration test // DB connections and predictable ordering — run integration test
// files one at a time. // files one at a time.