VPS
Example of vps deploy with infra split from business. Keeps one mysql instance for all.
This commit is contained in:
6 files changed
+359
No files matched your search
@@ -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"
|
||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
Reference in new issue
Block a user