Skip to Content
GuidesMulti-environment setup

Multi-environment setup

How to run dev, staging, and production cleanly without your test sign-ins polluting production data or your production OAuth keys leaking onto a laptop.

This guide assumes you have read Orgs and Brands. The decisions below come down to how many Brands you create.

The two patterns

There are exactly two reasonable shapes.

One Brand named after your product (acme). Inside it, three Apps — one per environment, each with its own client_id / client_secret and its own redirect URIs:

Brand: acme ├── App: acme-dev (redirect: http://localhost:3000/api/auth/callback) ├── App: acme-staging (redirect: https://staging.acme.com/api/auth/callback) └── App: acme-prod (redirect: https://acme.com/api/auth/callback)

Pros: one set of theme / logo / connections / consent screens. A change to the login screen ships to all environments at once. Users sign in with the same Google account in dev and prod and get the same sub claim everywhere — convenient.

Cons: dev sign-ins create rows in the same user table as prod. You’ll see dev test users in your prod user list (filter by createdVia). For most teams this is fine.

Pattern B: Separate Brands per environment

A separate Brand per environment:

Brand: acme-dev → App: acme-dev (localhost redirect) Brand: acme-staging → App: acme-staging (staging redirect) Brand: acme → App: acme-prod (prod redirect)

Pros: complete isolation. Dev users do not exist in the prod user table. Required if your dev environment is on a separate backend that shouldn’t see real PII at all (some regulated industries).

Cons: three sets of theme / connections / BYO OAuth credentials to keep in sync. A logo change ships to one Brand at a time. Same human signing into dev and prod sees the consent screen separately in each.

Pick A unless you have a regulatory or PII-isolation reason to need B. Most teams that pick B in advance regret the sync-overhead within a quarter.

Env-var layout

Per app the variables look identical, with different values:

# .env.development # .env.production APP_URL=https://localhost:3000 APP_URL=https://acme.com SYNQ_CLIENT_ID=acme-dev SYNQ_CLIENT_ID=acme-prod SYNQ_CLIENT_SECRET=<dev-secret> SYNQ_CLIENT_SECRET=<prod-secret> SESSION_SECRET=<dev-secret-≥32-chars> SESSION_SECRET=<prod-secret-≥32-chars> SYNQ_ISSUER=https://api.synq.id SYNQ_ISSUER=https://api.synq.id

SYNQ_ISSUER is the same across all environments — Synq’s production issuer is the only issuer. There is no “staging Synq”.

For Vercel previews (per-PR ephemeral URLs), the preview’s redirect URI shape is unpredictable. Two options:

  1. Wildcard redirect URI — Synq supports *.acme.com patterns in the dashboard. Use sparingly; this widens the redirect surface.
  2. Single dedicated preview Appacme-preview with redirect https://*.preview.acme.com/api/auth/callback and a stable wildcard DNS for previews.

We recommend #2 for any team larger than ~3 engineers.

Vercel / per-platform configuration

Vercel

In Vercel project settings → Environment Variables, scope each variable to the environment that needs it:

VariableDevelopmentPreviewProduction
APP_URLhttp://localhost:3000(Vercel auto-sets VERCEL_URL, derive APP_URL from it in next.config.ts)https://acme.com
SYNQ_CLIENT_IDacme-devacme-previewacme-prod
SYNQ_CLIENT_SECRET<dev><preview><prod>
SESSION_SECRETdistinct per envdistinct per envdistinct per env

Don’t share SESSION_SECRET across environments. A leaked dev secret should not let an attacker decrypt prod cookies.

Other platforms

  • AWS Amplify / SST — use SSM Parameter Store; tag parameters with Environment and read by tag in the build step.
  • Docker / Kubernetes — use Secret resources; one Secret per Environment per Namespace.
  • Doppler / 1Password Secrets Automation — create three projects or three configs depending on the tool; do not use a single config with prefixed variables (one typo = leak).

Local development

For localhost work:

  • Use http://localhost:3000 not http://127.0.0.1:3000. Browsers treat these as different origins for cookie storage; the redirect URI must match exactly.
  • next dev --experimental-https is supported by @synqid/nextjs. Pass secure: false only for plain HTTP http://localhost — the SDK defaults to secure: true which is correct for HTTPS dev.
  • Add localhost redirect URIs to the dev App only. Do not add localhost as a redirect on your prod App.

Migrating users between environments

You generally do not migrate users between environments. If you need a specific seed user in staging (e.g. to demo to a customer), sign them up in staging via the same email — Synq treats it as a fresh account.

For load-testing staging with realistic volume, see the @synqid/js testing helpers.

Webhook destinations

Each App has its own webhook configuration. In dev / staging, forward to your local machine via ngrok , localtunnel , or Cloudflare Tunnels .

ngrok http 3000 # → https://abc123.ngrok-free.app # In the Synq dashboard, App acme-dev → Webhooks: # POST https://abc123.ngrok-free.app/api/synq/webhook

For Vercel previews, a per-preview webhook URL is impractical. Two patterns work:

  1. No webhooks in preview — only configure them in prod / staging.
  2. Single preview webhook endpoint at a fixed preview domain that logs and discards (preserves the verify path without acting).

Failure modes to avoid

  • Sharing SYNQ_CLIENT_SECRET across environments — a leaked dev secret signs in to prod.
  • Sharing SESSION_SECRET across environments — a stolen dev cookie decrypts prod sessions.
  • Wildcard redirect URI in production — widens the attack surface for the OAuth redirect dance. Use only for previews.
  • Production App’s webhook pointing at a staging URL — Synq will deliver real events to staging, where they get processed twice (once in staging, never in prod).
  • One BYO Google OAuth project across environments — Google rate-limits per OAuth project. Dev test traffic eats your prod quota. Use distinct projects.

What good looks like

A team running Pattern A across dev / preview / prod with proper isolation:

Synq dashboard └── Brand: acme ├── App: acme-dev │ ├── Client ID: acme-dev │ ├── Client Secret: (rotates quarterly) │ ├── Redirect URIs: http://localhost:3000/api/auth/callback │ ├── Webhooks: → ngrok URL │ └── BYO OAuth: dev Google project, dev Apple Service ID ├── App: acme-preview │ ├── Client ID: acme-preview │ ├── Redirect URIs: https://*.preview.acme.com/api/auth/callback │ └── Webhooks: → preview log-and-discard endpoint └── App: acme-prod ├── Client ID: acme-prod ├── Client Secret: (rotates quarterly, distinct from dev) ├── Redirect URIs: https://acme.com/api/auth/callback ├── Webhooks: → prod backend (HMAC-verified, retried) └── BYO OAuth: prod Google project, prod Apple Service ID

Vercel projects:

acme-prod → env=production → APP_URL=https://acme.com acme-preview → env=preview → APP_URL derived from VERCEL_URL acme-local → no Vercel → APP_URL=http://localhost:3000

Three separate secret stores, three separate OAuth credentials, no shared state. Boring is correct.


Next: Rotate everything — how to swap secrets in any of these environments without a downtime window.

Last updated on