Deployment Redesign
CI / ci (push) Canceled after 0s

Implemented the deployment redesign.
- Added Dockge-first-class stacks:
  - [stacks/infra/compose.yaml]
  - [stacks/caddy/compose.yaml]
- Added reusable business template: [deploy/business-compose.yaml.template]
- Added automated provisioning: [scripts/create-business.sh]
- Added per-business database/user, secrets, media volume, private networks, and Caddy route generation.
- Removed Traefik and manual business Compose files.
- Updated environment templates, .gitignore, and deployment documentation in [README.md]
Validation completed:
- Docker Compose config passed for infra, Caddy, and generated business stacks.
- Shell syntax validation passed.
- Registry image tags containing / were tested.
- No remaining Traefik or obsolete deployment references found.
This commit is contained in:
Stefan Lazic committed 2026-09-04 20:32:06 +01:00
1 parent 5472db320e
commit 764d087eac
13 files changed
+288 -379

No files matched your search

+16 -99
View File
@@ -1,100 +1,17 @@
# One business's config on the shared multi-business VPS setup — see # Generated business environment file. Run
# docker-compose.infra.yaml for the one-time setup this depends on # `./scripts/create-business.sh <slug> <domain>` instead of editing a copy.
# (creating this business's database on the shared MySQL server) and DOMAIN="example.com"
# docker-compose.<business>.yaml for how this file is used. Copy this to DATABASE_HOST="nextchair-mysql"
# `.env.business.<slug>` (e.g. `.env.business.blake-heritage` — kept out DATABASE_PORT=3306
# of git, see .gitignore) per business and fill in real values. Nothing DATABASE_NAME="nextchair_example_business"
# here is a real credential. DATABASE_USER="nextchair_example_business"
#
# The app starts and runs a complete first-install flow with ONLY the
# "Required for basic startup" section below filled in — every other
# integration (email, push, Google sign-in, Google Calendar) shows a
# disabled state rather than crashing when its variables are left blank.
# ── Required for basic startup ─────────────────────────────────────────
# The public domain this business is served on — used by Traefik's
# routing label (see docker-compose.<business>.yaml) to decide which
# container handles requests for it, so this must be set and must match
# the domain's real DNS (pointed at the VPS) before Traefik can issue a
# certificate for it.
DOMAIN="blake-heritage.example.com"
# The app reaches MySQL via the shared infra's service hostname
# `next-chair-mysql`, never `localhost` — see docker-compose.infra.yaml.
# DATABASE_NAME/USER/PASSWORD must match exactly what you created for
# this business on the shared server (see docker-compose.infra.yaml's
# "CREATE DATABASE ..." step) — this file doesn't create them, it only
# tells the app what to connect as.
DATABASE_HOST="next-chair-mysql"
DATABASE_PORT="3306"
DATABASE_NAME="blake_heritage"
DATABASE_USER="blake_heritage"
DATABASE_PASSWORD="replace-me" DATABASE_PASSWORD="replace-me"
# Generate with: npx auth secret DATABASE_CONNECTION_LIMIT=5
AUTH_SECRET="replace-me-with-a-32-byte-random-string" DATABASE_CONNECT_TIMEOUT_MS=10000
# Auth.js needs to trust the host it's served from when not on localhost:3000. DATABASE_SSL=false
AUTH_TRUST_HOST="true" AUTH_SECRET="replace-with-a-long-random-secret"
NEXT_PUBLIC_APP_URL="https://blake-heritage.example.com" AUTH_TRUST_HOST=true
# One-time setup token the installation wizard (/install) requires before NEXT_PUBLIC_APP_URL="https://example.com"
# it will create the first business/administrator — generate with: INSTALLATION_TOKEN="replace-with-a-long-random-token"
# openssl rand -hex 32 CRON_SECRET="replace-with-a-long-random-secret"
INSTALLATION_TOKEN="replace-me" NEXTCHAIR_IMAGE="nextchair:latest"
# Shared secret an external scheduler presents to
# /api/cron/process-notifications — generate with: openssl rand -hex 32
CRON_SECRET="replace-me"
# ── Super Administrator (optional — see scripts/ensure-super-admin.ts) ──
# Platform-level account with every Owner permission. Not run
# automatically by this compose file (that's a Docker-entrypoint-only
# behaviour, see docker/entrypoint.sh) — run it manually, once, the same
# way migrations were run for the Vercel/Hostinger deployment earlier:
# 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
# wizard's own administrator account instead.
SUPER_ADMIN_EMAIL=""
SUPER_ADMIN_PASSWORD=""
SUPER_ADMIN_FIRST_NAME=""
SUPER_ADMIN_LAST_NAME=""
# ── Google sign-in (optional — "Continue with Google" on Sign In) ─────
AUTH_GOOGLE_ID=""
AUTH_GOOGLE_SECRET=""
# ── Google Calendar sync (optional — staff-level, see /staff/settings/calendar) ─
# From https://console.cloud.google.com/apis/credentials — a separate
# OAuth client from AUTH_GOOGLE_ID/SECRET above (different scopes).
GOOGLE_CALENDAR_CLIENT_ID=""
GOOGLE_CALENDAR_CLIENT_SECRET=""
GOOGLE_CALENDAR_REDIRECT_URI="https://blake-heritage.example.com/api/staff/calendar/google/callback"
# Encrypts stored refresh tokens at rest — generate with: openssl rand -hex 32
CALENDAR_TOKEN_ENCRYPTION_KEY=""
# ── Media Manager (optional — see src/lib/media/media-storage.ts) ──────
# Max upload size in bytes for the Media Manager. Defaults to 5,000,000
# (5MB) if unset.
MEDIA_MAX_UPLOAD_BYTES="5000000"
# ── Google Reviews (optional — see /owner/settings/reviews) ────────────
# From https://console.cloud.google.com/apis/credentials — needs "Places
# API (New)" enabled on that project (a different product from the older
# "Places API" — enabling the wrong one returns REQUEST_DENIED). Leave
# blank to keep review sync disabled entirely (Home shows clearly-labelled
# demo reviews instead).
GOOGLE_PLACES_API_KEY=""
# ── Email (optional — defaults to logging to the console instead of sending) ─
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=""
# ── Web Push (optional — defaults to logging to the console instead of sending) ─
# Generate with: npx web-push generate-vapid-keys
PUSH_PROVIDER="console"
VAPID_PUBLIC_KEY=""
NEXT_PUBLIC_VAPID_PUBLIC_KEY=""
VAPID_PRIVATE_KEY=""
VAPID_SUBJECT="mailto:owner@example.com"
+5 -20
View File
@@ -1,20 +1,5 @@
# Shared infrastructure config — see docker-compose.infra.yaml for the # Shared infrastructure config. Copy to `.env.infra`; the real file is
# full setup sequence. Copy this to `.env.infra` (kept out of git — see # ignored by git and is also read by scripts/create-business.sh.
# .gitignore) and fill in real values. MYSQL_ROOT_PASSWORD="replace-with-a-long-random-password"
# # Optional loopback-only port for an SSH tunnel or local admin tool.
# docker network create next-chair-edge MYSQL_HOST_PORT=127.0.0.1:3306
# docker compose -f docker-compose.infra.yaml --env-file .env.infra up -d
# ── MySQL (shared server — one database per business, created manually
# per business, see docker-compose.infra.yaml's own header) ────────────
MYSQL_ROOT_PASSWORD="replace-me"
# 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
# shared Docker network and the service name `next-chair-mysql`, never
# this.
MYSQL_HOST_PORT=3306
# ── Traefik (reverse proxy + automatic TLS via Let's Encrypt) ──────────
# Let's Encrypt sends renewal/expiry notices here — use a real address
# you actually check, not a placeholder.
ACME_EMAIL="you@example.com"
+3
View File
@@ -34,6 +34,9 @@ dist/
.env.infra .env.infra
.env.business.* .env.business.*
!.env.business.example !.env.business.example
.env.caddy
stacks/*/.env
stacks/caddy/sites/*.caddy
# prisma # prisma
prisma/dev.db prisma/dev.db
+83 -31
View File
@@ -17,14 +17,18 @@ The application is intentionally configured with discrete database settings: `DA
## Deployment Model ## Deployment Model
The intended deployment model is a shared multi-business VPS: The intended deployment model is one repeatable Docker deployment for both a
local/self-hosted Debian server and production:
- One Traefik reverse proxy handles HTTPS and routes each business domain. - One Dockge-managed Caddy stack owns ports 80 and 443 and routes each domain.
- One shared MySQL 8 server hosts a separate database and database user for each business. - One Dockge-managed MySQL 8 stack hosts a separate database and user per business.
- Each business runs its own application container and has its own media volume. - Each business is a separate Dockge stack with its own media volume and env file.
- Each business is installed independently through `/install`. - Business containers publish no host ports and share only the proxy and database networks.
- Each business is installed independently through `/install` after its stack starts.
`.env.business.example` is the primary application configuration template. The local Docker files are development tooling and should not be treated as the production deployment model. `deploy/business-compose.yaml.template` and `scripts/create-business.sh` are the
canonical business deployment path. The local Docker file remains useful for a
single isolated development instance.
## Requirements ## Requirements
@@ -38,7 +42,8 @@ For the primary VPS deployment:
- A Linux VPS with Docker Engine and Docker Compose - A Linux VPS with Docker Engine and Docker Compose
- A domain per business, with DNS pointing at the VPS - A domain per business, with DNS pointing at the VPS
- Ports 80 and 443 available for Traefik and Let's Encrypt - Ports 80 and 443 available for Caddy and Let's Encrypt
- Dockge configured to scan the directory containing the generated stacks
For local development: For local development:
@@ -150,7 +155,7 @@ Use this path when you already have MySQL running outside Docker or want fast Ne
The seed is idempotent and creates demo business, staff, services, customers, appointments, and related records. It marks the database as installed so the application can be used immediately. Do not run it alongside a real first-run installation. The seed is idempotent and creates demo business, staff, services, customers, appointments, and related records. It marks the database as installed so the application can be used immediately. Do not run it alongside a real first-run installation.
The direct Node path and the local Docker path should use different `.env.local` values: Docker connects to `next-chair-mysql`, while a host-run Next.js process normally connects to `localhost`. For the business-specific VPS environment, use `.env.business.<slug>` and the matching per-business compose file instead. The direct Node path and the local Docker path should use different `.env.local` values: Docker connects to `next-chair-mysql`, while a host-run Next.js process normally connects to `localhost`. Business stacks use `DATABASE_HOST=nextchair-mysql` on the shared database network.
## Environment Configuration ## Environment Configuration
@@ -158,7 +163,9 @@ The available templates are:
- `.env.example`: host-run development configuration - `.env.example`: host-run development configuration
- `.env.local.example`: Docker Desktop configuration - `.env.local.example`: Docker Desktop configuration
- `.env.business.example`: one business in the shared VPS deployment - `.env.infra.example`: shared MySQL root settings
- `stacks/caddy/.env.example`: shared Caddy ACME email
- `.env.business.example`: generated business environment reference
Required for a basic running installation: Required for a basic running installation:
@@ -245,35 +252,75 @@ npm run test:e2e
Playwright uses one Chromium project, one worker, a 45-second test timeout, screenshots on failure, and retains traces on failure. The database should be reset or recreated between isolated test runs when fixtures from previous runs could affect results. Playwright uses one Chromium project, one worker, a 45-second test timeout, screenshots on failure, and retains traces on failure. The database should be reset or recreated between isolated test runs when fixtures from previous runs could affect results.
## VPS Deployment Reference ## Docker Server Deployment
The repository also contains a shared multi-business deployment model: This is the same topology for a Debian self-hosted server and a production
server. The only intentional deployment difference is the image tag: build
`nextchair:latest` locally, or use a registry tag such as
`registry.example.com/nextchair:2026-09-04` in production.
- `docker-compose.infra.yaml` runs one Traefik reverse proxy and one shared MySQL 8 server. ### 1. Prepare the server
- Each business gets its own database and MySQL user, not a shared schema.
- `docker-compose.blake-heritage.yaml` and `docker-compose.marios.yaml` are per-business application examples.
- `.env.business.example` is the template for each business environment file.
The high-level setup is: Copy this repository's deployment files to a stable directory, for example
`/opt/nextchair`, and configure Dockge to use `/opt/nextchair/stacks` as its
stack directory. The tracked `infra` and `caddy` directories are shared
stacks; every generated business directory sits beside them.
1. Create the external network and configure `.env.infra`. Build or pull the application image once:
2. Start the shared infrastructure:
```bash ```bash
docker network create next-chair-edge # Self-hosted/local image
docker compose -f docker-compose.infra.yaml --env-file .env.infra up -d docker build -t nextchair:latest .
```
3. Create a separate database and user for the business on the shared MySQL server. # Production image example
4. Copy `.env.business.example` to `.env.business.<slug>` and set the domain, database credentials, secrets, and `NEXT_PUBLIC_APP_URL`. docker pull registry.example.com/nextchair:2026-09-04
5. Start the business compose file: ```
```bash Create the shared networks and start the two infrastructure stacks:
docker compose -f docker-compose.blake-heritage.yaml \
--env-file .env.business.blake-heritage up -d --build
```
Traefik terminates HTTPS with Let's Encrypt and routes by the configured `DOMAIN`. Read the comments in [docker-compose.infra.yaml](./docker-compose.infra.yaml) for the exact database provisioning sequence and operational assumptions. ```bash
cp .env.infra.example .env.infra
cp stacks/caddy/.env.example stacks/caddy/.env
# Edit both env files before starting them.
docker network create nextchair-db
docker network create nextchair-proxy
docker compose -f stacks/infra/compose.yaml up -d
docker compose -f stacks/caddy/compose.yaml up -d
```
The MySQL port is loopback-only by default. Use an SSH tunnel for database
administration rather than opening port 3306 publicly.
### 2. Provision a business
Run this after MySQL is healthy. The script creates the database and least
scope database user, generates secrets, writes a Dockge-ready stack directory,
and writes one Caddy site file:
```bash
NEXTCHAIR_STACK_ROOT=/opt/nextchair/stacks \
NEXTCHAIR_CADDY_SITES_DIR=/opt/nextchair/stacks/caddy/sites \
./scripts/create-business.sh blake-heritage blakeheritage.com nextchair:latest
```
For production, pass the exact image tag to use. The script never overwrites
an existing business slug. In Dockge, the new directory appears as its own
stack, so `start`, `stop`, `restart`, `recreate`, `update`, and logs are scoped
to that business. The generated `.env` is mode `600` and is ignored by git.
After DNS points at the server, start the generated stack from Dockge. Caddy
will obtain the certificate and route the domain to the app container over the
private `nextchair-proxy` network. Reload Caddy after adding or removing a
route:
```bash
docker compose -f stacks/caddy/compose.yaml exec nextchair-caddy caddy reload --config /etc/caddy/Caddyfile
```
Repeat the provisioning command for each business. A business's database,
media volume, env file, app container, and Caddy route are all named from its
slug. The application migration entrypoint runs on every start, while the
first-run business setup remains the app-level `/install` flow.
## Repository Layout ## Repository Layout
@@ -286,6 +333,9 @@ prisma/ Schema, migrations, and development seed
e2e/ Playwright browser journeys and test setup e2e/ Playwright browser journeys and test setup
docker/ Container entrypoint docker/ Container entrypoint
docs/ Phase implementation notes and historical decisions docs/ Phase implementation notes and historical decisions
deploy/ Reusable business stack template
stacks/ Dockge stacks: infra, caddy, and businesses
scripts/ Database/business provisioning utilities
templates/ UI reference templates templates/ UI reference templates
``` ```
@@ -295,7 +345,9 @@ The phase documents in `docs/` contain implementation history and deeper design
**The app redirects to `/install`.** This is expected for a fresh or unseeded database. Set `INSTALLATION_TOKEN` and complete the wizard, or explicitly run the development seed. **The app redirects to `/install`.** This is expected for a fresh or unseeded database. Set `INSTALLATION_TOKEN` and complete the wizard, or explicitly run the development seed.
**The app cannot connect to MySQL in Docker.** Use `DATABASE_HOST=next-chair-mysql`, not `localhost`, and ensure the `DATABASE_*` credentials match `MYSQL_DATABASE`, `MYSQL_USER`, and `MYSQL_PASSWORD`. **The app cannot connect to MySQL in the local stack.** Use `DATABASE_HOST=next-chair-mysql`, not `localhost`, and ensure the local credentials match the local MySQL container.
**The app cannot connect to MySQL in a business stack.** Use `DATABASE_HOST=nextchair-mysql`, confirm `nextchair-mysql` is attached to `nextchair-db`, and confirm the generated `DATABASE_NAME`, `DATABASE_USER`, and `DATABASE_PASSWORD` match the database created by `scripts/create-business.sh`.
**The app cannot connect to MySQL when run with `npm run dev`.** Use `DATABASE_HOST=localhost` or the hostname of your external MySQL server, and verify that the database and user already exist. **The app cannot connect to MySQL when run with `npm run dev`.** Use `DATABASE_HOST=localhost` or the hostname of your external MySQL server, and verify that the database and user already exist.
+35
View File
@@ -0,0 +1,35 @@
name: nextchair-${BUSINESS_SLUG}
# Generated by scripts/create-business.sh. Keep this file self-contained so
# Dockge can operate this business independently.
services:
nextchair-${BUSINESS_SLUG}:
image: ${NEXTCHAIR_IMAGE:-nextchair:latest}
container_name: nextchair-${BUSINESS_SLUG}
restart: unless-stopped
env_file:
- .env
volumes:
- nextchair-media-${BUSINESS_SLUG}:/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:
- nextchair-proxy
- nextchair-db
volumes:
nextchair-media-${BUSINESS_SLUG}:
name: nextchair-media-${BUSINESS_SLUG}
networks:
nextchair-proxy:
name: nextchair-proxy
external: true
nextchair-db:
name: nextchair-db
external: true
-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
+80
View File
@@ -0,0 +1,80 @@
#!/usr/bin/env bash
set -Eeuo pipefail
usage() {
echo "Usage: $0 <business-slug> <domain> [image-tag]" >&2
echo "Example: $0 blake-heritage blakeheritage.com nextchair:2026-09-04" >&2
exit 2
}
[[ $# -ge 2 && $# -le 3 ]] || usage
business_slug="$1"
domain="$2"
image_tag="${3:-nextchair:latest}"
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
stack_root="${NEXTCHAIR_STACK_ROOT:-${repo_root}/stacks}"
infra_env="${NEXTCHAIR_INFRA_ENV:-${repo_root}/.env.infra}"
stack_dir="${stack_root}/${business_slug}"
compose_template="${repo_root}/deploy/business-compose.yaml.template"
[[ "$business_slug" =~ ^[a-z0-9]+([a-z0-9-]*[a-z0-9])?$ ]] || { echo "Invalid slug: use lowercase letters, numbers, and hyphens." >&2; exit 2; }
[[ ${#business_slug} -le 22 ]] || { echo "Invalid slug: maximum length is 22 characters." >&2; exit 2; }
[[ "$domain" =~ ^[A-Za-z0-9.-]+$ ]] || { echo "Invalid domain." >&2; exit 2; }
[[ -f "$infra_env" ]] || { echo "Missing $infra_env. Copy .env.infra.example first." >&2; exit 1; }
[[ -f "$compose_template" ]] || { echo "Missing $compose_template." >&2; exit 1; }
[[ ! -e "$stack_dir" ]] || { echo "Stack already exists: $stack_dir" >&2; exit 1; }
# shellcheck disable=SC1090
source "$infra_env"
[[ -n "${MYSQL_ROOT_PASSWORD:-}" ]] || { echo "MYSQL_ROOT_PASSWORD is required in $infra_env." >&2; exit 1; }
database_name="nextchair_${business_slug//-/_}"
database_user="$database_name"
database_password="$(openssl rand -hex 24)"
auth_secret="$(openssl rand -base64 48 | tr -d '\n')"
installation_token="$(openssl rand -hex 32)"
cron_secret="$(openssl rand -hex 32)"
docker network inspect nextchair-db >/dev/null 2>&1 || docker network create nextchair-db >/dev/null
docker network inspect nextchair-proxy >/dev/null 2>&1 || docker network create nextchair-proxy >/dev/null
docker inspect nextchair-mysql >/dev/null 2>&1 || { echo "nextchair-mysql is not running. Start stacks/infra/compose.yaml first." >&2; exit 1; }
printf 'Creating database %s...\n' "$database_name"
sql="CREATE DATABASE IF NOT EXISTS \`$database_name\` CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci; CREATE USER IF NOT EXISTS '$database_user'@'%' IDENTIFIED BY '$database_password'; ALTER USER '$database_user'@'%' IDENTIFIED BY '$database_password'; GRANT ALL PRIVILEGES ON \`$database_name\`.* TO '$database_user'@'%'; FLUSH PRIVILEGES;"
printf '%s\n' "$sql" | docker exec -i nextchair-mysql mysql -uroot -p"$MYSQL_ROOT_PASSWORD"
mkdir -p "$stack_dir"
sed \
-e "s|\${BUSINESS_SLUG}|$business_slug|g" \
-e "s|\${NEXTCHAIR_IMAGE:-nextchair:latest}|$image_tag|g" \
"$compose_template" > "$stack_dir/compose.yaml"
cat > "$stack_dir/.env" <<EOF
DOMAIN="$domain"
DATABASE_HOST="nextchair-mysql"
DATABASE_PORT=3306
DATABASE_NAME="$database_name"
DATABASE_USER="$database_user"
DATABASE_PASSWORD="$database_password"
DATABASE_CONNECTION_LIMIT=5
DATABASE_CONNECT_TIMEOUT_MS=10000
DATABASE_SSL=false
AUTH_SECRET="$auth_secret"
AUTH_TRUST_HOST=true
NEXT_PUBLIC_APP_URL="https://$domain"
INSTALLATION_TOKEN="$installation_token"
CRON_SECRET="$cron_secret"
NEXTCHAIR_IMAGE="$image_tag"
EOF
caddy_sites_dir="${NEXTCHAIR_CADDY_SITES_DIR:-${stack_root}/caddy/sites}"
mkdir -p "$caddy_sites_dir"
cat > "$caddy_sites_dir/$business_slug.caddy" <<EOF
$domain {
reverse_proxy nextchair-$business_slug:3000
}
EOF
chmod 600 "$stack_dir/.env"
printf '\nCreated Dockge stack: %s\n' "$stack_dir"
printf 'Created Caddy route: %s/%s.caddy\n' "$caddy_sites_dir" "$business_slug"
printf 'Start it from Dockge, or run: docker compose -f %s up -d\n' "$stack_dir/compose.yaml"
+5
View File
@@ -0,0 +1,5 @@
{
email {$ACME_EMAIL}
}
import /etc/caddy/sites/*.caddy
+30
View File
@@ -0,0 +1,30 @@
name: nextchair-caddy
services:
nextchair-caddy:
image: caddy:2.10-alpine
container_name: nextchair-caddy
restart: unless-stopped
env_file:
- .env
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- ./sites:/etc/caddy/sites:ro
- nextchair-caddy-data:/data
- nextchair-caddy-config:/config
networks:
- nextchair-proxy
volumes:
nextchair-caddy-data:
name: nextchair-caddy-data
nextchair-caddy-config:
name: nextchair-caddy-config
networks:
nextchair-proxy:
name: nextchair-proxy
external: true
View File
Whitespace-only changes.
+31
View File
@@ -0,0 +1,31 @@
name: nextchair-infra
services:
nextchair-mysql:
image: mysql:8.0
container_name: nextchair-mysql
restart: unless-stopped
env_file:
- ../../.env.infra
command: --character-set-server=utf8mb4 --collation-server=utf8mb4_0900_ai_ci
ports:
- "${MYSQL_HOST_PORT:-127.0.0.1:3306}:3306"
volumes:
- nextchair-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:
- nextchair-db
volumes:
nextchair-mysql-data:
name: nextchair-mysql-data
networks:
nextchair-db:
name: nextchair-db
external: true