Dev, staging and prod from one instancez.yaml file
Promote one instancez.yaml from dev to staging to prod: per-environment values and secrets from env vars, migration previews, a drop guard and typed clients.
With instancez, dev, staging and production all run the same instancez.yaml. The file doesn’t change between environments. Anything that does differ, like allowed origins, API keys, database URLs and function secrets, comes from environment variables, which the file references as ${INSTANCEZ_ENV_*}. Each environment applies the same file to its own Postgres.
This post walks one small project from a laptop to staging to production, using only the inz CLI. We ran every command here against inz dev --embedded-pg and two throwaway Postgres containers standing in for staging and prod.
What changes between environments, and what doesn’t?
| Same in every environment (in the file) | Different per environment (in env vars) |
|---|---|
| Tables, columns, constraints, indexes | INSTANCEZ_DATABASE_URL (or the owner/auth DSN pair) |
| RLS policies on tables and buckets | INSTANCEZ_PUBLISHABLE_KEY, INSTANCEZ_SECRET_KEY |
Auth settings, buckets, rpc, functions | Origins, redirect URLs, anything else you parameterize |
References like ${INSTANCEZ_ENV_WEBHOOK_SECRET} | The secret values those references point to |
The left column goes through code review. The right column never lands in git.
How do I put per-environment values in the file?
Here’s the project. It has one table, one function that needs a secret, and an app origin that differs per environment:
version: 1
project:
name: notes
server:
cors:
origins: ["${INSTANCEZ_ENV_APP_ORIGIN:-http://localhost:5173}"]
auth:
allow_signup: true
redirect_urls: ["${INSTANCEZ_ENV_APP_ORIGIN:-http://localhost:5173}"]
tables:
notes:
rls_enabled: true
fields:
- name: id
type: bigserial
primary_key: true
- name: user_id
required: true
foreign_key:
references: auth.users.id
on_delete: cascade
- name: body
type: text
required: true
- name: pinned
type: boolean
required: true
default: false
rls:
- operations: [select, insert, update, delete]
using: "user_id = auth.uid()"
with_check: "user_id = auth.uid()"
functions:
whoami:
runtime: node
file: functions/whoami.js
auth_required: true
env:
WEBHOOK_SECRET: ${INSTANCEZ_ENV_WEBHOOK_SECRET}
There are two forms of reference:
${INSTANCEZ_ENV_APP_ORIGIN:-http://localhost:5173}has a default. Dev getshttp://localhost:5173without setting anything, and staging and prod override it.${INSTANCEZ_ENV_WEBHOOK_SECRET}has no default. If it’s missing,inz validatefails withmissing required environment variables: [INSTANCEZ_ENV_WEBHOOK_SECRET], so a deploy without the secret stops before it starts.
References have to use the INSTANCEZ_ENV_ prefix. If you write ${APP_ORIGIN}, inz validate rejects it and suggests the fix:
✗ Error: ${APP_ORIGIN}
environment variable "APP_ORIGIN" may not be referenced in config; only the INSTANCEZ_ENV_ namespace is allowed
Suggestion: Rename it to INSTANCEZ_ENV_APP_ORIGIN so it lives in the user namespace.
The function reads its secret as ctx.env.WEBHOOK_SECRET. Only INSTANCEZ_ENV_* variables are forwarded to function workers, and they don’t go through the worker’s process environment.
Where do the values come from in each environment?
| Environment | Command | Where the env comes from |
|---|---|---|
| Dev | inz dev | .development.env, plus your shell |
| Staging, prod (plain config file) | inz serve --migrate | Process env, plus .production.env next to the YAML |
| Staging, prod (bundle) | inz serve --bundle ... --migrate | Process env (Docker, Kubernetes secrets, CI) |
Shell variables always win over .production.env. We checked this by putting https://app.example.com in the file and starting the server with INSTANCEZ_ENV_APP_ORIGIN=https://override.example.com in the shell. The CORS header came back as the override. inz init adds both .development.env and .production.env to .gitignore.
Step 1: run it in dev
echo 'INSTANCEZ_ENV_WEBHOOK_SECRET=dev-only-secret' > .development.env
inz dev --embedded-pg
--embedded-pg starts a local Postgres, so you don’t need Docker or a Postgres install (more on that here). On first run, inz dev generates a publishable key and a secret key and appends them to .development.env. A preflight request from http://localhost:5173 gets Access-Control-Allow-Origin: http://localhost:5173, the default from the file. The function reports that its secret is set.
Iterate here. Save the YAML and the schema change applies right away. The one-file backend post covers that loop, the dashboard and the rest of the file format.
Step 2: check the file in CI
All three checks read only the YAML, so CI doesn’t need a database:
- run: inz validate
env:
INSTANCEZ_ENV_WEBHOOK_SECRET: ci-placeholder
- run: inz vet --fail-on medium
- run: inz gen types | diff - database.types.ts
inz validate checks structure and references. Because it also checks for missing variables, give the job a placeholder for each required one. inz vet lints for open RLS policies, disabled RLS, hardcoded secrets and unsafe auth or CORS settings, and the RLS post goes into its rules. The last line fails the build if someone changed the schema without regenerating database.types.ts.
The types check matters for the frontend too. They come from the same file every environment runs, so a client typed against them matches dev, staging and prod alike:
import { createClient } from '@supabase/supabase-js'
import type { Database } from './database.types'
const supabase = createClient<Database>(import.meta.env.VITE_API_URL, import.meta.env.VITE_PUBLISHABLE_KEY)
Only the URL and key change per environment. inz gen types needs no secrets, so it runs even when the referenced INSTANCEZ_ENV_* variables aren’t set.
Step 3: preview what staging will run
Before you touch staging, ask for the migration plan against its database:
inz validate --use-dsn "$STAGING_DATABASE_URL"
This connects, diffs the YAML against the live schema and prints the SQL without applying any of it. Say the change removes the pinned column. The plan includes:
ALTER TABLE notes DROP COLUMN IF EXISTS pinned;
The plan also re-states the idempotent grants and auth helpers that every boot ensures, so | grep -i drop is a quick way to spot what would be lost.
Step 4: build once, deploy to staging, then prod
A project with code functions ships as a bundle: instancez.yaml plus functions/ with vendored node_modules, in one archive.
inz bundle --output bundle.tar.gz
inz bundle runs the same validation, so the variables the file references need to be set in the shell that builds it. The archive stores the ${...} references, not their values. We opened it to check: the instancez.yaml inside still reads WEBHOOK_SECRET: ${INSTANCEZ_ENV_WEBHOOK_SECRET}. That means one artifact can go to every environment.
Staging:
INSTANCEZ_DATABASE_URL="$STAGING_DATABASE_URL" \
INSTANCEZ_PUBLISHABLE_KEY="$STAGING_PUBLISHABLE_KEY" \
INSTANCEZ_SECRET_KEY="$STAGING_SECRET_KEY" \
INSTANCEZ_ENV_APP_ORIGIN=https://staging.example.com \
INSTANCEZ_ENV_WEBHOOK_SECRET="$STAGING_WEBHOOK_SECRET" \
inz serve --bundle bundle.tar.gz --migrate
The preflight from https://staging.example.com got that origin back, sign-up and inserts worked through supabase-js, and the function saw its staging secret. Production is the same command with production values, from whatever your platform uses to inject env. With a project that has no code functions, you can skip the bundle, keep a .production.env next to the YAML and run inz serve --migrate.
What stops a bad change on the way?
Removing pinned is destructive, and inz serve won’t apply it on its own. When we started staging with the new bundle, the server kept running on the last applied schema and logged this (message fields only):
destructive change blocked; applied additive provisioning only
this change drops notes.pinned.
If you meant to rename it, declare the old name with renamed_from: so the data moves instead of being dropped.
To drop it for real, pass --allow-destructive (or set INSTANCEZ_ALLOW_DESTRUCTIVE=true)
So the drop has to be an explicit decision for each environment. Pass --allow-destructive on staging once you’re happy with it, then again on prod. If it was really a rename, add renamed_from: and the data moves instead. The migrations post covers renames and the migration lock.
The other guard is the dashboard. inz serve runs with the dashboard off by default. With --dashboard readonly, a config write is refused:
{"error":"dashboard_readonly","message":"This deployment is GitOps-managed. To change the configuration, update the source and redeploy."}
Staging and prod stay on the reviewed file, and nobody can click a schema change into prod that git never saw.
What about instancez Cloud?
On instancez Cloud, each project has a single deployed version with no draft stage, so staging is a separate cloud project. The docs recommend leaving project_id out of the YAML and passing --project per job, so both jobs deploy the exact same file. From the Cloud deploy docs:
# Pull requests: deploy to a dev project for review.
- name: Deploy to dev project
if: github.event_name == 'pull_request'
run: inz cloud deploy --project "$DEV_PROJECT_ID" --yes
env:
INSTANCEZ_CLOUD_PAT: ${{ secrets.INSTANCEZ_CLOUD_PAT }}
DEV_PROJECT_ID: ${{ secrets.INSTANCEZ_DEV_PROJECT_ID }}
# main: deploy to the production project. --yes skips the confirmation
# prompt, since the runner has no terminal to answer it anyway.
- name: Deploy to production project
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
run: inz cloud deploy --project "$PROD_PROJECT_ID" --yes
env:
INSTANCEZ_CLOUD_PAT: ${{ secrets.INSTANCEZ_CLOUD_PAT }}
PROD_PROJECT_ID: ${{ secrets.INSTANCEZ_PROD_PROJECT_ID }}
inz cloud deploy shows a diff and asks for confirmation unless you pass --yes. inz validate --project <id> previews a change against a cloud project without deploying it. The cloud runs the same open-source runtime as inz serve.
If you self-host, the same file runs on a VPS, in Docker or on Kubernetes (self-hosting post, guide) or on AWS Lambda.
What this doesn’t give you
There’s no database branching yet, so a preview environment is a real database (or a real cloud project) that you create and seed yourself. Data doesn’t move between environments either. The file promotes the schema, not the rows. If you’re coming from Supabase, the compare page lists the remaining differences, including no Realtime.
FAQ
Can I use a .staging.env file?
inz serve auto-loads .production.env only, and only when the config is a plain local file. For staging, put staging values in that server’s .production.env, or inject them from your platform. Process env always wins.
Do secrets end up in the bundle?
No. The bundle keeps the ${INSTANCEZ_ENV_*} references, and the server resolves them from its environment at load time. The config reference documents the ${VAR:-default} syntax.
How do I see what prod will run before deploying?
Run inz validate --use-dsn with prod’s owner DSN. It prints the plan and applies nothing. The environment variables reference lists every setting you can change per environment.