From 841ee7eb52d7a330e06292bee46c0e210b664c90 Mon Sep 17 00:00:00 2001 From: Stefan Lazic Date: Fri, 7 Aug 2026 19:45:56 +0100 Subject: [PATCH] VPS Example of vps deploy with infra split from business. Keeps one mysql instance for all. --- .env.business.example | 100 ++++++++++++++++++++++++++++ .env.infra.example | 20 ++++++ .gitignore | 10 +++ docker-compose.blake-heritage.yaml | 63 ++++++++++++++++++ docker-compose.infra.yaml | 103 +++++++++++++++++++++++++++++ docker-compose.marios.yaml | 63 ++++++++++++++++++ 6 files changed, 359 insertions(+) create mode 100644 .env.business.example create mode 100644 .env.infra.example create mode 100644 docker-compose.blake-heritage.yaml create mode 100644 docker-compose.infra.yaml create mode 100644 docker-compose.marios.yaml diff --git a/.env.business.example b/.env.business.example new file mode 100644 index 0000000..380dffb --- /dev/null +++ b/.env.business.example @@ -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..yaml for how this file is used. Copy this to +# `.env.business.` (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..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" diff --git a/.env.infra.example b/.env.infra.example new file mode 100644 index 0000000..e1c9f20 --- /dev/null +++ b/.env.infra.example @@ -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" diff --git a/.gitignore b/.gitignore index 90390d3..0b47c75 100644 --- a/.gitignore +++ b/.gitignore @@ -25,6 +25,16 @@ dist/ .env.development .env.production +# per-deployment production env files (multi-business VPS setup — see +# docker-compose.infra.yaml / docker-compose..yaml). Named +# .env.business.* (not .env. 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/dev.db prisma/dev.db-journal diff --git a/docker-compose.blake-heritage.yaml b/docker-compose.blake-heritage.yaml new file mode 100644 index 0000000..2d455e7 --- /dev/null +++ b/docker-compose.blake-heritage.yaml @@ -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 diff --git a/docker-compose.infra.yaml b/docker-compose.infra.yaml new file mode 100644 index 0000000..7577d7c --- /dev/null +++ b/docker-compose.infra.yaml @@ -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. +# .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., fill it in +# with the credentials just created and that business's domain. +# 3. docker compose -f docker-compose..yaml --env-file .env.business. 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 diff --git a/docker-compose.marios.yaml b/docker-compose.marios.yaml new file mode 100644 index 0000000..c528f69 --- /dev/null +++ b/docker-compose.marios.yaml @@ -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