Example of vps deploy with infra split from business.

Keeps one mysql instance for all.
This commit is contained in:
Stefan Lazic committed 2026-08-07 19:45:56 +01:00
1 parent b41a6d0af7
commit 841ee7eb52
6 files changed
+359

No files matched your search

+100
View File
@@ -0,0 +1,100 @@
# One business's config on the shared multi-business VPS setup — see
# docker-compose.infra.yaml for the one-time setup this depends on
# (creating this business's database on the shared MySQL server) and
# docker-compose.<business>.yaml for how this file is used. Copy this to
# `.env.business.<slug>` (e.g. `.env.business.blake-heritage` — kept out
# of git, see .gitignore) per business and fill in real values. Nothing
# here is a real credential.
#
# 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"
# Generate with: npx auth secret
AUTH_SECRET="replace-me-with-a-32-byte-random-string"
# Auth.js needs to trust the host it's served from when not on localhost:3000.
AUTH_TRUST_HOST="true"
NEXT_PUBLIC_APP_URL="https://blake-heritage.example.com"
# One-time setup token the installation wizard (/install) requires before
# it will create the first business/administrator — generate with:
# openssl rand -hex 32
INSTALLATION_TOKEN="replace-me"
# 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"
+20
View File
@@ -0,0 +1,20 @@
# Shared infrastructure config — see docker-compose.infra.yaml for the
# full setup sequence. Copy this to `.env.infra` (kept out of git — see
# .gitignore) and fill in real values.
#
# docker network create next-chair-edge
# 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"
+10
View File
@@ -25,6 +25,16 @@ dist/
.env.development .env.development
.env.production .env.production
# per-deployment production env files (multi-business VPS setup — see
# docker-compose.infra.yaml / docker-compose.<business>.yaml). Named
# .env.business.* (not .env.<business> directly) specifically so this one
# wildcard rule keeps covering every future business without another
# .gitignore edit. The template itself (.env.business.example) stays
# tracked, same as .env.local.example above.
.env.infra
.env.business.*
!.env.business.example
# prisma # prisma
prisma/dev.db prisma/dev.db
prisma/dev.db-journal prisma/dev.db-journal
+63
View File
@@ -0,0 +1,63 @@
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
@@ -0,0 +1,103 @@
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
@@ -0,0 +1,63 @@
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