Why staging users can never reach production: isolated environments for auth

Environments in Nulvless are separate stores, not a column. Here is what that rules out, and what moves between them instead.

Gleisson MatosFounder & CEO3 min read
On this page

Most products model environments as a column. Users, sessions and settings sit in the same tables with an environment_id next to them, and every query remembers to filter by it. That works until one query forgets.

In Nulvless, an environment is a full copy of the project, with its own users, sessions, identities, providers, templates and keys. This post is about why we chose that, what it costs, and what moves between environments instead.

A column is a promise every query has to keep

When environments share storage, isolation is a property of your code, not of your data. A missing filter in a support script, an admin endpoint that joins on the wrong key, or a cache keyed only by user id are enough to show a test account in production or send a real customer an email from staging.

These bugs are rare, and that is what makes them expensive. They do not show up in tests, because tests run in one environment at a time.

A separate store makes the bug impossible

Each environment answers on its own issuer URL:

organization
└── project · acme
    ├── environment · main      → acme.nulvless.co
    ├── environment · staging   → acme-staging.nulvless.co
    └── environment · develop   → acme-develop.nulvless.co

A user created in develop does not exist in production, and never will, because they were never written to that store. There is no filter to forget.

The issuer URL is also the first argument to createClient, so pointing an app at another environment is a URL change and a key change. Keys are per environment too: a publishable key from staging is not accepted by production.

What moves: configuration, as a diff

Data never moves between environments. Configuration does, but only when you promote it, and a promotion starts as a diff, not a write.

The Compare screen takes an origin and a destination and lists every setting that differs: sign-up rules, sign-in methods, password policy, MFA, tokens, social providers, sudo mode, attack protection, the OAuth server, rate limits and notifications. Each row has the destination's current value and the incoming one. Reading that list is the review.

Underneath, the diff is two sections, auth and communications, each a list of field, before and after. The grouping you see in the console is only presentation. The unit that moves is one settings field.

What a promotion deliberately leaves behind

Some things are never carried by a merge, and none of these are errors:

  • Credentials and secrets. Provider API keys, SMTP passwords and OAuth client secrets are configured by hand in the destination. A staging key should never become a production key by accident.
  • Users, sessions and identities. Promotion moves settings, never data.
  • Edge functions. A function belongs to the project, not to an environment, so it has no place in an environment diff.

The first one is the one that surprises people. A provider can be promoted as enabled while its credentials are still missing in the destination, and then it fails at delivery time rather than at merge time. That is why the Compare screen warns about it, and why the recommended flow configures credentials once, before the first promotion that needs them.

Applying it

Merging is protected by sudo mode: confirming asks the person to prove they are still at the keyboard, and that confirmation lasts an hour, so a sequence of promotions asks once.

The merge then runs as a pipeline and produces a deployment, with each step timed: initialise, authentication, communications. A typical run finishes in about three seconds.

A merge cannot be undone, so the history matters. Every merge records who requested it, a timestamped event log and the exact before and after rows that were reviewed. Months later, that snapshot answers "who changed this, and what was it before" without anyone having written it down.

The cost, and why we accept it

Separate stores are not free. A new environment starts empty: no users, no providers, none of the configuration you built elsewhere. That is the point, and also the first surprise for most teams.

We think that trade is right for identity. A shared table saves a few clicks at setup and leaves a class of incident open forever. A separate store costs a deliberate promotion, and in exchange the worst environment bug becomes one that cannot happen.

The shape we recommend is simple: change things in develop, compare into staging and read the diff, then promote into main last. If the diff gets large, promote more often. The environments guide covers each step in detail.

Keep reading

Engineering5 min read

Introducing Edge Functions on the Nulvless Network

What edge functions are, how they run your TypeScript in the region closest to each user, and when to use them instead of single-region serverless.

Gleisson Matos