Skip to content
Operate And Ship

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 read

On This Page

What You Deploy

PieceRole
apps/webProduct app on port 3000. Needs DATABASE_URL and BETTER_AUTH_SECRET.
apps/docsFumadocs site on port 3001. No database. Optional, but shipped with the kit.
MigratorOne-shot prisma migrate deploy before each release (release hook or explicit step).
PostgresYou 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.

TimeVariablesWhere they go
Build-timeNEXT_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_HOSTVercel env (available at build), Docker --build-arg, Fly [build.args], Render envVars
RuntimeDATABASE_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 catalogVercel 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

TargetMigration handlingMulti-app patternBuilt-in CDNConfig in repo
VercelExplicit step (pnpm db:migrate:deploy before promote)Two Vercel projects (one per app)Yesapps/web/vercel.json, apps/docs/vercel.json
Fly.ioRelease hook (release_command in fly.toml)Two Fly apps via two fly.toml filesYes (Fly Edge)fly.toml, apps/docs/fly.toml
RenderPre-deploy hook (preDeployCommand)Two Render services via blueprintYesrender.yaml
Self-hosted DockerManual (docker compose run --rm migrator)Three services in one compose fileNodocker-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.yml is a manual workflow (workflow_dispatch only). It is not triggered by push or merge. In GitHub: Actions → Migrate Database → Run workflow → pick staging or production. It runs scripts/deploy/migrate-database.sh against that environment's DATABASE_URL secret.
  • Or run pnpm db:migrate:deploy locally against the production DATABASE_URL before 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-deploy

Fly 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.toml

Docs 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-web

Add 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    # docs

For 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 -d

migrator 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) after pnpm install.
  • Apply the same build-time vs runtime env split.
  • Run migrations from CI before deploy. scripts/deploy/migrate-database.sh works on any Linux host with Node and the packages/database workspace.

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

Also useful: Environment Variables, Webhooks And Async Workflows, and Setup.

Was this page helpful?

On this page