Skip to content

Environment Strategy: Production vs. Dev/UAT

Status: Live since 2026-07-08 (NFX-027). This is the as-built architecture, the verified parity status, and the mandatory promotion runbook — not a proposal. Known gaps are listed explicitly in §7, not hidden.

Why this exists

Before 2026-07-08, nefoxx.com, devv.nefoxx.com, and every developer's npm run dev all pointed at the same Supabase project — the live production database. Any local development, manual QA, or future E2E automation ran directly against real user data with no isolation. A migration tested on "dev" was a migration tested on prod, by construction. This was a standing risk with no containment: a bad RLS policy, a destructive test insert, or a runaway cron job during manual testing had a direct path to production data.

Nefoxx now operates two independently managed Supabase environments. They started as an exact schema replica and are kept in parity only by deliberate, tracked promotion — never by assumption.

1. Architecture overview

Design decision — why dev and UAT share one backend instead of three separate projects: UAT is a promotion gate (a human sign-off step between dev and production), not a functionally distinct environment — it runs the same code against the same data shape as dev, just on a different domain and at a later point in the promotion pipeline. Splitting it into a third Supabase project would triple the promotion surface (three targets for every migration/EF/secret instead of two) for no isolation benefit, since UAT and dev were never going to hold different data contracts. Production is the only environment that needs — and gets — full backend isolation.

ProductionDev/UAT
Supabase projectNefoxx-ProdNefoxx-Dev
Project reflyaldbfgdhxpllgxbcxprchglrpywmxkcbmxcepl
RegionSouth Asia (Mumbai)South Asia (Mumbai)
TierProFree (see §7 for the auto-pause implication)
Frontend domain(s)nefoxx.comdevv.nefoxx.com, uatt.nefoxx.com, local npm run dev
Real user dataYesNo — synthetic seed users only
Who can deployManual workflow_dispatch onlydev: automatic on push to develop. UAT: manual workflow_dispatch

2. Deployment & promotion flow

Critical distinction: the frontend Cloudflare Pages pipeline (top) and the Supabase backend promotion (bottom) are two separate pipelines. Deploying frontend code to nefoxx.com does not touch the Nefoxx-Prod database, Edge Functions, or secrets — those are promoted independently, by hand, per §5. A frontend-only PR (UI copy, a component fix) needs no backend promotion step at all; a PR that adds a migration or an Edge Function needs both pipelines exercised.

3. How each domain finds its backend

Nothing in the React app itself branches on environment. src/lib/supabaseClient.js reads two build-time values — VITE_SUPABASE_URL and VITE_SUPABASE_ANON_KEY (plus VITE_SUPABASE_URL_FALLBACK, used only if a network-layer error triggers a retry) — and that's the entire mechanism. Which values get baked in depends on where the build runs:

Build contextSource of VITE_SUPABASE_*Resolves to
Local npm run dev / npm run build.env.local (git-ignored)Nefoxx-Dev
CI deploy.yml, environment_name: devGitHub Environment dev → VariablesNefoxx-Dev
CI deploy.yml, environment_name: uatGitHub Environment uat → VariablesNefoxx-Dev
CI deploy.yml, environment_name: productionGitHub Environment production → VariablesNefoxx-Prod

The reusable deploy.yml job declares environment: from the caller's inputs.environment_name, which is what makes the vars context resolve to a different value per caller (deploy-dev/deploy-uat/ deploy-prod) without any per-caller wiring — GitHub resolves repository variables in the context of whichever Environment the job declares. All three of VITE_SUPABASE_URL, VITE_SUPABASE_URL_FALLBACK, and VITE_SUPABASE_ANON_KEY must be set per Environment — supabaseClient.js falls back to a hardcoded production URL if VITE_SUPABASE_URL is ever unset, and silently retries any network error against VITE_SUPABASE_URL_FALLBACK; setting both URL vars to the same per-environment value means even a fallback retry stays inside that environment.

The Supabase CLI operates on whichever project is currently linked (supabase/.temp/project-ref, git-ignored — reflects only this machine's current state, not a shared setting):

bash
# Day-to-day default (dev/UAT work, safe):
npx supabase link --project-ref rchglrpywmxkcbmxcepl

# Deliberate production promotion only, then switch back immediately after:
npx supabase link --project-ref lyaldbfgdhxpllgxbcxp

npx supabase db push --linked, npx supabase functions deploy <name> --project-ref <ref>, and npx supabase secrets set --project-ref <ref> all accept an explicit --project-ref, which is safer for one-off commands than relying on link state — prefer it when scripting a promotion so the target is explicit in the command itself, not implicit in whatever the CLI happens to be linked to.

4. Configuration changes made during this migration (2026-07-08)

ComponentChange
supabase/migrations/20260708000000_baseline_prod_schema.sqlNew baseline migration (schema dump + auth.users triggers + storage.objects policies, hand-verified against gaps db dump doesn't capture). Proven byte-identical to live prod via db diff --linked → "No schema changes found." Pushed clean to Nefoxx-Dev.
supabase/migrations/_archive/All 210 pre-baseline migrations moved here (history preserved via git mv, never replayed) — see NFX-030 for why the old YYYYMMDD_NNN_ naming collided and forced this squash.
Storage buckets5 buckets replicated with identical config (public/private, size limits, MIME allowlists) — verified byte-for-byte equal, see §6.
Edge FunctionsAll 67 functions deployed to Nefoxx-Dev with per-function verify_jwt matched exactly to production (32 Type B / --no-verify-jwt, 35 Type A).
Cron jobsAll 26 cron.job rows replicated to Nefoxx-Dev, with every net.http_post URL rewritten from lyaldbfgdhxpllgxbcxp.supabase.co to rchglrpywmxkcbmxcepl.supabase.co. Schedules, jobname, and active/inactive flags matched exactly (see follow-up fix below — the first pass missed job names).
Seed data3 synthetic users (*.local emails) via supabase/seed.sql — deliberately not a copy of real production users (see §7 rationale).
.github/workflows/deploy.ymlBuild step now injects VITE_SUPABASE_URL / _FALLBACK / ANON_KEY from the vars context, resolved per-environment_name — see §3.
.env.local (git-ignored)Repointed from production to Nefoxx-Dev for local development.
.env.example (new, tracked)Documents every required env var name with no real values.
.gitignoreAdded supabase/.dev-project-credentials.local, supabase/.dev-secrets.local, supabase/.temp/ (CLI link-state cache — was previously, incorrectly, git-tracked).
CLAUDE.mdNew Architecture — Environment Strategy section (non-negotiable promotion rule); fixed two pieces of now-stale documentation this split exposed — the migration-naming convention (was still describing the exact YYYYMMDD_NNN_ pattern that caused NFX-030) and the Change Manifest Policy section (was still asserting "a single Supabase project shared by dev and prod").

5. Promotion runbook (mandatory)

The non-negotiable rule (also in CLAUDE.md): any change that touches Supabase — migration, Edge Function, RLS policy, storage config, cron job, or secret — must be promoted to Production as an explicit step of shipping that change. Dev/UAT does not sync itself.

ComponentCommandRun against
Migrationnpx supabase db push --linkedBoth — Dev first, then relink and push to Prod
Edge Functionnpx supabase functions deploy <name> --project-ref <ref> [--no-verify-jwt]Both explicit refs — preserve the Type A/B verify_jwt setting exactly
Secretnpx supabase secrets set KEY=value --project-ref <ref>Both, unless deliberately environment-specific (e.g. the Zoom OAuth app's client id/secret legitimately differ between the dev and prod Zoom apps)
Storage bucketDashboard or storage.buckets insert via db query --linkedBoth
Cron jobselect cron.schedule(...) via db query --linkedBoth — the net.http_post URL must point at that project's own ref, never cross-environment
Auth config (Site URL, redirect URLs, providers, JWT expiry)Supabase Dashboard (not CLI-scriptable)Both, deliberately — redirect URLs necessarily differ per domain; document the difference, don't let it happen by accident

Verifying parity after a promotion — the same read-only queries used for the initial audit (§6) work for any future spot-check:

sql
-- Object counts (run against both projects, compare)
select 'tables', count(*) from information_schema.tables where table_schema='public'
union all select 'policies', count(*) from pg_policies where schemaname='public'
union all select 'functions', count(*) from information_schema.routines where routine_schema='public'
union all select 'triggers', count(*) from information_schema.triggers where trigger_schema='public';
bash
npx supabase functions list --project-ref <ref>          # compare function sets
npx supabase secrets list --project-ref <ref>             # names only — values are never retrievable

6. Parity verification (2026-07-08)

A full audit was run using live, read-only queries against both projects (not assumptions carried over from the original replication work — that audit's own claims about cron jobs turned out to be wrong, which is exactly why this was re-verified independently).

ObjectProductionDev (before fix)Dev (after fix)
Tables144144144 ✅
RLS policies192192192 ✅
Triggers868686 ✅
Views181818 ✅
Materialized views212121 ✅
Sequences121212 ✅
Extensions111111 ✅
Functions232233233 (extra is rls_auto_enable, a Supabase-platform-injected event trigger auto-provisioned on newer projects — not application code, benign)
Storage buckets555 ✅ (identical public/private, size limit, MIME config)
Edge Functions deployed67067 ✅ (fixed same day — all deployed with matched verify_jwt)
Secrets configured250Cloudflare + Zoom pending (structural: Supabase never returns secret values, only digests — see §7)
Cron jobs26 (21 active net.http_post, 4 VACUUM, 1 inactive — all named)1 ❌ (a generic unnamed vacuum job that matched none of prod's actual jobs)26 ✅ (exact jobname/schedule/active-flag parity, URLs rewritten to Nefoxx-Dev)

Corrected finding: the original replication work concluded "cron jobs" parity was fine because no migration file contained a net.http_post call. That check only looked at version-controlled migration files. Production's actual cron configuration — 21 of 26 jobs — was set up directly against the live database (SQL editor or equivalent), never captured in a migration at all. This is a real infrastructure-as-code gap on production itself, independent of the dev/prod split, logged separately (NFX-033).

Second-pass correction (same day): the first cron replication also dropped every job's name — production names all 26 jobs (nse-, fetch_nse_market_status, vacuum-analyze-option-chain-daily, etc.), but the replication used the unnamed 2-arg cron.schedule(schedule, command) form, so Nefoxx-Dev showed "No name provided" for all 26 in the Dashboard. This audit's own verification query didn't select jobname either, so it wasn't caught until the user compared Dashboard screenshots directly. Re-fixed: dropped and recreated all 26 with the named 3-arg form, names matched exactly, plus one incidental whitespace-only schedule typo (prod's own pre-existing double space) corrected for full byte-level parity.

7. Known gaps (honest disclosure)

This section exists because a "no known gaps" sign-off is only meaningful if gaps are actively hunted for and disclosed, not asserted by omission.

  • Cloudflare + Zoom secrets — Supabase's secrets list returns only a digest, never the value; there is no API path to "copy" a secret from one project to another. These must be supplied fresh for Nefoxx-Dev. Tracked as part of NFX-027 until set.
  • Auth configuration parity (Site URL, redirect URLs, email templates, JWT expiry) — not stored in queryable Postgres tables on hosted Supabase (dashboard/Management-API-only config), so it wasn't verified via the SQL-based audit in §6. Needs a manual Dashboard comparison between the two projects, or a deliberate follow-up session with Management API access.
  • Google OAuth on Nefoxx-Dev — not configured yet; explicitly deferred as the user's own action item (NFX-031). Email/password auth is fully functional on dev in the meantime.
  • SHOONYA_API_KEY / PASSWORD / USER_ID / INSTRUMENT_SYMBOL — confirmed stale/unused even on production (the only consumer, shoonya-nse-indices, isn't cron-scheduled anywhere). Deliberately not replicated to dev. Recommend removing from production as cleanup — not actioned here, since removing a production secret wasn't in scope for this review.
  • Cron jobs living outside version control — see §6's corrected finding. Production's 21 net.http_post cron jobs are already named and correctly configured (and now match on Nefoxx-Dev too, after the second-pass fix) — the remaining gap is purely that neither project's cron configuration exists in a migration file, so it can't be code-reviewed, diffed, or restored from version control. A future migration that captures the already-named cron.schedule() calls as idempotent, version-controlled statements would close this gap — not done here, since writing and applying a new migration to production was outside this review's read/verify-then-fix scope.
  • Nefoxx-Dev is Free tier — auto-pauses after 7 days of inactivity. A keep-warm mechanism (scheduled no-op ping) is designed but not yet built; until then, a long-idle dev/UAT environment may need a manual wake (any Dashboard visit or API call un-pauses it).
  • GitHub Environment variable values — confirmed set by the user for dev/uat/production, but not independently verifiable from this session (no gh CLI available on this machine, and GitHub Environment secrets/variables are never readable back via the web UI either, by GitHub's own design). Structural wiring (deploy.yml resolving vars.* per environment:) is verified correct; the actual values are trust-but-not-independently-checked.
  • No live GitHub Actions run has exercised the new per-environment wiring end-to-end yet. The workflow YAML is verified syntactically and structurally correct, and the underlying Supabase parity is now verified independently of it, but an actual deploy-dev / deploy-uat / deploy-prod run post-dating this change hasn't been observed. Recommend triggering one real deploy per environment as a live confirmation at the next natural release point.

8. Operational guidelines & maintenance

  • Default CLI link state is Nefoxx-Dev. Never leave a long-running local session linked to Production. Relink deliberately, promote, relink back.
  • A migration/EF/secret change is not "done" at Dev. Per §5, the Production promotion step is part of shipping the change, not a follow-up task — track it in the same PR/session, don't close the loop later "when there's time."
  • Never point one environment's cron job at another environment's Edge Function URL. Confirmed safe today (dev's cron rows are fully separate from prod's), but this is the one mistake in this whole design that would create a live cross-environment side effect rather than just visible breakage — double-check the domain in any net.http_post URL before running cron.schedule().
  • Seed data stays synthetic. Do not import real production users into Nefoxx-Dev for convenience — see the rationale in the original NFX-027 discussion (data-hygiene isn't the concern; cron/webhook consumers reacting to real-looking rows, and public unauthenticated pages like /shared/plan/:token being crawlable, are).
  • Any new Edge Function must be deployed to both projects with the same verify_jwt setting. A mismatch (e.g. Type B on prod, accidentally Type A on dev) silently breaks cron-triggered dev testing with an auth error that has nothing to do with the function's actual logic.

Bulk activate / deactivate cron jobs

Two equivalent ways to run these — pick whichever fits the moment. Both use cron.alter_job(), pg_cron's supported API (not a raw UPDATE cron.job) — the same pattern already verified working in this session for notifications-fanout-zoom-classes.

  • CLI (npx supabase db query --linked "<sql>") — runs against whichever project is currently linked; check with cat supabase/.temp/project-ref first, especially before the deactivate-all one.
  • SQL Editor (Supabase Dashboard → SQL Editor, paste and run) — runs against whichever project you have open in the Dashboard; check the project switcher top-left (Nefoxx-Prod vs Nefoxx-Dev) before running, same caution as the CLI link state.

Check current status of every job:

sql
select jobname, schedule, active
from cron.job
order by jobname;
bash
npx supabase db query --linked "select jobname, schedule, active from cron.job order by jobname;"

Deactivate ALL 26 jobs (e.g. pausing Nefoxx-Dev entirely — no market-data churn, no housekeeping — while it sits idle for a stretch):

sql
select cron.alter_job(jobid, active := false)
from cron.job;
bash
npx supabase db query --linked "select cron.alter_job(jobid, active := false) from cron.job;"

Reactivate ALL 26 jobs:

sql
select cron.alter_job(jobid, active := true)
from cron.job;
bash
npx supabase db query --linked "select cron.alter_job(jobid, active := true) from cron.job;"

Deactivate a specific named subset — worked example: the 18 external NSE market-data fetch jobs (the ones worth pausing to cut external API calls), leaving the 8 internal housekeeping jobs running:

sql
select cron.alter_job(jobid, active := false)
from cron.job
where jobname = any(array[
  'nse-',
  'fetch_nse_market_status',
  'fetch-nse-market-indices',
  'fetch-nse-oi-spurts',
  'fetch-nse-all-stocks-traded',
  'fetch-nifty50-contributors',
  'fetch-banknifty-contributors',
  'fetch-finserv-contributors',
  'fetch-midcap-select-contributors',
  'fetch-nifty-next50-contributors',
  'fetch-nse-most-active-equities-by-value',
  'fetch-nse-most-active-equities-by-volume',
  'fetch-nse-volume-gainers',
  'fetch-ipo-current-issue',
  'fetch-nifty500-contributors',
  'fetch-nse-all-indices',
  'fetch-option-chain-live',
  'fetch-nse-trading-holidays'
]);
bash
npx supabase db query --linked "select cron.alter_job(jobid, active := false) from cron.job where jobname = any(array[
  'nse-','fetch_nse_market_status','fetch-nse-market-indices','fetch-nse-oi-spurts',
  'fetch-nse-all-stocks-traded','fetch-nifty50-contributors','fetch-banknifty-contributors',
  'fetch-finserv-contributors','fetch-midcap-select-contributors','fetch-nifty-next50-contributors',
  'fetch-nse-most-active-equities-by-value','fetch-nse-most-active-equities-by-volume',
  'fetch-nse-volume-gainers','fetch-ipo-current-issue','fetch-nifty500-contributors',
  'fetch-nse-all-indices','fetch-option-chain-live','fetch-nse-trading-holidays'
]);"

Swap active := false for active := true (both SQL and CLI forms) to reactivate the same subset. Swap the array contents for any other subset — the full 26 names, grouped, for reference:

GroupJob names
NSE market-data fetch (external HTTP, 18 jobs)nse-, fetch_nse_market_status, fetch-nse-market-indices, fetch-nse-oi-spurts, fetch-nse-all-stocks-traded, fetch-nifty50-contributors, fetch-banknifty-contributors, fetch-finserv-contributors, fetch-midcap-select-contributors, fetch-nifty-next50-contributors, fetch-nse-most-active-equities-by-value, fetch-nse-most-active-equities-by-volume, fetch-nse-volume-gainers, fetch-ipo-current-issue, fetch-nifty500-contributors, fetch-nse-all-indices, fetch-option-chain-live, fetch-nse-trading-holidays
Internal housekeeping — EF-based (2 jobs)refresh-materialized-views, daily-scheduled-clean-up
Internal housekeeping — direct VACUUM (4 jobs)post-cleanup-vacuum, vacuum-analyze-nse-all-stocks-daily, vacuum-analyze-option-chain-daily, vacuum-analyze-nifty500-contributors-daily
FinFluencify live classes (2 jobs)zoom-classes-finfluencify-reminder-sweep, notifications-fanout-zoom-classes (already inactive by design on both projects)

Single job by name:

sql
select cron.alter_job(
  (select jobid from cron.job where jobname = 'fetch-option-chain-live'),
  active := false
);
bash
npx supabase db query --linked "select cron.alter_job((select jobid from cron.job where jobname = 'fetch-option-chain-live'), active := false);"

Running this against Production

These queries work identically against Nefoxx-Prod. Via SQL Editor: switch the project selector top-left to Nefoxx-Prod first. Via CLI: relink first (npx supabase link --project-ref lyaldbfgdhxpllgxbcxp) and relink back to Dev immediately after (see §3). Deactivating production's market-data cron jobs stops OI Pulse / Index Radar / Market Mood from receiving fresh data platform-wide — treat the deactivate-all query against Production as a deliberate incident-response action, not a routine one.

9. Future considerations

  • Automate backend promotion (auto-deploy to non-prod, manual prod promotion) — backend-ci.yml now exists and validates PRs (migration replay + pgTAP, EF tests/type-check), but it doesn't deploy anything yet. See Backend CI/CD, Phases D–F, not yet built.
  • Design and implement the Nefoxx-Dev keep-warm mechanism before the Free-tier auto-pause becomes a recurring workflow interruption.
  • Once NFX-031 (Google OAuth) and the Cloudflare/Zoom secrets land, re-run the §6 audit and update this doc's status table.
  • Revisit whether UAT should ever get its own Supabase project — the current shared-with-dev design (§1) is a deliberate simplicity trade-off, not a permanent architectural constraint; reconsider if UAT ever needs data isolation from day-to-day dev work (e.g. a formal UAT sign-off period where dev activity would otherwise contaminate the data under test).
  • Capture production's cron jobs as version-controlled, named, idempotent migrations (closes the infra-as-code gap noted in §6 for good, on both projects).