Skip to content
Y
YAS.SH
Docs

10_DEPLOYMENT

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 compose local 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 provider per 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 under Type=simple, env from chmod 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. Add limit_req_zone in the http {} block and reference it in location /api/ + location / (see docs/15_OPERATIONS_ORACLE_LINUX.md).
  • Client IP / TRUST_PROXY (v3.2.5): the app only trusts X-Real-IP/X-Forwarded-For/CF-Connecting-IP when TRUST_PROXY=true. The setup script and yas.service set this for production. Nginx overwrites X-Real-IP with $remote_addr, so this is safe and makes /ip and what-is-my-ip report the caller's real public IP. When running the standalone server without a trusted proxy, keep TRUST_PROXY=false — the app then reports the genuine connection address and ignores forged headers.

First-boot / rollback

  • DB init: pnpm db:push:prod is idempotent; re-running only applies diffs.
  • Rollback: previous release = previous /opt/yas tree — keep releases under /opt/yas/releases/<ver> and repoint the systemd WorkingDirectory; DB changes are additive so db:push of 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.

🍪 Cookies & privacy. yas.sh uses only essential cookies to keep you signed in and remember your preferences. We do not run third-party trackers. See our cookie policy and privacy policy.
Settings