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_*.sqlthrough006_*.sql) -
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 - 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 buildsucceeds locally with production env vars
Required environment variables
| Var | Where it's used | Server-only? |
|---|---|---|
NEXT_PUBLIC_APP_URL | Canonical URLs, emails, 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 (webhooks, admin tasks) | 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_PRO_MONTHLY_PRICE_ID | Pro plan checkout | No |
NEXT_PUBLIC_STRIPE_PRO_YEARLY_PRICE_ID | Pro plan checkout | No |
NEXT_PUBLIC_STRIPE_ENTERPRISE_MONTHLY_PRICE_ID | Enterprise plan checkout | No |
NEXT_PUBLIC_STRIPE_ENTERPRISE_YEARLY_PRICE_ID | Enterprise plan checkout | No |
RESEND_API_KEY | Email sending | Yes |
RESEND_FROM_EMAIL (or EMAIL_FROM) | Email sender address | Yes |
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.
- 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. ~60 seconds to first build.
- 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_SECRETand redeploy.
- URL:
- Supabase → Auth → URL Configuration:
- Site URL:
https://yourdomain.com - Redirect URLs:
https://yourdomain.com/auth/callback
- Site URL:
- 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 subscriptionscustomer.subscription.createdcustomer.subscription.updated: plan upgrades/downgradescustomer.subscription.deleted: cancellationsinvoice.payment_failed: dunning
Test locally with the Stripe CLI:
stripe listen --forward-to localhost:3000/api/stripe/webhook
stripe trigger checkout.session.completed
Monitoring
| Concern | Recommended tool | Free tier? |
|---|---|---|
| Frontend errors | Sentry | Yes |
| API/server logs | Vercel logs (built-in) or Logtail/Axiom | Yes |
| Performance / Web Vitals | Vercel Analytics | Yes (Vercel Pro) |
| Uptime checks | Better Stack / UptimeRobot | Yes |
| Database query insights | Supabase Dashboard → Reports | Yes |
| Stripe webhook deliveries | Stripe Dashboard → Webhooks | Yes |
For Sentry, install @sentry/nextjs, run npx @sentry/wizard@latest -i nextjs, set SENTRY_DSN and SENTRY_AUTH_TOKEN in env.
Backup strategy
| Data | Where | Recovery |
|---|---|---|
| Postgres | Supabase Daily backups (Pro+) | Restore from dashboard; PITR on Pro+ |
| File uploads | Supabase Storage | Versioning enabled in bucket settings |
| Stripe | Stripe-side (always recoverable) | n/a |
| Code + envs | Git + Vercel/secret manager | Re-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:
- Hit
/: landing page renders, no console errors - Sign up with a real email → confirmation arrives → confirm
- Sign in with OAuth (Google + GitHub if configured)
- Create a workspace
- Invite a member → invite email arrives
- Create a product (or whatever your main entity is)
- Subscribe to a paid plan with a Stripe test card (
4242 4242 4242 4242) - Check the Customer Portal opens (Settings → Billing → Manage)
- Cancel the subscription → webhook fires → status updates in app
- Trigger a password reset → reset email arrives
- Enable 2FA → scan QR → verify code
If any step fails, check Vercel logs + Supabase logs + Stripe webhook delivery log in that order.
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 (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.