Deployment Guide

Production checklist, deployment paths (Vercel / Docker / self-hosted), env var reference, monitoring, backups, and post-deploy verification.

For the quick Vercel walkthrough, see Deployment. This guide is the long-form companion.

Pre-deploy checklist

Run through this before clicking deploy:

Code & config

  • All Supabase SQL migrations applied (supabase/001_*.sql through 006_*.sql)
  • src/config/brand.ts updated (product name, emails, social)
  • src/config/seo.ts updated (default title/description, OG image)
  • Logo assets replaced in public/ (light, dark, mobile, favicon)
  • Pricing tiers in src/config/pricing.ts match what you sell
  • Legal pages reviewed (src/app/(marketing)/(legal)/)

Environment

  • All required env vars set in production (table below)
  • Stripe is in live mode (sk_live_…, pk_live_…), not test
  • Stripe webhook URL points at production, signing secret captured
  • Supabase Auth Site URL + Redirect URLs use production domain
  • Resend domain verified (DKIM/SPF green)

Tests

  • pnpm test (Vitest unit) passes
  • pnpm test:e2e (Playwright) passes against a staging deploy
  • pnpm build succeeds locally with production env vars

Required environment variables

VarWhere it's usedServer-only?
NEXT_PUBLIC_APP_URLCanonical URLs, emails, redirectsNo
NEXT_PUBLIC_SUPABASE_URLSupabase clientNo
NEXT_PUBLIC_SUPABASE_ANON_KEYSupabase client (client-side)No
SUPABASE_SERVICE_ROLE_KEYServer-only privileged ops (webhooks, admin tasks)Yes
STRIPE_SECRET_KEYStripe API callsYes
STRIPE_WEBHOOK_SECRETWebhook signature verificationYes
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEYStripe.js clientNo
NEXT_PUBLIC_STRIPE_PRO_MONTHLY_PRICE_IDPro plan checkoutNo
NEXT_PUBLIC_STRIPE_PRO_YEARLY_PRICE_IDPro plan checkoutNo
NEXT_PUBLIC_STRIPE_ENTERPRISE_MONTHLY_PRICE_IDEnterprise plan checkoutNo
NEXT_PUBLIC_STRIPE_ENTERPRISE_YEARLY_PRICE_IDEnterprise plan checkoutNo
RESEND_API_KEYEmail sendingYes
RESEND_FROM_EMAIL (or EMAIL_FROM)Email sender addressYes

NEXT_PUBLIC_* is bundled into client JS: never store secrets there. The keys without that prefix stay server-side.

Path 1: Vercel (recommended)

This is the path the template is tuned for: zero config, instant rollbacks, edge runtime, preview deployments per branch.

  1. Push to GitHub.
  2. Vercel → Add New Project → Import the repo.
  3. Framework auto-detected as Next.js. Leave defaults.
  4. Add all env vars under Project Settings → Environment Variables (Production, Preview, Development scopes).
  5. Click Deploy. ~60 seconds to first build.
  6. Stripe → Developers → Webhooks → Add endpoint:
    • URL: https://yourdomain.com/api/stripe/webhook
    • Events: checkout.session.completed, customer.subscription.updated, customer.subscription.deleted, customer.subscription.created
    • Copy the signing secret into STRIPE_WEBHOOK_SECRET and redeploy.
  7. Supabase → Auth → URL Configuration:
    • Site URL: https://yourdomain.com
    • Redirect URLs: https://yourdomain.com/auth/callback
  8. Custom domain: Vercel → Project → Domains → add domain → set DNS as instructed.

Path 2: Docker

For environments that need a container (Fly.io, Railway, AWS ECS, your own k8s).

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
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",
  // ... existing config
};

Build and run:

docker build -t saasforge-core \
  --build-arg NEXT_PUBLIC_APP_URL=https://yourdomain.com \
  --build-arg NEXT_PUBLIC_SUPABASE_URL=https://xxx.supabase.co \
  --build-arg NEXT_PUBLIC_SUPABASE_ANON_KEY=... \
  --build-arg NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=... .

docker run -p 3000:3000 \
  -e SUPABASE_SERVICE_ROLE_KEY=... \
  -e STRIPE_SECRET_KEY=... \
  -e STRIPE_WEBHOOK_SECRET=... \
  -e RESEND_API_KEY=... \
  -e RESEND_FROM_EMAIL=noreply@yourdomain.com \
  -e NEXT_PUBLIC_STRIPE_PRO_MONTHLY_PRICE_ID=... \
  saasforge-core

NEXT_PUBLIC_* vars must be present at build time (they get baked into the bundle). Server-only vars can be passed at runtime.

Front the container with a TLS-terminating proxy (Caddy / Nginx / ALB): never expose Node directly.

Path 3: Self-hosted Node (VPS)

For a single VPS (Hetzner, DigitalOcean, etc.):

# On the server
git clone https://github.com/your-org/saasforge-core /opt/saasforge
cd /opt/saasforge
pnpm install --frozen-lockfile
cp .env.local.example .env.local   # fill in production values
pnpm build

Run with PM2 (or systemd):

pnpm add -g pm2
pm2 start "pnpm start" --name saasforge
pm2 save
pm2 startup            # writes the boot script

Reverse proxy with Caddy (/etc/caddy/Caddyfile):

yourdomain.com {
  reverse_proxy localhost:3000
}

Caddy auto-provisions Let's Encrypt certs.

Stripe webhook setup

The webhook handler is at /api/stripe/webhook. Required events:

  • checkout.session.completed: fulfill new subscriptions
  • customer.subscription.created
  • customer.subscription.updated: plan upgrades/downgrades
  • customer.subscription.deleted: cancellations
  • invoice.payment_failed: dunning

Test locally with the Stripe CLI:

stripe listen --forward-to localhost:3000/api/stripe/webhook
stripe trigger checkout.session.completed

Monitoring

ConcernRecommended toolFree tier?
Frontend errorsSentryYes
API/server logsVercel logs (built-in) or Logtail/AxiomYes
Performance / Web VitalsVercel AnalyticsYes (Vercel Pro)
Uptime checksBetter Stack / UptimeRobotYes
Database query insightsSupabase Dashboard → ReportsYes
Stripe webhook deliveriesStripe Dashboard → WebhooksYes

For Sentry, install @sentry/nextjs, run npx @sentry/wizard@latest -i nextjs, set SENTRY_DSN and SENTRY_AUTH_TOKEN in env.

Backup strategy

DataWhereRecovery
PostgresSupabase Daily backups (Pro+)Restore from dashboard; PITR on Pro+
File uploadsSupabase StorageVersioning enabled in bucket settings
StripeStripe-side (always recoverable)n/a
Code + envsGit + Vercel/secret managerRe-deploy from prior commit

For Supabase free-tier: pg_dump on a cron to S3/B2:

pg_dump "$DATABASE_URL" | gzip > "backup-$(date +%F).sql.gz"
aws s3 cp "backup-$(date +%F).sql.gz" s3://my-backups/

Post-deploy verification

Smoke-test in production within 5 minutes of deploy:

  1. Hit /: landing page renders, no console errors
  2. Sign up with a real email → confirmation arrives → confirm
  3. Sign in with OAuth (Google + GitHub if configured)
  4. Create a workspace
  5. Invite a member → invite email arrives
  6. Create a product (or whatever your main entity is)
  7. Subscribe to a paid plan with a Stripe test card (4242 4242 4242 4242)
  8. Check the Customer Portal opens (Settings → Billing → Manage)
  9. Cancel the subscription → webhook fires → status updates in app
  10. Trigger a password reset → reset email arrives
  11. Enable 2FA → scan QR → verify code

If any step fails, check Vercel logs + Supabase logs + Stripe webhook delivery log in that order.

Custom domain

  1. Vercel → Project → Settings → Domains → Add → set DNS records as instructed.
  2. Set NEXT_PUBLIC_APP_URL to the new domain → redeploy.
  3. Supabase Auth → URL Configuration → update Site URL + Redirect URLs.
  4. Stripe webhooks → update endpoint URL → capture new signing secret if required.
  5. Re-test sign-up + checkout end-to-end.

Rollback

  • Vercel: Deployments → previous deployment → Promote to Production (instant).
  • Docker / VPS: git checkout <previous-tag> → rebuild → restart.

Vercel keeps every deployment indefinitely; rollback is risk-free for code. Database migrations are not rolled back automatically: write reverse migrations or be prepared to repair manually if a schema change goes wrong.