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.
Pattern A: One Brand, multiple Apps (recommended for ≤3 envs)
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.idSYNQ_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:
- Wildcard redirect URI — Synq supports
*.acme.compatterns in the dashboard. Use sparingly; this widens the redirect surface. - Single dedicated preview App —
acme-previewwith redirecthttps://*.preview.acme.com/api/auth/callbackand 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:
| Variable | Development | Preview | Production |
|---|---|---|---|
APP_URL | http://localhost:3000 | (Vercel auto-sets VERCEL_URL, derive APP_URL from it in next.config.ts) | https://acme.com |
SYNQ_CLIENT_ID | acme-dev | acme-preview | acme-prod |
SYNQ_CLIENT_SECRET | <dev> | <preview> | <prod> |
SESSION_SECRET | distinct per env | distinct per env | distinct 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
Environmentand 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:3000nothttp://127.0.0.1:3000. Browsers treat these as different origins for cookie storage; the redirect URI must match exactly. next dev --experimental-httpsis supported by@synqid/nextjs. Passsecure: falseonly for plain HTTPhttp://localhost— the SDK defaults tosecure: truewhich is correct for HTTPS dev.- Add
localhostredirect 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/webhookFor Vercel previews, a per-preview webhook URL is impractical. Two patterns work:
- No webhooks in preview — only configure them in prod / staging.
- 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_SECRETacross environments — a leaked dev secret signs in to prod. - Sharing
SESSION_SECRETacross 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 IDVercel 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:3000Three 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.