Deployment Guide
Production checklist, deployment paths (Vercel / Docker / self-hosted), env var reference, AI provider configuration, the daily credit-reset cron, monitoring, backups, and post-deploy verification.
For the quick walkthrough see Deployment. This guide is the long-form companion.
Pre-deploy checklist
Code & config
- Supabase SQL schema applied (
src/db/schema.sql) includingpgvectorextension - Supabase Storage bucket
uploadscreated -
src/config/brand.tsupdated (product name, emails, social) -
src/config/seo.tsupdated (default title/description, OG image) - Logo assets replaced in
public/(light, dark, mobile, favicon) - Pricing tiers in
src/config/pricing.tsmatch what you sell - AI model list in
src/config/ai-models.tsmatches what you'll allow - System prompt reviewed (
src/app/api/chat/route.tsorsrc/lib/ai/system-prompt.ts)
Environment
- All required env vars set in production (table below)
- Stripe is in live mode, not test
- Stripe webhook URL points at production, signing secret captured
- Supabase Auth Site URL + Redirect URLs use production domain
- OpenAI / Anthropic keys are production keys with usage caps configured
Tests
-
pnpm testpasses -
pnpm buildsucceeds locally with production env vars
Required environment variables
| Var | Where it's used | Server-only? |
|---|---|---|
NEXT_PUBLIC_APP_URL | Canonical URLs, redirects | No |
NEXT_PUBLIC_SUPABASE_URL | Supabase client | No |
NEXT_PUBLIC_SUPABASE_ANON_KEY | Supabase client (client-side) | No |
SUPABASE_SERVICE_ROLE_KEY | Server-only privileged ops | Yes |
STRIPE_SECRET_KEY | Stripe API calls | Yes |
STRIPE_WEBHOOK_SECRET | Webhook signature verification | Yes |
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY | Stripe.js client | No |
NEXT_PUBLIC_STRIPE_PORTAL_URL | Customer portal link | No |
STRIPE_PRO_PRICE_ID | Pro plan checkout | Yes |
STRIPE_TOPUP_PRICE_ID | Credit top-up checkout | Yes |
STRIPE_ENTERPRISE_PRICE_ID | Enterprise plan checkout | Yes |
OPENAI_API_KEY | OpenAI LLMs + embeddings | Yes |
ANTHROPIC_API_KEY | Anthropic LLMs | Yes |
CRON_SECRET | Auth header for cron endpoint | Yes |
NEXT_PUBLIC_* vars are bundled into client JS: never store secrets there.
Path 1: Vercel (recommended)
Vercel is the default target: the cron in vercel.json and Next.js 16 App Router are tuned for it.
- Push to GitHub.
- Vercel → Add New Project → Import the repo.
- Framework auto-detected as Next.js. Leave defaults.
- Add all env vars under Project Settings → Environment Variables (Production, Preview, Development scopes).
- Click Deploy.
- Stripe → Developers → Webhooks → Add endpoint:
- URL:
https://yourdomain.com/api/webhooks/stripe - Events:
checkout.session.completed,customer.subscription.updated,customer.subscription.deleted,customer.subscription.created,invoice.payment_succeeded - Copy the signing secret into
STRIPE_WEBHOOK_SECRETand redeploy.
- URL:
- Supabase → Auth → URL Configuration:
- Site URL:
https://yourdomain.com - Redirect URLs:
https://yourdomain.com/auth/callback
- Site URL:
- Verify the cron runs (see "Daily credit-reset cron" below).
Vercel plan note
The shipped vercel.json declares a daily cron at 00:00 UTC for /api/cron/reset-credits. Vercel Hobby does not include cron jobs: cron functionality requires Vercel Pro or higher. If you stay on Hobby, you must trigger the endpoint from an external scheduler (cron-job.org, GitHub Actions on a schedule, an Upstash QStash schedule, etc.) using the CRON_SECRET for auth.
Daily credit-reset cron
The endpoint at /api/cron/reset-credits runs once a day to refill monthly credit allocations for active subscribers. It's authenticated by the CRON_SECRET env var.
On Vercel Pro+: the schedule in vercel.json (0 0 * * *) handles it: set CRON_SECRET and it just works. Verify the next day in Vercel → Logs (filter by /api/cron/reset-credits).
Off Vercel (or on Hobby): trigger externally:
# Once a day from any cron, GitHub Actions, or Upstash QStash
curl -X POST https://yourdomain.com/api/cron/reset-credits \
-H "Authorization: Bearer $CRON_SECRET"
Sample GitHub Actions workflow (.github/workflows/reset-credits.yml):
name: Reset credits
on:
schedule:
- cron: "0 0 * * *"
jobs:
reset:
runs-on: ubuntu-latest
steps:
- run: |
curl -fsS -X POST $URL \
-H "Authorization: Bearer $SECRET"
env:
URL: https://yourdomain.com/api/cron/reset-credits
SECRET: ${{ secrets.CRON_SECRET }}
AI provider configuration
The template uses the Vercel AI SDK with adapters for OpenAI and Anthropic. To customize:
Restrict / expand the model list
Edit src/config/ai-models.ts. Each entry maps an internal id to a provider, display name, credit cost, and tier access.
Add a new provider (Groq, Together, Mistral, etc.)
- Install the AI SDK adapter:
pnpm add @ai-sdk/groq - In
src/lib/ai/providers.ts(or wherever the chat route resolves the model), add a provider switch case:import { groq } from "@ai-sdk/groq"; if (provider === "groq") return groq(modelId); - Add an env var (
GROQ_API_KEY) and reference it in the SDK init. - Add the new model entries in
src/config/ai-models.ts.
Reference: Vercel AI SDK provider docs.
Embeddings provider
The shipped pipeline uses OpenAI embeddings for documents (text-embedding-3-small). To swap (e.g., to Cohere or local), edit the embed* calls in src/lib/rag/: typically in an embedDocuments.ts or similar file. Match the embedding dimension to your pgvector column type.
Path 2: Docker
Sample Dockerfile:
FROM node:20-alpine AS base
WORKDIR /app
RUN corepack enable
FROM base AS deps
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
FROM base AS build
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ARG NEXT_PUBLIC_APP_URL
ARG NEXT_PUBLIC_SUPABASE_URL
ARG NEXT_PUBLIC_SUPABASE_ANON_KEY
ARG NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY
ARG NEXT_PUBLIC_STRIPE_PORTAL_URL
RUN pnpm build
FROM base AS runner
ENV NODE_ENV=production
COPY --from=build /app/.next/standalone ./
COPY --from=build /app/.next/static ./.next/static
COPY --from=build /app/public ./public
EXPOSE 3000
CMD ["node", "server.js"]
Add output: "standalone" to next.config.ts:
const nextConfig: NextConfig = { output: "standalone", /* ... */ };
Run with all server-only env vars passed at runtime:
docker run -p 3000:3000 \
-e SUPABASE_SERVICE_ROLE_KEY=... \
-e STRIPE_SECRET_KEY=... \
-e STRIPE_WEBHOOK_SECRET=... \
-e OPENAI_API_KEY=... \
-e ANTHROPIC_API_KEY=... \
-e CRON_SECRET=... \
-e STRIPE_PRO_PRICE_ID=... \
-e STRIPE_TOPUP_PRICE_ID=... \
-e STRIPE_ENTERPRISE_PRICE_ID=... \
saasforge-ai
Front the container with a TLS-terminating proxy. Schedule the credit-reset via host crontab or systemd timer.
Path 3: Self-hosted Node (VPS)
git clone https://github.com/your-org/saasforge-ai /opt/saasforge-ai
cd /opt/saasforge-ai
pnpm install --frozen-lockfile
cp .env.local.example .env.local # fill in production values
pnpm build
pnpm add -g pm2
pm2 start "pnpm start" --name saasforge-ai
pm2 save && pm2 startup
Reverse proxy with Caddy (/etc/caddy/Caddyfile):
yourdomain.com {
reverse_proxy localhost:3000
}
Schedule the credit reset via host cron:
0 0 * * * curl -fsS -X POST https://yourdomain.com/api/cron/reset-credits -H "Authorization: Bearer $CRON_SECRET"
Stripe webhook setup
The webhook handler is at /api/webhooks/stripe. Required events:
checkout.session.completed: fulfill new subscriptions and credit top-upscustomer.subscription.createdcustomer.subscription.updated: plan upgrades/downgradescustomer.subscription.deleted: cancellationsinvoice.payment_succeeded: credit refresh on renewalinvoice.payment_failed: dunning
Test locally with the Stripe CLI:
stripe listen --forward-to localhost:3000/api/webhooks/stripe
stripe trigger checkout.session.completed
Monitoring
| Concern | Tool | Free tier? |
|---|---|---|
| Frontend errors | Sentry | Yes |
| API/server logs | Vercel logs / Logtail / Axiom | Yes |
| Web Vitals | Vercel Analytics (already wired in template) | Yes (Pro) |
| Uptime | Better Stack / UptimeRobot | Yes |
| AI provider quota | OpenAI / Anthropic dashboards | Yes |
| Stripe webhook deliveries | Stripe Dashboard → Webhooks | Yes |
| Supabase queries | Supabase Dashboard → Reports | Yes |
Set hard usage caps on OpenAI and Anthropic: a runaway loop without caps can spend hundreds of dollars before you notice.
Backup strategy
| Data | Where | Recovery |
|---|---|---|
| Postgres + vectors | Supabase Daily backups (Pro+) | Restore from dashboard; PITR on Pro+ |
| Uploaded documents | Supabase Storage uploads bucket | Bucket versioning |
| Embeddings | Postgres (already backed up) | Re-embed via background job if lost |
| Stripe | Stripe-side | n/a |
| Code + envs | Git + secret manager | Re-deploy from prior commit |
For Supabase free-tier: schedule pg_dump to S3 (works for the small/embedding tables; very large vector tables get expensive).
Post-deploy verification
- Hit
/: landing page renders, no console errors - Sign up → confirmation email arrives → confirm
- Sign in
- Send a chat message: response streams back, credits decremented
- Upload a PDF: appears in document list, embeddings job completes (check
documents+embeddingstables in Supabase) - Ask a RAG question with the doc selected: response cites context, RAG indicator shows
- Subscribe to a paid plan with a Stripe test card (
4242 4242 4242 4242) - Check Customer Portal opens
- Cancel the subscription → webhook fires → status updates
- Wait until 00:00 UTC (or invoke manually with
CRON_SECRET) → confirm credits reset for active subscribers incredit_ledger
If any step fails: check Vercel logs + Supabase logs + Stripe webhook delivery log + OpenAI/Anthropic dashboard for rate-limit hits.
Custom domain
- Vercel → Project → Settings → Domains → Add → set DNS records as instructed.
- Set
NEXT_PUBLIC_APP_URLto the new domain → redeploy. - Supabase Auth → URL Configuration → update Site URL + Redirect URLs.
- Stripe webhooks → update endpoint URL → capture new signing secret if required.
- Re-test sign-up + checkout end-to-end.
Rollback
- Vercel: Deployments → previous deployment → Promote to Production.
- Docker / VPS:
git checkout <previous-tag>→ rebuild → restart.
Database schema changes are not rolled back automatically: write reverse migrations or be prepared to repair manually.