Deployment
Deploy apps/web and apps/docs with Next.js standalone output. Run prisma migrate deploy before the new version takes traffic. Configs ship for Vercel, Fly.io, Render, and Docker Compose.
Last updated on
7 min readOn This Page
What You Deploy
| Piece | Role |
|---|---|
| apps/web | Product app on port 3000. Needs DATABASE_URL and BETTER_AUTH_SECRET. |
| apps/docs | Fumadocs site on port 3001. No database. Optional, but shipped with the kit. |
| Migrator | One-shot prisma migrate deploy before each release (release hook or explicit step). |
| Postgres | You provide it (Neon, Supabase, RDS, or any Postgres). Compose does not include one. |
Both apps build with Next.js output: "standalone", so any Node or Docker host can run them.
Build-Time vs Runtime Env
Hosts treat two kinds of variables differently. Build-time values are inlined into the JS bundle. Runtime values are read by the server after start. Mixing them up is why a "changed" env var looks stuck until you rebuild.
| Time | Variables | Where they go |
|---|---|---|
| Build-time | NEXT_PUBLIC_APP_URL, NEXT_PUBLIC_DOCS_URL, NEXT_PUBLIC_S3_PUBLIC_URL, NEXT_PUBLIC_S3_BUCKET_NAME_IMAGES, NEXT_PUBLIC_TURNSTILE_SITE_KEY, NEXT_PUBLIC_POSTHOG_KEY, NEXT_PUBLIC_POSTHOG_HOST, NEXT_PUBLIC_POSTHOG_UI_HOST | Vercel env (available at build), Docker --build-arg, Fly [build.args], Render envVars |
| Runtime | DATABASE_URL, BETTER_AUTH_SECRET, BETTER_AUTH_URL, OAuth client IDs/secrets, Stripe secrets and price IDs, email provider credentials, TURNSTILE_SECRET_KEY, S3 credentials, UPSTASH_REDIS_REST_URL, UPSTASH_REDIS_REST_TOKEN, NEXT_SERVER_ACTIONS_ENCRYPTION_KEY, and the rest of the server catalog | Vercel runtime env, Docker environment:, Fly secrets, Render envVars |
Full names live in Environment Variables. The build-time list matches deploy/public-build-args.txt in the kit.
Changing a NEXT_PUBLIC_* value requires a rebuild on every host. Stripe price IDs (STRIPE_PRICE_ID_*) are runtime, so rotating them does not need a rebuild.
In production, missing required runtime config does not crash the process. The app serves a configuration-error page and /api/health returns 503. On Fly and Render that fails the health check, so a bad release does not take traffic. Production always needs Upstash (UPSTASH_REDIS_REST_URL + UPSTASH_REDIS_REST_TOKEN) and a real email mode (plunk, resend, postmark, brevo, sendgrid, or smtp) with credentials and EMAIL_FROM. log / noop and DISABLE_ABUSE_PROTECTION are rejected in production.
Picking A Target
| Target | Migration handling | Multi-app pattern | Built-in CDN | Config in repo |
|---|---|---|---|---|
| Vercel | Explicit step (pnpm db:migrate:deploy before promote) | Two Vercel projects (one per app) | Yes | apps/web/vercel.json, apps/docs/vercel.json |
| Fly.io | Release hook (release_command in fly.toml) | Two Fly apps via two fly.toml files | Yes (Fly Edge) | fly.toml, apps/docs/fly.toml |
| Render | Pre-deploy hook (preDeployCommand) | Two Render services via blueprint | Yes | render.yaml |
| Self-hosted Docker | Manual (docker compose run --rm migrator) | Three services in one compose file | No | docker-compose.yml, .env.docker.example |
Netlify, Railway, Heroku, Coolify, and other generic Next.js hosts use the same shape as Vercel. Cloudflare Workers and Pages are on the roadmap, not shipped.
Deploy To Your Target
Pick a platform and expand its runbook. Vercel is the shortest path. The others trade convenience for more control.
Vercel: the fastest path
Vercel auto-detects Next.js. The shipped vercel.json files set workspace-aware install and build commands.
Create two Vercel projects
One with root directory apps/web, one with apps/docs. Filters differ per app.
apps/web/vercel.json:
{
"buildCommand": "cd ../.. && pnpm build --filter=@syntaxkit/web",
"installCommand": "pnpm install",
"framework": "nextjs"
}apps/docs/vercel.json:
{
"buildCommand": "cd ../.. && pnpm build --filter=@syntaxkit/docs",
"installCommand": "pnpm install",
"framework": "nextjs"
}Vercel installs the workspace once and builds only the targeted package.
Add env vars in the dashboard
Put both NEXT_PUBLIC_* and server secrets in each project's Environment Variables panel. Vercel exposes the right ones at build vs runtime.
Run migrations before each promote
Vercel has no release hooks, so migrations run outside the platform. Order is migrate first, then let the new deploy take traffic.
- The shipped
.github/workflows/migrate.ymlis a manual workflow (workflow_dispatchonly). It is not triggered by push or merge. In GitHub: Actions → Migrate Database → Run workflow → pickstagingorproduction. It runsscripts/deploy/migrate-database.shagainst that environment'sDATABASE_URLsecret. - Or run
pnpm db:migrate:deploylocally against the productionDATABASE_URLbefore you promote.
Prefer expand/contract migrations so the old app version still works against the new schema while traffic flips.
Push to your default branch
Vercel builds and promotes both projects. First deploy is often 4–6 minutes; later deploys usually hit the build cache and finish in 1–2.
Both vercel.json files set "framework": "nextjs", so Vercel picks the runtime, output mode, and routing after the projects exist. No manual project-settings tweaks needed for a standard deploy.
Fly.io
Two Fly apps, one per Next.js app, both built from apps/Dockerfile. The web app's release hook runs migrations.
Both shipped fly.toml files use placeholders (your-app-web, your-app-docs, empty NEXT_PUBLIC_*). Replace app names, URLs, and public build args before fly launch. That keeps a fresh deploy from inheriting another project's IDs.
Launch the web app
From the repo root:
fly launch --copy-config --no-deployFly reads fly.toml, which already sets the Dockerfile path, build args, primary region (iad), VM size, and:
[deploy]
release_command = '/bin/sh /release/scripts/deploy/migrate-database.sh'Launch the docs app
Still from the repo root (not apps/docs/). The Dockerfile copies workspace files from the root, so the build context must be the root:
fly launch --copy-config --no-deploy -c apps/docs/fly.tomlDocs has no migrator and no Postgres. It listens on port 3001.
Set runtime secrets
NEXT_PUBLIC_* build args live in fly.toml under [build.args]. Runtime secrets use fly secrets:
fly secrets set \
DATABASE_URL="postgresql://..." \
BETTER_AUTH_SECRET="$(openssl rand -base64 32)" \
UPSTASH_REDIS_REST_URL="https://..." \
UPSTASH_REDIS_REST_TOKEN="..." \
EMAIL_DELIVERY_MODE="resend" \
RESEND_API_KEY="re_..." \
EMAIL_FROM="App <noreply@example.com>" \
--app your-app-webAdd Stripe, OAuth, storage, and other feature secrets when you use those features.
Deploy
From the repo root:
fly deploy # web (./fly.toml)
fly deploy -c apps/docs/fly.toml # docsFor web, Fly builds the image, runs release_command (migrate-database.sh), then starts the new machine. Roll-forward happens only if the release command exits 0. Docs has no release command.
Point NEXT_PUBLIC_DOCS_URL at your public docs origin (the web fly.toml placeholder uses https://your-app-docs.fly.dev/docs). Health checks probe /api/health. TRUST_PROXY_HEADERS=true is already set in the web fly.toml because Fly terminates TLS at the edge.
Render
Render reads render.yaml at the repo root and provisions both services. Migrations run as preDeployCommand on every release.
Push the repo to GitHub
The blueprint defines syntaxkit-web and syntaxkit-docs, their Dockerfiles, build filters, and env declarations.
Deploy the blueprint
Open https://dashboard.render.com/select-repo?type=blueprint, connect the repo, and Render detects render.yaml.
Fill in runtime secrets when prompted
Secrets use sync: false so they stay out of source control. BETTER_AUTH_SECRET and NEXT_SERVER_ACTIONS_ENCRYPTION_KEY use generateValue: true on first deploy.
Push to deploy
Render runs preDeployCommand: /bin/sh /release/scripts/deploy/migrate-database.sh, then deploys. Build filters rebuild a service when its paths change (apps/web/**, apps/docs/**, shared packages, lockfile, Dockerfile).
TRUST_PROXY_HEADERS=true and TRUSTED_PROXY_IP_HEADERS=x-forwarded-for are already set in render.yaml so Better Auth sees real client IPs behind Render's load balancer.
Self-hosted Docker
Root docker-compose.yml defines three services: one-shot migrator (tools profile), web, and docs. You bring Postgres.
cp .env.docker.example .env
docker compose build
docker compose run --rm migrator
docker compose up -dmigrator is on the tools profile, so up does not run it. Invoke it before each release. Healthchecks use /api/health for web and / for docs (same readiness idea as Fly and Render). Web has a 30-second stop_grace_period so Next.js after() callbacks can finish before SIGTERM.
Pre-built images on GHCR. .github/workflows/docker.yml publishes on every push to main and every semver tag:
ghcr.io/<owner>/<repo>/web:latest(also:<sha>,:<branch>,:<version>)ghcr.io/<owner>/<repo>/docs:latest
Use these on Kubernetes, Coolify, Dokku, Nomad, or any Docker host. Set runtime env, then migrate with the migrator stage (docker build --target migrator ...) or scripts/deploy/migrate-database.sh in a job that has Prisma and packages/database.
NEXT_PUBLIC_* values are baked at image build time from CI variables. Empty Actions variables produce an image with empty public URLs. Changing a public var means republishing the image.
Netlify and other Next.js hosts
The kit ships no netlify.toml. Netlify, Railway, Heroku, Coolify, and similar hosts follow the Vercel shape:
- Build with
pnpm build --filter=@syntaxkit/web(or--filter=@syntaxkit/docs) afterpnpm install. - Apply the same build-time vs runtime env split.
- Run migrations from CI before deploy.
scripts/deploy/migrate-database.shworks on any Linux host with Node and thepackages/databaseworkspace.
Each host has its own config surface (netlify.toml, dashboard settings, and so on). Use that host's Next.js guide for the exact syntax.
Cloudflare (roadmap)
Cloudflare Workers and Pages are on the roadmap, not shipped. The Edge runtime still needs kit changes (Prisma adapter such as @prisma/adapter-d1, edge-safe auth sessions, and dropping output: "standalone"). Until then, use Vercel, Fly.io, Render, or Docker.
Operational Notes
Details that apply on every target.
Multi-instance deploys need NEXT_SERVER_ACTIONS_ENCRYPTION_KEY
Generate with openssl rand -base64 32 and set the same value on every replica. Without a shared key, a server-action payload signed on replica A fails to decrypt on replica B. Render's blueprint generates this for you.
Behind a load balancer, trust the forwarded headers
Set TRUST_PROXY_HEADERS=true and TRUSTED_PROXY_IP_HEADERS to the header your proxy sets (often x-forwarded-for). Better Auth uses these for real client IPs in rate limits and audit logs.
Fly's web fly.toml already sets TRUST_PROXY_HEADERS=true. Set TRUSTED_PROXY_IP_HEADERS via secrets if you need IP-based limits. Render's blueprint sets both. On Docker Compose, set them only when a trusted proxy in front overwrites those headers. Do not enable them on a directly exposed app; clients can spoof the headers.
When BETTER_AUTH_URL is required
Only when Better Auth must use a different origin from NEXT_PUBLIC_APP_URL. Most deploys leave it unset so Better Auth derives the base URL from the public app URL.
Where To Go Next
Going To Production
Pre-launch checklist: hardening, smoke tests, observability, and rollout.
Database
How prisma migrate deploy works and what the migrator script runs.
Also useful: Environment Variables, Webhooks And Async Workflows, and Setup.
Customization
Retheme with OKLCH tokens in one CSS file, swap fonts and dark mode, extend shadcn primitives in packages/ui, and drive homepage branding from brand and marketing config.
Going To Production
Pre-launch checklist for product-specific work (brand, copy, legal, live Stripe, email, OAuth). Infrastructure is wired; each category links to its subsystem page rather than duplicating it.
