10 — DEPLOYMENT
Depends on: 09_TESTING.md · Next: 11_ROADMAP.md · Updated: v3.1.0 (2026-08-07) — every command below was executed against this repo.
1. Targets
- Local test: macOS (13+) — SQLite, zero external services
- Production: Oracle Linux 9 — Node 20 LTS + systemd + Nginx + MariaDB 11 + SELinux enforcing
- Evaluation (not prod):
docker composelocal stack
2. Local Test — macOS
Requirements: Node 20–24 (brew install node@20 or nvm), pnpm 9.12.3
(corepack enable && corepack prepare pnpm@9.12.3 --activate).
cp .env.example .env # SESSION_SECRET=$(openssl rand -hex 32)
pnpm install --frozen-lockfile # runs prisma generate (SQLite)
pnpm db:push # creates prisma/dev.db
SEED_ADMIN_PASSWORD='DevOnly!234' pnpm db:seed # optional admin user
pnpm dev # dev server → http://localhost:3000
# or the exact production build:
pnpm build && pnpm start
# verification battery (all must be green)
pnpm typecheck && pnpm lint
BASE=http://localhost:3000 pnpm smoke # 57 checks
BASE=http://localhost:3000 pnpm smoke:security # 18 adversarial checks
pnpm audit --prod
Notes: Secure cookies work on http://localhost in Chrome/Firefox/Safari
(localhost is a secure context). If you need a real DB locally, point
DATABASE_URL at Docker MariaDB and use the production schema (§4).
3. Docker — local evaluation only
cp .env.example .env
# fill MARIADB_ROOT_PASSWORD / MARIADB_PASSWORD (compose refuses to start without them)
MARIADB_PASSWORD=localdev MARIADB_ROOT_PASSWORD=localdev docker compose up -d
bash scripts/smoke.sh && bash scripts/security-smoke.sh
The image (docker/Dockerfile) builds the standalone Next.js server and
generates the Prisma client from prisma/schema.production.prisma
(mysql provider) — the same artifact layout deployed on OL9. It runs as a
non-root user with a healthcheck. Do not use this compose in production —
there is no TLS, secret management, or log rotation in it by design.
4. Oracle Linux 9 — Production (canonical)
All of the heavy lifting is scripted — deploy/setup-oracle-linux-9.sh
(packages, Node, DB + generated credentials, systemd unit, Nginx, env file).
After it completes, the remaining manual steps:
# as root, from the repo dir:
bash deploy/setup-oracle-linux-9.sh
# copy source (exclude build junk)
rsync -av --exclude node_modules --exclude .next --exclude .git ./ yas@HOST:/opt/yas/
# as the yas user — build with the PRODUCTION (MariaDB) Prisma schema:
cd /opt/yas
pnpm install --frozen-lockfile
pnpm db:generate:prod # ← mysql client from prisma/schema.production.prisma
pnpm db:push:prod # ← creates/updates MariaDB schema
SEED_ADMIN_PASSWORD='<strong>' pnpm db:seed
BUILD_STANDALONE=true pnpm build
cp -r .next/standalone/. /opt/yas/ && cp -r .next/static /opt/yas/.next/ && cp -r public /opt/yas/
sudo systemctl enable --now yas
sudo certbot --nginx -d yas.sh -d www.yas.sh
systemctl status yas && curl -I https://yas.sh/api/health
BASE=https://yas.sh bash scripts/smoke.sh
BASE=https://yas.sh bash scripts/security-smoke.sh
Why two Prisma schemas? Prisma supports exactly one
providerper schema.schema.prisma(SQLite) keeps local development dependency-free;schema.production.prisma(mysql) is what Docker and OL9 generate the client from. Model definitions must stay 1:1 — verify with:diff <(grep -A200 '^model' prisma/schema.prisma) <(grep -A200 '^model' prisma/schema.production.prisma)
systemd + edge
deploy/yas.service— Node process underType=simple, env fromchmod 600 /etc/yas/yas.env,ProtectSystem=strict,NoNewPrivileges,PrivateTmp, logs → journald +/var/log/yas/yas.log.deploy/nginx-yas.conf— TLS 1.2/1.3, HSTS, OCSP stapling,X-Real-IP/X-Forwarded-*hygiene, 2m body cap, static-vs-API caching. Addlimit_req_zonein thehttp {}block and reference it inlocation /api/+location /(seedocs/15_OPERATIONS_ORACLE_LINUX.md).- Client IP /
TRUST_PROXY(v3.2.5): the app only trustsX-Real-IP/X-Forwarded-For/CF-Connecting-IPwhenTRUST_PROXY=true. The setup script andyas.serviceset this for production. Nginx overwritesX-Real-IPwith$remote_addr, so this is safe and makes/ipandwhat-is-my-ipreport the caller's real public IP. When running the standalone server without a trusted proxy, keepTRUST_PROXY=false— the app then reports the genuine connection address and ignores forged headers.
First-boot / rollback
- DB init:
pnpm db:push:prodis idempotent; re-running only applies diffs. - Rollback: previous release = previous
/opt/yastree — keep releases under/opt/yas/releases/<ver>and repoint the systemd WorkingDirectory; DB changes are additive sodb:pushof the older schema restores behavior.
5. Backups & Restore
bash deploy/backup.sh # mariadb-dump → /var/backups/yas (cron: daily),
# see docs/15 for off-site (rclone/S3) + logrotate
Restore drill (monthly): provision scratch DB → mariadb yas_restore < dump.sql
→ point a staging instance at it → pnpm smoke.
6. Cutover Checklist (V1 → V3)
- Feature inventory signed off in PROJECT_MEMORY.md
- DNS TTL lowered 24h before cutover
-
db:push:prod+ seed applied; admin password rotated from seed value - smoke 57/57 + security-smoke 18/18 green against https://yas.sh
-
limit_req, fail2ban, firewalld, SELinux verified (docs/15) - Rollback plan validated (DNS revert + DB snapshot)
Next: 11_ROADMAP.md — phased delivery.