-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathllms-small.txt
More file actions
133 lines (67 loc) · 154 KB
/
Copy pathllms-small.txt
File metadata and controls
133 lines (67 loc) · 154 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
<SYSTEM>This is the abridged developer documentation for instancez</SYSTEM>
# CLI Reference
> All inz subcommands with flags and examples.
```plaintext inz [command] [flags] ``` ## inz init [Section titled “inz init”](#inz-init) Scaffold a new instancez project in the current directory. Writes `instancez.yaml`, a `.development.env.example`, and optional boilerplate. Never touches a database. The example code function is scaffolded only when Node.js 22+ is on your PATH; otherwise init warns and omits the `functions:` block. ```plaintext inz init [name] [flags] ``` | Flag | Default | Description | | --------- | ------- | ------------------------------------- | | `--dir` | `.` | Output directory. | | `--force` | `false` | Overwrite existing scaffolding files. | ```bash inz init my-app --dir ./my-app ``` ## inz dev [Section titled “inz dev”](#inz-dev) Start a local development server with hot-reload. Reads config, connects to Postgres, runs migrations, and watches for file changes. Requires `INSTANCEZ_DATABASE_URL` (a superuser DSN) to provision roles on every startup, or set `INSTANCEZ_OWNER_DATABASE_URL` and `INSTANCEZ_AUTH_DATABASE_URL` directly. ```plaintext inz dev [flags] ``` | Flag | Default | Description | | -------------------------- | ----------------------- | --------------------------------------------------------------------------- | | `--config` | `instancez.yaml` | Config source: file path or `s3://bucket/key`. Env: `INSTANCEZ_CONFIG`. | | `--dashboard` | `readwrite` | Dashboard mode: `disabled`, `readonly`, or `readwrite`. | | `--dashboard-write-dotenv` | `true` | Allow the dashboard to write secrets to `.development.env`. | | `--dotenv-path` | `.development.env` | Path to the .env file for dashboard secret writing. | | `--embedded-pg` | `false` | Start an embedded Postgres 16 (data at `./pgdata/`); no external DB needed. | | `--no-watch` | `false` | Disable hot-reload. | | `--port` | (from config or `8080`) | Override server port. | | `--reset-pg` | `false` | Wipe `./pgdata/` before starting (requires `--embedded-pg`). | | `--verbose` | `false` | Enable debug logging. | | `--watch` | `true` | Watch the config source for changes. | | `--watch-interval` | `1m` | S3-watch poll interval (minimum 10s). | ```bash INSTANCEZ_DATABASE_URL=postgres://postgres:postgres@localhost:5432/postgres inz dev ``` ## inz serve [Section titled “inz serve”](#inz-serve) Start the production server. Unlike `dev`, does not hot-reload and defaults to dashboard disabled. ```plaintext inz serve [flags] ``` | Flag | Default | Description | | -------------------------- | ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `--allow-destructive` | `false` | Permit `DROP TABLE` and `DROP COLUMN` during migration. Without it, `inz serve` refuses to apply a plan that drops a table or column and reports what would have been lost. `inz dev` always permits drops and logs each one. Env: `INSTANCEZ_ALLOW_DESTRUCTIVE`. | | `--bundle` | — | Bundle pointer: file path or `s3://bucket/key[#version]`. When set, reads config and functions from the bundle archive instead of `--config`. Env: `INSTANCEZ_BUNDLE`. | | `--config` | `instancez.yaml` | Config source: file path or `s3://bucket/key`. Ignored when `--bundle` is set. Env: `INSTANCEZ_CONFIG`. | | `--dashboard` | `disabled` | Dashboard mode. Env: `INSTANCEZ_DASHBOARD`. | | `--dashboard-write-dotenv` | `false` | Allow dashboard to write secrets to a .env file. Env: `INSTANCEZ_DASHBOARD_WRITE_DOTENV`. | | `--dotenv-path` | — | Path to .env file when `--dashboard-write-dotenv` is set. Env: `INSTANCEZ_DOTENV_PATH`. | | `--migrate` | `false` | Run pending migrations on startup. | | `--port` | (from config or `8080`) | Override server port. | | `--watch` | `false` | Watch the config source for changes. In bundle mode, polls the bundle ETag (S3) or mtime (local) instead of the config file. Env: `INSTANCEZ_WATCH`. | | `--watch-interval` | `1m` | S3-watch poll interval. Env: `INSTANCEZ_WATCH_INTERVAL`. | ```bash inz serve --migrate --config instancez.yaml # Bundle mode: config + functions come from a single archive (no race condition) inz serve --bundle s3://my-bucket/bundles/app.tar.gz --migrate --watch inz serve --bundle /path/to/bundle.tar.gz --migrate ``` ## inz validate [Section titled “inz validate”](#inz-validate) Validate `instancez.yaml` structure and references without starting the server. Checks YAML structure, identifiers, cross-references, and verifies that each declared code function’s `file:` exists on disk. With `--use-dsn`, also connects to the database and prints the migration plan (DDL diff) without applying it. ```plaintext inz validate [flags] ``` | Flag | Default | Description | | ----------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `--config` | `instancez.yaml` | Config source. Env: `INSTANCEZ_CONFIG`. | | `--json` | `false` | Output errors as JSON (for CI). | | `--project` | — | Preview against a cloud project. Bare `--project` uses `instancez.yaml`’s linked project; `--project <id>` or `--project=<id>` targets a different one. Never creates a project; link one first with `inz cloud deploy --new`. | | `--use-dsn` | — | After syntax check, plan a migration against this owner-class DSN (plan only — never applied). | ```bash inz validate inz validate --use-dsn postgres://owner:pass@localhost/mydb inz validate --project # preview against instancez.yaml's linked project inz validate --project abc123 # preview against a specific project id ``` ## inz bundle [Section titled “inz bundle”](#inz-bundle) Build a self-contained tar.gz bundle from `instancez.yaml` and `functions/`. The bundle is the deployment artifact for projects that use code functions. It contains `instancez.yaml`, `functions/` (with vendored `node_modules/`), and a `manifest.json`. Upload it to S3 then set `functions_bundle:` in `instancez.yaml` to the returned pointer. Runs stateless validation (same as `inz validate`) including checking that each declared function’s `file:` exists on disk. ```plaintext inz bundle [flags] ``` | Flag | Default | Description | | ---------- | ---------------- | --------------------------------------------------------------------------------------------------------- | | `--config` | `instancez.yaml` | Path to `instancez.yaml`. | | `--output` | — | Destination: local file path or `s3://bucket/key`. If omitted, writes to a temp file and prints the path. | ```bash inz bundle # write temp file, print path inz bundle --output bundle.tar.gz # write to local file inz bundle --output s3://my-bucket/bundle.tar.gz # upload to S3, print pointer ``` ## inz cloud deploy [Section titled “inz cloud deploy”](#inz-cloud-deploy) Write the current `instancez.yaml` to an instancez Cloud project. Shows a diff of what would change and prompts for confirmation before writing. If no project is linked, pass `--new` to create one (after local validation passes) or `--project <id>` to target an existing one without editing the yaml. ```plaintext inz cloud deploy [flags] ``` | Flag | Default | Description | | ------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------ | | `--config` | `instancez.yaml` | Path to `instancez.yaml`. | | `--new` | `false` | Create a new instancez Cloud project when none is linked yet (only after local validation passes). | | `--project` | — | Target this cloud project id for this run, instead of `instancez.yaml`’s `project.cloud.project_id`. Does not modify the file. | | `--yes`, `-y` | `false` | Skip the deploy confirmation prompt. | ```bash inz cloud deploy --new # first deploy: create + link + push inz cloud deploy --project abc123 # target a specific project without editing the yaml inz cloud deploy --yes # skip the confirmation prompt (e.g. in CI) ``` ## inz doctor [Section titled “inz doctor”](#inz-doctor) Run preflight checks for `inz dev`: config validity, the superuser database DSN, and Postgres role layout. Exits non-zero if any check fails. ```plaintext inz doctor [flags] ``` | Flag | Default | Description | | ---------- | ---------------- | ------------------------- | | `--config` | `instancez.yaml` | Path to `instancez.yaml`. | ```bash inz doctor ``` ## inz cloud status [Section titled “inz cloud status”](#inz-cloud-status) Show the linked cloud project’s current state: name, ID, URL, and deploy status. Requires a linked project (`inz cloud deploy --new` links one). ```plaintext inz cloud status [flags] ``` | Flag | Default | Description | | ---------- | ---------------- | ------------------------- | | `--config` | `instancez.yaml` | Path to `instancez.yaml`. | ```bash inz cloud status ``` ## inz cloud login [Section titled “inz cloud login”](#inz-cloud-login) Authenticate against instancez Cloud via device-code flow. Opens a browser to confirm a one-time code, then stores a Personal Access Token at `~/.instancez/credentials`. ```plaintext inz cloud login [flags] ``` | Flag | Default | Description | | --------- | ------- | ------------------------------------------ | | `--force` | `false` | Re-authenticate even if already logged in. | ```bash inz cloud login ``` ## inz cloud logout [Section titled “inz cloud logout”](#inz-cloud-logout) Remove the PAT stored at `~/.instancez/credentials`. The token remains valid server-side until revoked from the dashboard. ```plaintext inz cloud logout ``` ## inz cloud whoami [Section titled “inz cloud whoami”](#inz-cloud-whoami) Print the currently logged-in instancez Cloud user. ```plaintext inz cloud whoami ``` ## inz version [Section titled “inz version”](#inz-version) Print the binary version. ```plaintext inz version ```
# Configuration
> Complete instancez.yaml schema reference. Every key, type, default, and example.
`instancez.yaml` is the single source of truth for your project. On boot, the server diffs it against the live database and applies migrations automatically. Env vars are interpolated using `${VAR}` or `${VAR:-default}`. They are resolved at load time; references are never stored in the database. ## version [Section titled “version”](#version) | Key | Type | Required | Description | | --------- | --------- | -------- | ------------------------------ | | `version` | `integer` | yes | Schema version. Currently `1`. | ## project [Section titled “project”](#project) | Key | Type | Default | Description | | -------------------------- | -------- | ------- | ------------------------------------------------------------------------------------------------- | | `project.name` | `string` | — | Display name shown in the dashboard. | | `project.description` | `string` | — | Optional project description. | | `project.cloud.project_id` | `string` | — | Cloud project ID. Written automatically by `inz cloud deploy --new`; not meant to be hand-edited. | ## database [Section titled “database”](#database) | Key | Type | Default | Description | | ---------------------------- | ---------- | ------- | -------------------------------------------------- | | `database.pool.max` | `integer` | `20` | Maximum connections in the request pool. | | `database.pool.min` | `integer` | `5` | Minimum idle connections. | | `database.pool.idle_timeout` | `duration` | `300s` | How long idle connections are held before closing. | ## server [Section titled “server”](#server) | Key | Type | Default | Description | | ---------------------- | --------- | ------- | ------------------------------------------------------------------------------------------------------------------------ | | `server.port` | `integer` | `8080` | HTTP listen port. | | `server.max_body_size` | `string` | `1MB` | Maximum request body size for non-upload endpoints. | | `server.max_limit` | `integer` | `100` | **Not currently enforced.** Configuration value is defined but not validated on REST queries. Default query limit is 20. | ### server.cors [Section titled “server.cors”](#servercors) Methods, headers, credentials, and preflight caching are fixed platform defaults — origins is the only knob, matching Supabase’s own gateway. | Key | Type | Default | Description | | --------------------- | ---------- | ------- | ------------------------------------------ | | `server.cors.origins` | `string[]` | `[]` | Allowed origins. Use `["*"]` to allow all. | ### server.timeouts [Section titled “server.timeouts”](#servertimeouts) | Key | Type | Default | Description | | -------------------------- | ---------- | ------- | ---------------------------------- | | `server.timeouts.request` | `duration` | `30s` | Per-request deadline. | | `server.timeouts.db_query` | `duration` | `10s` | Per-query deadline. | | `server.timeouts.upload` | `duration` | `5m` | Deadline for file upload requests. | | `server.timeouts.shutdown` | `duration` | `30s` | Graceful shutdown window. | Duration strings use Go format: `30s`, `5m`, `1h`. ## providers [Section titled “providers”](#providers) ### providers.email [Section titled “providers.email”](#providersemail) | Key | Type | Default | Description | | ------------------------------------ | -------- | ------- | ----------------------------------------------------- | | `providers.email.type` | `string` | — | Email provider type. Currently `"resend"` or `"ses"`. | | `providers.email.api_key` | `string` | — | Provider API key. Supports `${VAR}`. | | `providers.email.default_from_email` | `string` | — | Default sender address. | Set `providers.email: null` to disable email sending. ### providers.storage [Section titled “providers.storage”](#providersstorage) | Key | Type | Default | Description | | ------------------------------------- | -------- | ------- | --------------------------------------------------------- | | `providers.storage.type` | `string` | — | `"local"` or `"s3"`. | | `providers.storage.path` | `string` | — | (`local` only) Directory for local file storage. | | `providers.storage.bucket` | `string` | — | (`s3` only) S3 bucket name. | | `providers.storage.region` | `string` | — | (`s3` only) AWS region. | | `providers.storage.access_key_id` | `string` | — | (`s3` only) AWS access key. Supports `${VAR}`. | | `providers.storage.secret_access_key` | `string` | — | (`s3` only) AWS secret key. Supports `${VAR}`. | | `providers.storage.endpoint` | `string` | — | (`s3` only) Custom endpoint URL for S3-compatible stores. | ## auth [Section titled “auth”](#auth) Omit the `auth:` block entirely to disable authentication endpoints. | Key | Type | Default | Description | | --------------------------- | ---------- | ------------------------------- | -------------------------------------------------------------------------------------------------------------------- | | `auth.jwt_expiry` | `duration` | `15m` (when `auth:` is present) | Access token lifetime. Default applies only when `auth:` block is declared but `jwt_expiry` is not. | | `auth.refresh_tokens` | `boolean` | `false` | Enable refresh token issuance. | | `auth.refresh_token_expiry` | `duration` | `7d` | Refresh token lifetime (only used when `refresh_tokens: true`). | | `auth.allow_signup` | `boolean` | `true` | Allow public `POST /auth/v1/signup`. Set to `false` for invite-only. | | `auth.allow_anonymous` | `boolean` | `true` | Allow anonymous sign-in (empty-body signup). | | `auth.redirect_urls` | `string[]` | `[]` | Allowlist of origins for post-auth redirects (OAuth, email verification). The server’s own origin is always allowed. | ### auth.email [Section titled “auth.email”](#authemail) | Key | Type | Default | Description | | --------------------------------------- | --------- | ------- | --------------------------------------------------------------------------------------------- | | `auth.email.verify_email` | `boolean` | `false` | Require email verification before the user can sign in. Requires a configured email provider. | | `auth.email.templates` | `map` | — | Override built-in email templates by name (e.g. `confirm`, `recovery`). | | `auth.email.templates.<name>.subject` | `string` | — | Email subject line. | | `auth.email.templates.<name>.body` | `string` | — | Inline HTML/text body. | | `auth.email.templates.<name>.body_file` | `string` | — | Path to a file containing the body (alternative to `body`). | ### auth.oauth.\<name> [Section titled “auth.oauth.\<name>”](#authoauthname) OAuth provider configuration. Providers are keyed by name under `auth.oauth`; the name (`google`, `github`, …) selects the built-in provider implementation. | Key | Type | Default | Description | | --------------------------------- | -------- | ------- | ------------------------------------------------ | | `auth.oauth.<name>.client_id` | `string` | — | OAuth client ID. Supports `${VAR}`. | | `auth.oauth.<name>.client_secret` | `string` | — | OAuth client secret. Supports `${VAR}`. | | `auth.oauth.<name>.redirect_url` | `string` | — | OAuth callback URL registered with the provider. | ## tables [Section titled “tables”](#tables) Tables map to Postgres tables in the `public` schema by default. The migrator diffs this block against the live database on each boot. ```yaml tables: posts: schema: public # optional; default "public" fields: - name: id type: bigserial primary_key: true - ... indexes: - ... rls: - ... ``` ### tables.\<name>.fields [Section titled “tables.\<name>.fields”](#tablesnamefields) | Key | Type | Default | Description | | --------------------------------- | ---------- | ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `fields[].name` | `string` | required | Column name. | | `fields[].type` | `string` | required | Postgres type (e.g. `text`, `bigint`, `uuid`, `timestamptz`, `text[]`). | | `fields[].primary_key` | `boolean` | `false` | Mark as primary key. | | `fields[].required` | `boolean` | `false` | Add `NOT NULL` constraint. | | `fields[].unique` | `boolean` | `false` | Add `UNIQUE` constraint. | | `fields[].default` | `any` | — | Column default. Supported: literal values, `now()`, `current_date`, `current_time`. The shorthand `uuid_v7()` and `uuid_v4()` are normalized to `gen_random_uuid()`. | | `fields[].enum` | `string[]` | — | Restrict values to this list (creates a `CHECK` constraint). | | `fields[].pattern` | `string` | — | Regex pattern for a `CHECK` constraint. | | `fields[].min` | `number` | — | Minimum numeric value (inclusive). | | `fields[].max` | `number` | — | Maximum numeric value (inclusive). | | `fields[].check` | `string` | — | Raw SQL `CHECK` expression. | | `fields[].foreign_key.references` | `string` | — | `table.column` or `schema.table.column`. | | `fields[].foreign_key.on_delete` | `string` | `restrict` | `cascade`, `restrict`, or `set_null`. Defaults to `restrict` when omitted. | | `fields[].ref` | `string` | — | Storage reference in the form `storage.<bucket>`. | | `fields[].on_delete` | `string` | — | (`ref` only) `cascade` or `keep` — whether to delete the object on row deletion. | No columns are injected automatically. Every column, including primary keys, must be declared. ### tables.\<name>.indexes [Section titled “tables.\<name>.indexes”](#tablesnameindexes) | Key | Type | Default | Description | | ------------------- | ---------- | -------- | ----------------------------------------- | | `indexes[].columns` | `string[]` | required | Columns to index. | | `indexes[].unique` | `boolean` | `false` | Create a unique index. | | `indexes[].where` | `string` | — | Partial index condition (SQL expression). | ### tables.\<name>.rls [Section titled “tables.\<name>.rls”](#tablesnamerls) RLS is the only authorization layer. Declare policies here; instancez applies `ENABLE ROW LEVEL SECURITY` and creates the policies automatically. | Key | Type | Default | Description | | ------------------ | ---------- | ------------ | ------------------------------------------------------ | | `rls[].operations` | `string[]` | required | One or more of `select`, `insert`, `update`, `delete`. | | `rls[].check` | `string` | required | SQL boolean expression evaluated per row. | | `rls[].type` | `string` | `permissive` | `permissive` or `restrictive`. | Useful SQL helpers available in RLS expressions: * `auth.uid()` — UUID of the authenticated user (`null` for anonymous). * `auth.is_authenticated()` — true when the request carries a valid user JWT. ## storage [Section titled “storage”](#storage) Bucket definitions. Buckets cannot be created, modified, or deleted at runtime; only `instancez.yaml` changes take effect. ```yaml storage: avatars: public: false max_size: 5MB types: - image/png - image/jpeg rls: - operations: [select] using: "true" ``` | Key | Type | Default | Description | | ------------------------- | ------------- | ------- | ----------------------------------------------------------------------------------------- | | `storage.<name>.public` | `boolean` | `false` | Allow unauthenticated downloads via `/storage/v1/object/public/...`. | | `storage.<name>.max_size` | `string` | `50MB` | Maximum file size per upload (e.g. `5MB`, `1GB`). | | `storage.<name>.types` | `string[]` | `[]` | Allowed MIME types. Wildcards like `image/*` are accepted. Empty means all types allowed. | | `storage.<name>.rls` | `RLSPolicy[]` | `[]` | Same policy shape as table RLS, applied to `storage.objects`. | ## rpc [Section titled “rpc”](#rpc) Postgres stored procedures exposed at `/rest/v1/rpc/<name>`. ```yaml rpc: search_posts: description: Full-text search auth_required: false language: plpgsql # sql | plpgsql (default: plpgsql) volatility: stable # volatile | stable | immutable (default: volatile) security: invoker # invoker | definer (default: invoker) args: - name: query type: text required: true returns: type: "setof posts" body: | SELECT * FROM posts WHERE body @@ plainto_tsquery(query); ``` | Key | Type | Default | Description | | ---------------------------- | --------- | ---------- | ------------------------------------------------------------------------------------- | | `rpc.<name>.description` | `string` | — | Documentation string. | | `rpc.<name>.auth_required` | `boolean` | `false` | Reject unauthenticated callers. | | `rpc.<name>.language` | `string` | `plpgsql` | `sql` or `plpgsql`. | | `rpc.<name>.volatility` | `string` | `volatile` | `volatile`, `stable`, or `immutable`. Only stable/immutable can be called with `GET`. | | `rpc.<name>.security` | `string` | `invoker` | `invoker` or `definer`. | | `rpc.<name>.args[].name` | `string` | required | Argument name. | | `rpc.<name>.args[].type` | `string` | required | Postgres type. | | `rpc.<name>.args[].required` | `boolean` | `false` | Return 400 if argument is absent. | | `rpc.<name>.args[].default` | `any` | — | Postgres DEFAULT value for optional args. | | `rpc.<name>.returns.type` | `string` | — | Return type: `void`, a scalar type, or `setof <table>`. | | `rpc.<name>.body` | `string` | required | Function body (PL/pgSQL or SQL). | ## functions [Section titled “functions”](#functions) JavaScript code functions served at `/functions/v1/<name>`. Distinct from `rpc:` (which declares Postgres stored procedures). ```yaml functions: send_notification: runtime: node file: functions/send_notification.js auth_required: true timeout: 15s env: WEBHOOK_URL: https://hooks.example.com/notify API_KEY: ${INSTANCEZ_ENV_NOTIFY_API_KEY} ``` | Key | Type | Default | Description | | -------------------------------- | ---------- | -------- | --------------------------------------------------------------------------------------------------- | | `functions.<name>.runtime` | `string` | required | `"node"`. | | `functions.<name>.file` | `string` | required | Path to the JS handler file, relative to the config root. | | `functions.<name>.auth_required` | `boolean` | `false` | Require a valid JWT. Returns 401 otherwise. | | `functions.<name>.timeout` | `duration` | `30s` | Per-request timeout. Exceeded requests return 504. | | `functions.<name>.env` | `map` | `{}` | Env values injected as `ctx.env`. Values may be string literals or `${INSTANCEZ_ENV_*}` references. | `INSTANCEZ_ENV_*` references are resolved from the process environment. Plain `${VAR}` references (without the `INSTANCEZ_ENV_` prefix) are not supported in `env:` — use them elsewhere in the config file for other values. ## functions\_bundle [Section titled “functions\_bundle”](#functions_bundle) Bundle pointer for self-hosted deployments. `inz bundle` produces the value (e.g. `s3://bucket/key#sha256`) and writes it here; the managed cloud stamps it server-side from uploaded sources. `inz cloud deploy` does not write this field. *** ## Full example [Section titled “Full example”](#full-example) ```yaml version: 1 project: name: My App description: Example instancez project server: port: 8080 max_body_size: 5MB max_limit: 500 cors: origins: ["https://app.example.com"] timeouts: request: 30s db_query: 10s upload: 5m shutdown: 30s database: pool: max: 20 min: 5 idle_timeout: 300s providers: email: type: resend api_key: ${RESEND_API_KEY} default_from_email: noreply@example.com storage: type: s3 bucket: my-app-storage region: us-east-1 access_key_id: ${AWS_ACCESS_KEY_ID} secret_access_key: ${AWS_SECRET_ACCESS_KEY} auth: jwt_expiry: 1h refresh_tokens: true refresh_token_expiry: 30d allow_signup: true allow_anonymous: false redirect_urls: - https://app.example.com email: verify_email: true tables: profiles: fields: - name: id foreign_key: references: auth.users.id on_delete: cascade primary_key: true - name: display_name type: text - name: avatar_url type: text ref: storage.avatars on_delete: keep rls: - operations: [select] using: "true" - operations: [insert] with_check: "auth.uid() = id" - operations: [update] using: "auth.uid() = id" with_check: "auth.uid() = id" storage: avatars: public: true max_size: 2MB types: [image/png, image/jpeg, image/webp] rls: - operations: [insert] with_check: "auth.uid() IS NOT NULL" - operations: [delete] using: "auth.uid() IS NOT NULL" rpc: profile_search: language: sql volatility: stable args: - name: q type: text required: true returns: type: "setof profiles" body: | SELECT * FROM profiles WHERE display_name ILIKE '%' || q || '%'; functions: resize_avatar: runtime: node file: functions/resize_avatar.js auth_required: true timeout: 20s env: MAX_WIDTH: "512" ```
# SQL Functions
> Call Postgres stored procedures at /rest/v1/rpc/<name>.
`rpc:` declares Postgres stored procedures called via HTTP. This is distinct from `functions:`, which declares JavaScript [code functions](/instancez/build/functions/) served at `/functions/v1/<name>`. ## Declaring [Section titled “Declaring”](#declaring) ```yaml rpc: team_stats: description: Get team statistics auth_required: true language: sql volatility: stable # stable | volatile | immutable security: invoker # invoker | definer args: - name: team_id type: bigint required: true - name: limit_rows type: integer default: 10 returns: type: record # scalar type, record, setof <table>, void, etc. body: | SELECT count(*) AS total FROM todos WHERE team_id = team_stats.team_id ``` | Field | Required | Description | | ----------------- | -------- | ---------------------------------------------- | | `language` | no | `sql` or `plpgsql` (default: `plpgsql`) | | `volatility` | no | `volatile` (default), `stable`, or `immutable` | | `security` | no | `invoker` (default) or `definer` | | `auth_required` | no | Reject unauthenticated callers when `true` | | `args` | no | Ordered list of named arguments | | `args[].required` | no | Return 400 if the argument is absent | | `args[].default` | no | Postgres DEFAULT value for optional args | | `returns.type` | yes | Return type; `void` emits 204 No Content | ## Calling via HTTP [Section titled “Calling via HTTP”](#calling-via-http) ```http POST /rest/v1/rpc/team_stats Content-Type: application/json Authorization: Bearer <jwt> {"team_id": 42} ``` Stable and immutable functions may also be called with `GET`: ```plaintext GET /rest/v1/rpc/team_stats?team_id=42 ``` Volatile functions reject `GET` with HTTP 405. Pass the entire body as a single `jsonb` argument using `Prefer: params=single-object`: ```http POST /rest/v1/rpc/process_payload Prefer: params=single-object Content-Type: application/json {"key": "value", "nested": {"x": 1}} ``` ## Calling via a Supabase client [Section titled “Calling via a Supabase client”](#calling-via-a-supabase-client) ```ts const { data, error } = await supabase.rpc('team_stats', { team_id: 42 }) ``` For setof functions, filter/order/limit can be chained: ```ts const { data, error } = await supabase .rpc('search_todos', { query: 'milk' }) .eq('done', false) .order('created_at', { ascending: false }) .limit(10) ``` ## Arguments [Section titled “Arguments”](#arguments) Arguments are passed as a JSON object in the request body (POST) or as query parameters (GET). Each key must match a declared arg name exactly; unknown keys are rejected with HTTP 400. Values are passed to Postgres as typed bind parameters — they are never concatenated into SQL. Required arguments (`required: true`) that are missing cause a 400 error. Optional arguments with a `default` receive the Postgres DEFAULT when omitted. ## Roles [Section titled “Roles”](#roles) The function runs under the same Postgres role as any other request: `anon` for anonymous, `authenticated` for signed-in users, `service_role` for admin-key requests. RLS applies inside the function body unless `security: definer` is set, in which case the function runs as its owner and must manage access explicitly.
# Auth
> Password, magic link, OTP, OAuth, anonymous sign-in, and TOTP MFA — all wired to Postgres RLS.
## Configuration [Section titled “Configuration”](#configuration) The `auth:` block in `instancez.yaml` controls JWT lifetime, refresh tokens, sign-up permissions, and OAuth providers: ```yaml auth: jwt_expiry: 1h refresh_tokens: true refresh_token_expiry: 7d # Set to false to disable public sign-up (the secret key can still create users) allow_signup: true # Set to false to block anonymous sign-in allow_anonymous: true # Allowlist of frontend origins that post-auth flows (OAuth, magic link, # password recovery) may redirect the user's browser back to. See "OAuth # (Google, GitHub)" below for how this differs from oauth.<name>.redirect_url. redirect_urls: - https://myapp.example.com email: # When true, signup emails must be confirmed before a session is issued. # Requires an email provider under providers.email. verify_email: false # OAuth providers are keyed by name under oauth. The name (google, github, …) # selects the built-in provider implementation. oauth: google: client_id: YOUR_GOOGLE_CLIENT_ID client_secret: ${INSTANCEZ_ENV_GOOGLE_CLIENT_SECRET} redirect_url: https://api.myapp.example.com/auth/v1/callback/google github: client_id: YOUR_GITHUB_CLIENT_ID client_secret: ${INSTANCEZ_ENV_GITHUB_CLIENT_SECRET} redirect_url: https://api.myapp.example.com/auth/v1/callback/github ``` All keys are optional. Omit `auth:` entirely and JWT auth still works with the defaults (15m expiry, no refresh tokens, sign-up open). **With `refresh_tokens` off, `supabase-js` reports `session: null` even on success.** The `/auth/v1/signup` and `/auth/v1/token` responses still carry a valid `access_token`, but `supabase-js`’s client-side check for “is there a session” requires `access_token`, `refresh_token`, and `expires_in` all to be present. No `refresh_token` means `data.session` comes back `null` from `signUp()` / `signInWithPassword()`, even though the token itself works. Set `refresh_tokens: true` if you want the SDK to actually see a session. The dashboard’s **Auth** page edits these too: the Registration toggles map to `allow_signup` / `allow_anonymous`, and the Redirect URLs list maps to `redirect_urls`. When sign-up is off, the anonymous toggle is disabled, since anonymous sign-in is blocked along with it. ## Auth methods [Section titled “Auth methods”](#auth-methods) instancez exposes the same auth API as Supabase, so any Supabase client library works. The examples below use `@supabase/supabase-js` — the same client the integration tests run against — but the Python, Swift, Flutter, and other clients work the same way. **Email + password** — `supabase.auth.signUp()` / `supabase.auth.signInWithPassword()` When `email.verify_email` is `false` (the default), `signUp` returns a session immediately. Set it to `true` and configure an email provider to require confirmation first. **Magic link / Email OTP** — `supabase.auth.signInWithOtp()` / `supabase.auth.verifyOtp()` Requires an `auth.email` block in the config — without it, the OTP endpoint isn’t mounted at all and the call 404s. With the block present but no email provider configured to actually send it, `signInWithOtp` returns a 200 with an empty response body. **OAuth (Google, GitHub)** — `supabase.auth.signInWithOAuth({ provider: 'google' })` There are two different URLs involved, and they are not interchangeable: * **`auth.oauth.<name>.redirect_url`** (config, fixed) — the URL the *provider* redirects back to once the user approves consent. This must be instancez’s own callback route, always shaped `<base URL>/auth/v1/callback/<name>` (e.g. `/auth/v1/callback/google`), and must exactly match what’s registered in that provider’s console (Google Cloud Console, GitHub OAuth Apps, …) — providers reject any other value. It always points at your **API server**, not your frontend. * **`redirectTo`** (client-supplied, dynamic) — where the *app* should land once instancez finishes the exchange, passed as `options.redirectTo` to `signInWithOAuth()`. It must match an origin listed in `auth.redirect_urls`, and it points at your **frontend**. ```js const { error } = await supabase.auth.signInWithOAuth({ provider: 'google', options: { redirectTo: window.location.origin }, }) ``` When `redirectTo` is omitted (or fails the `auth.redirect_urls` check), instancez lands the browser on the first entry of `auth.redirect_urls` — its stand-in for Supabase’s project Site URL. Only when `auth.redirect_urls` is empty too is there nowhere to send the browser, and the callback returns the session as a raw JSON body instead. Passing `redirectTo` explicitly is still the clearest path. If the exchange fails (bad client secret, provider outage), the callback redirects to that same target with GoTrue-style error params in the fragment — `#error=server_error&error_code=unexpected_failure&error_description=…` — which supabase-js surfaces through `detectSessionInUrl`. The underlying cause stays in the server logs rather than the browser. The full round trip: ```plaintext browser → GET /auth/v1/authorize?provider=google&redirect_to=<frontend URL> instancez → 307 to Google, using auth.oauth.google.redirect_url as redirect_uri Google → user consents → redirects to auth.oauth.google.redirect_url (fixed) instancez → exchanges the code, then redirects to the original redirect_to with the session in the URL fragment (#access_token=…) ``` By default this is the implicit flow (tokens in the URL fragment, which supabase-js parses automatically via `detectSessionInUrl`). PKCE is also supported: create the client with `createClient(url, key, { auth: { flowType: 'pkce' } })` and supabase-js adds `code_challenge`/`code_challenge_method` to `/authorize` for you, getting back an auth code on the redirect instead of tokens directly. **Anonymous** — `supabase.auth.signInAnonymously()` Issues a JWT with `is_anonymous: true` and the `anon` Postgres role. Set `allow_anonymous: false` to disable. Anonymous users can be promoted to a full account by calling `signUp` or linking an OAuth identity. **Session management** — `getSession()`, `onAuthStateChange()`, `signOut()` all work as documented by supabase-js. `signOut` invalidates the refresh token server-side. **TOTP MFA** — the full `auth.mfa` surface is implemented: `enroll`, `challenge`, `verify`, `unenroll`, `listFactors`. A successful `verify` re-issues the session JWT with `aal: aal2`. ## Using auth in RLS [Section titled “Using auth in RLS”](#using-auth-in-rls) Every request carries the user’s JWT. The middleware switches the Postgres role and writes the user ID into a session GUC before running any query, so RLS policies can call `auth.uid()` and `auth.is_authenticated()` directly: ```yaml tables: posts: fields: - name: id type: bigserial primary_key: true - name: user_id foreign_key: references: auth.users.id on_delete: cascade - name: body type: text required: true rls: - operations: [select] using: "true" - operations: [insert] with_check: "auth.uid() = user_id" - operations: [update] using: "auth.uid() = user_id" with_check: "auth.uid() = user_id" - operations: [delete] using: "auth.uid() = user_id" ``` To restrict a table to signed-in users only: ```yaml rls: - operations: [select] using: "auth.is_authenticated()" ``` See [RLS Policies](/instancez/build/rls/) for the full policy reference. ## Managing users in the dashboard [Section titled “Managing users in the dashboard”](#managing-users-in-the-dashboard) The dashboard’s **Users** section (top-level nav item) provides a full admin UI for user management: * **List users** — paginated table showing email, confirmed status, last sign-in, and ban status * **Create user** — email + password, with optional automatic email confirmation * **Edit user** — change email or password, ban/unban with one toggle * **Delete user** — gated by typing the user’s email to confirm All operations go through the Supabase-compatible `/auth/v1/admin/users` endpoints using the secret key. The same endpoints work directly via `supabase-js` using the `admin` client surface (requires the secret key). ## What’s next [Section titled “What’s next”](#whats-next) * [RLS Policies](/instancez/build/rls/) — write access rules in SQL expressions * [Tables / Schema](/instancez/build/schema/) — declare tables and fields in YAML * [Storage](/instancez/build/storage/) — file uploads wired to the same JWT
# Code Functions
> JavaScript ESM HTTP handlers served at /functions/v1/<name>. Full access to ctx.supabase, secrets, and structured logging.
Code functions are JavaScript ESM handlers served at `/functions/v1/<name>`, callable from supabase-js via `supabase.functions.invoke()`. ## Requirements [Section titled “Requirements”](#requirements) Functions run in Node.js worker processes, so **Node.js must be installed and on `PATH`** wherever instancez serves or builds functions. With a `functions:` block declared, `inz dev` and `inz serve` require `node` at startup; `inz bundle` requires it when your functions have npm dependencies, because it runs `npm ci` locally to vendor them before producing the archive. `inz cloud deploy` only uploads your sources and does not run `npm ci` locally (the cloud installs dependencies during the build). Each command refuses to proceed with an actionable error if node is missing. Projects without a `functions:` block do not need Node.js. The supported minimum is **Node.js 22**. Functions get an injected `@supabase/supabase-js` client (`ctx.supabase` / `ctx.serviceClient`), and that client opens a realtime connection that needs a native `WebSocket`, which Node ships from v22 on. The minimum is documented, not enforced, so an older version may appear to work until a function touches `ctx.supabase`. When your functions have npm dependencies, **`inz bundle` requires a committed `package-lock.json`.** It vendors dependencies with `npm ci` (reproducible, never `npm install`), which fails without a lockfile. Run `npm install` in `functions/` once to generate it and commit the result. (`inz dev` is more lenient: it falls back to `npm install` to create the lockfile on first run.) For `inz cloud deploy`, no local npm step is needed: the cloud installs dependencies from your `package.json` and `package-lock.json` during the build, so a committed lockfile is still required but you do not run npm yourself. instancez also verifies at startup that **every declared function’s source file exists** on disk (the `file:` path under each entry). A missing file fails fast with a clear error instead of surfacing later when the function is first called. ## A minimal handler [Section titled “A minimal handler”](#a-minimal-handler) functions/hello.js ```js export default async function handler(req, ctx) { return { status: 200, body: { message: "hello" } }; } ``` A handler receives two arguments — `req` (the incoming request) and `ctx` (runtime context) — and returns an object with `status`, `body`, and optionally `headers`. ## Request object (req) [Section titled “Request object (req)”](#request-object-req) | Property | Type | Description | | ------------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `method` | `string` | HTTP method (`"GET"`, `"POST"`, etc.) | | `path` | `string` | Request path including the function prefix (e.g. `/functions/v1/todos`) | | `query` | `object` | URL query parameters, first value per key (`{ tag: "a" }`). | | `queryAll` | `object` | URL query parameters with every value per key as an array (`{ tag: ["a", "b"] }`). Use this when a parameter can repeat; `query` keeps only the first value. | | `rawQuery` | `string` | The unparsed query string (everything after `?`, without the `?`). For schemes that sign the query string verbatim. `""` when there is no query. | | `headers` | `object` | Lowercased request headers, first value per key. | | `headersAll` | `object` | Lowercased request headers with every value per key as an array. `headers` keeps only the first. | | `body` | `any` | Parsed request body. JSON when `content-type: application/json`, raw string otherwise. `undefined` when body is empty. | | `rawBody` | `Buffer` | The unparsed request body: the exact bytes the client sent, before any JSON parsing. Reach for this instead of `body` when the bytes themselves matter, such as verifying a webhook signature (`body` has already been re-shaped and won’t hash to the same value). An empty `Buffer` when the request has no body. | ## Context object (ctx) [Section titled “Context object (ctx)”](#context-object-ctx) | Property | Type | Description | | ------------------- | ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `ctx.supabase` | `SupabaseClient` | A `@supabase/supabase-js` client carrying the **caller’s JWT**. RLS applies as the calling user. Lazily constructed on first access. Throws if `@supabase/supabase-js` is not vendored. | | `ctx.serviceClient` | `SupabaseClient` | A `@supabase/supabase-js` client carrying a short-lived `service_role` JWT (bypasses RLS). Use for explicit privilege escalation. | | `ctx.claims` | `object \| null` | Claims extracted from the caller’s JWT. `null` for anonymous callers. Contains at most four keys: `sub` (user ID string), `role` (wire role string), `email` (if present in the JWT), and `jwt` (raw token string). Custom JWT fields beyond these are not available. | | `ctx.env` | `object` | Secrets declared in the function’s `env:` YAML block, resolved from `INSTANCEZ_ENV_*` variables. | | `ctx.log` | `object` | Structured logger with methods `debug`, `info`, `warn`, `error`. Each takes `(message, fields?)`. Log lines appear in `inz dev` output. | | `ctx.signal` | `AbortSignal` | Aborted when the caller disconnects or the per-request timeout fires. Honoring it is optional — the server enforces the timeout regardless. | `console.log`, `console.warn`, `console.error`, and related methods are patched to emit structured log lines. Prefer `ctx.log` for structured field support. ## Checking auth before escalating privilege [Section titled “Checking auth before escalating privilege”](#checking-auth-before-escalating-privilege) `ctx.serviceClient` bypasses RLS, so a function that uses it is doing work the database would otherwise refuse. Two rules keep that safe: * Set `auth_required: true` on any function that touches `ctx.serviceClient`. A public function (`auth_required: false`) is callable by anyone on the internet, so it must stick to `ctx.supabase` and let RLS decide what the caller can see or change. * Read `ctx.claims.sub` and do the caller-scoped work through `ctx.supabase` first. Reach for `ctx.serviceClient` only on the specific step RLS blocks, and stamp it with the `sub` you already checked. The example below records an order for the signed-in user, then writes an audit row that normal users cannot write directly: ```js // functions/place-order.js (declared with auth_required: true) export default async function handler(req, ctx) { // auth_required: true means anonymous callers are already rejected with 401, // but read sub anyway; it's the identity every step below trusts. const userId = ctx.claims?.sub; if (!userId) return { status: 401, body: { error: "sign in required" } }; // Caller-scoped write under the caller's own RLS. The database enforces that // the row belongs to this user; the handler doesn't have to. const { data: order, error } = await ctx.supabase .from("orders") .insert({ user_id: userId, item: req.body.item }) .select() .single(); if (error) return { status: 400, body: { error: error.message } }; // Only the audit write needs to bypass RLS, so serviceClient is scoped to it. // Stamp the row with the sub checked above, not with client-supplied input. await ctx.serviceClient .from("audit_log") .insert({ actor: userId, action: "order.placed", order_id: order.id }); return { status: 200, body: { order } }; } ``` There is no role or admin concept to gate on: `ctx.claims.role` is `"authenticated"` for every signed-in user. An “admin only” endpoint is one you build yourself. Check `sub` against an owners table (read via `ctx.serviceClient`) before doing the privileged work. ## Response object [Section titled “Response object”](#response-object) A handler returns an object with `status`, `body`, and optionally `headers`. | Property | Type | Description | | --------- | ---------------------------- | ----------------------------------------------------------------------- | | `status` | `number` | HTTP status code. Defaults to `200`. | | `headers` | `object` | Response headers. Defaults to `{ "content-type": "application/json" }`. | | `body` | `string \| object \| Buffer` | The response body. See below. | How `body` is written depends on its type: * A **string** is sent as-is. * A **`Buffer`** is sent as raw bytes, so a function can return a file, an image, or any binary payload. Set `content-type` yourself in `headers` for these; the JSON default won’t fit. * **Anything else** is JSON-serialized. ```js // functions/badge.js: return a PNG generated in the handler export default async function handler(req, ctx) { const png = await renderBadge(req.query.label); // returns a Buffer return { status: 200, headers: { 'content-type': 'image/png' }, body: png, }; } ``` ## Declaring in YAML [Section titled “Declaring in YAML”](#declaring-in-yaml) Functions are declared under the top-level `functions:` key in `instancez.yaml`: ```yaml functions: todos: runtime: node # required; "node" is the only supported value file: functions/todos.js # path relative to the config root auth_required: true # when true, unauthenticated callers receive 401 before the handler runs timeout: 30s # per-request deadline; defaults to 30s env: # secrets injected as ctx.env STRIPE_KEY: ${INSTANCEZ_ENV_STRIPE_KEY} FIXED_VALUE: "literal" ``` | Key | Type | Description | | --------------- | ------------------- | ------------------------------------------------------------------------------------------------------- | | `runtime` | `string` | Runtime identifier. Only `"node"` is supported. | | `file` | `string` | Path to the handler file, relative to the config root. | | `auth_required` | `bool` | If `true`, instancez returns `401` for anonymous requests before invoking the handler. Default `false`. | | `timeout` | `string` | Go duration string (e.g. `"30s"`, `"5s"`). Defaults to `30s`. Exceeding the timeout returns `504`. | | `env` | `map[string]string` | Secrets available as `ctx.env`. Values are either plain literals or `${INSTANCEZ_ENV_*}` references. | ## Creating from the dashboard [Section titled “Creating from the dashboard”](#creating-from-the-dashboard) The dashboard’s **Code Functions** page has a **New function** button. Give the function a name and it is created with `runtime: node`, a `functions/<name>.js` file, and a small starter handler that returns a 200. You land in the editor to change it. This is a shortcut for the YAML above: it adds the entry to `instancez.yaml` and writes the file, so the result is identical to declaring it by hand. The button appears when the dashboard runs in readwrite mode (`inz dev`, or `inz serve --dashboard readwrite`). ## Secrets [Section titled “Secrets”](#secrets) Set secrets as environment variables with the `INSTANCEZ_ENV_` prefix: ```sh # .env or .development.env (gitignored) INSTANCEZ_ENV_STRIPE_KEY=sk_test_... ``` Reference them in YAML: ```yaml functions: charge: runtime: node file: functions/charge.js env: STRIPE_KEY: ${INSTANCEZ_ENV_STRIPE_KEY} ``` Access in the handler: ```js const stripe = new Stripe(ctx.env.STRIPE_KEY); ``` Secrets are resolved from three sources in ascending precedence order: 1. `.env` (base file) 2. `.<mode>.env` (e.g. `.development.env`, `.production.env`) 3. Process environment variables (`INSTANCEZ_ENV_*`) Only keys with the `INSTANCEZ_ENV_` prefix are passed to functions. Other environment variables in those files are ignored. Function workers run with a scrubbed environment, so host secrets like AWS credentials and database URLs are not visible inside a handler. The values you declare under `env:` are the only secrets a worker sees. instancez injects them per request over an internal channel and never writes them to the worker’s process environment. ## npm dependencies [Section titled “npm dependencies”](#npm-dependencies) Functions run from the `functions/` subdirectory of your project. Place a `package.json` there to declare dependencies: ```json { "name": "functions", "private": true, "type": "module", "dependencies": { "@supabase/supabase-js": "^2.107.0" } } ``` `@supabase/supabase-js` is required if any function uses `ctx.supabase` or `ctx.serviceClient`. The worker loads it lazily — functions that never access those properties work without it. Dependencies must import as ESM, and they must not rely on native add-ons unless those are prebuilt for the platform you deploy to. ## Calling a function [Section titled “Calling a function”](#calling-a-function) **curl:** ```sh curl https://your-project.instancez.ai/functions/v1/todos \ -H "Authorization: Bearer <user-jwt>" ``` **supabase-js:** ```js const { data, error } = await supabase.functions.invoke("todos", { body: { title: "Buy milk" }, }); ``` ## Lifecycle [Section titled “Lifecycle”](#lifecycle) | Command | npm | Hot reload | | ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- | | `inz dev` | Runs `npm ci` on startup. Falls back to `npm install` when no lockfile exists yet (first run). Restart required only when adding or removing npm dependencies. | JS code changes and `functions:` YAML changes are picked up automatically without a restart. | | `inz cloud deploy` | Uploads function sources; the cloud installs dependencies and builds the bundle. A committed `package-lock.json` is required. | N/A | | `inz bundle` | Runs `npm ci` (requires a committed `package-lock.json`) and produces a tar archive. Use `--output s3://…` for self-hosted deploys. | N/A | | `inz serve` | Never runs npm. Consumes the pre-built bundle produced by `inz bundle --output s3://…`. | N/A | ## Runtime limits [Section titled “Runtime limits”](#runtime-limits) | Setting | Value | | ----------------------- | ------------------------------------------------ | | Default timeout | `30s` (configurable per-function via `timeout:`) | | Worker pool size | `min(4, GOMAXPROCS)` Node processes | | Max concurrent requests | `pool_size × 64` | **Error codes:** | Code | Meaning | | ----- | ---------------------------------------------------- | | `401` | `auth_required: true` and no valid JWT provided | | `504` | Handler exceeded the `timeout` | | `503` | All in-flight slots are occupied (runtime saturated) | | `502` | Worker process died or no healthy worker available | | `500` | Handler threw an unhandled exception | ## What’s next [Section titled “What’s next”](#whats-next) * [RLS](/instancez/build/rls/) — the policies `ctx.supabase` runs under * [Storage](/instancez/build/storage/) — upload/download from a function via `ctx.serviceClient` * [Deploy](/instancez/deploy/cloud/) — `inz cloud deploy` ships function source alongside your schema
# Querying
> Filter, embed, aggregate, and paginate with the PostgREST-compatible query API.
instancez exposes the same HTTP API as Supabase, so any Supabase client library works — JavaScript, Python, Swift, Flutter, and others. Examples here use `@supabase/supabase-js` (which is also what the integration tests run against), but the raw HTTP parameters are shown so you can use any client or make requests directly. ## Basic select [Section titled “Basic select”](#basic-select) Fetch all columns: ```js const { data, error } = await supabase.from('todos').select('*') // GET /rest/v1/todos?select=* ``` List queries default to `LIMIT 20` when no `.limit()`/`.range()` is given — use pagination (below) to get more than 20 rows back. Fetch specific columns: ```js const { data, error } = await supabase.from('todos').select('id, title, done') // GET /rest/v1/todos?select=id,title,done ``` Alias a column in the response: ```js const { data, error } = await supabase.from('todos').select('label:title, done') // GET /rest/v1/todos?select=label:title,done // response key is "label", not "title" ``` Cast a column type: ```js // GET /rest/v1/todos?select=id::text,title ``` ## Filtering [Section titled “Filtering”](#filtering) Filters are query parameters of the form `column=operator.value`. ### Comparison operators [Section titled “Comparison operators”](#comparison-operators) | Operator | Meaning | Example | | ------------ | ----------------------------------------- | ---------------------------- | | `eq` | equal | `priority=eq.3` | | `neq` | not equal | `priority=neq.3` | | `gt` | greater than | `priority=gt.3` | | `gte` | greater than or equal | `priority=gte.3` | | `lt` | less than | `priority=lt.3` | | `lte` | less than or equal | `priority=lte.3` | | `like` | SQL LIKE (case-sensitive, `%` wildcard) | `title=like.%milk%` | | `ilike` | SQL ILIKE (case-insensitive) | `title=ilike.%MILK%` | | `match` | regex match (`~`) | `title=match.^buy` | | `imatch` | case-insensitive regex (`~*`) | `title=imatch.^BUY` | | `is` | IS NULL / IS TRUE / IS FALSE / IS UNKNOWN | `done=is.false` | | `isdistinct` | IS DISTINCT FROM (NULL-safe not-equal) | `title=isdistinct.null` | | `in` | set membership | `status=in.(active,pending)` | ```js // eq const { data } = await supabase.from('todos').select('*').eq('done', false) // neq const { data } = await supabase.from('todos').select('*').neq('priority', 3) // gt / gte / lt / lte const { data } = await supabase.from('todos').select('*').gt('priority', 3) const { data } = await supabase.from('todos').select('*').lte('priority', 2) // like / ilike const { data } = await supabase.from('todos').select('*').like('title', '%milk%') const { data } = await supabase.from('todos').select('*').ilike('title', '%MILK%') // is const { data } = await supabase.from('todos').select('*').is('done', false) // in const { data } = await supabase.from('todos').select('*').in('status', ['active', 'pending']) ``` ### Pattern matching against multiple values [Section titled “Pattern matching against multiple values”](#pattern-matching-against-multiple-values) ```plaintext # match ALL patterns (LIKE ALL) title=like(all).{%milk%,%eggs%} # match ANY pattern (LIKE ANY) title=like(any).{%milk%,%eggs%} # case-insensitive variants title=ilike(all).{%MILK%,%EGGS%} title=ilike(any).{%MILK%,%EGGS%} ``` ### Negation [Section titled “Negation”](#negation) Prefix any operator value with `not.` to negate it: ```js // not equal to 3 const { data } = await supabase.from('todos').select('*').not('priority', 'eq', 3) // URL: priority=not.eq.3 ``` ### Logical OR and AND [Section titled “Logical OR and AND”](#logical-or-and-and) ```js // OR: rows where priority is 1 or 5 const { data } = await supabase .from('todos') .select('*') .or('priority.eq.1,priority.eq.5') // URL: or=(priority.eq.1,priority.eq.5) ``` Nested logic: ```plaintext or=(title.like.%milk%,and(priority.gt.3,done.is.false)) ``` ### Array and range operators [Section titled “Array and range operators”](#array-and-range-operators) These apply to Postgres array and range types: | Operator | SQL | Meaning | | -------- | ---- | ------------------------------ | | `cs` | `@>` | array/range contains value | | `cd` | `<@` | array/range is contained by | | `ov` | `&&` | arrays/ranges overlap | | `sl` | `<<` | range strictly left of | | `sr` | `>>` | range strictly right of | | `nxl` | `&>` | range does not extend left of | | `nxr` | `&<` | range does not extend right of | | `adj` | \`- | -\` | ```plaintext tags=cs.{urgent,blocked} price_range=ov.[10,50] ``` ### Full-text search [Section titled “Full-text search”](#full-text-search) ```js // fts → to_tsquery const { data } = await supabase.from('todos').select('*').textSearch('title', 'milk') // URL: title=fts.milk ``` Other FTS operators (use via raw URL parameters): | Operator | Postgres function | | -------- | ---------------------- | | `fts` | `to_tsquery` | | `plfts` | `plainto_tsquery` | | `phfts` | `phraseto_tsquery` | | `wfts` | `websearch_to_tsquery` | Pass a language config in parentheses: `title=fts(english).milk`. ### JSONB path filtering [Section titled “JSONB path filtering”](#jsonb-path-filtering) Access nested JSONB values using `->` (returns jsonb) and `->>` (returns text): ```plaintext metadata->>theme=eq.dark settings->notifications->>enabled=eq.true ``` ## Ordering and pagination [Section titled “Ordering and pagination”](#ordering-and-pagination) ### Order [Section titled “Order”](#order) ```js // ascending (default) const { data } = await supabase.from('todos').select('*').order('priority') // descending const { data } = await supabase.from('todos').select('*').order('priority', { ascending: false }) // nulls first / nulls last (use URL param directly) // order=priority.desc.nullslast ``` Multiple columns: `order=priority.asc,created_at.desc`. ### Limit and offset [Section titled “Limit and offset”](#limit-and-offset) ```js const { data } = await supabase.from('todos').select('*').order('priority').limit(10) // URL: order=priority&limit=10&offset=0 ``` ### Range (HTTP Range header) [Section titled “Range (HTTP Range header)”](#range-http-range-header) The client can request a slice with an HTTP `Range` header (`Range-Unit: items`). `supabase-js` exposes this via `.range()`: ```js // rows 2–3 (zero-based, inclusive) const { data } = await supabase.from('todos').select('*').order('priority').range(2, 3) ``` The response includes a `Content-Range` header: `2-3/*` (or `2-3/N` when a count is requested). ### Count [Section titled “Count”](#count) Pass `Prefer: count=exact` to get the total row count alongside (or instead of) the rows: ```js const { data, count } = await supabase .from('todos') .select('*', { count: 'exact' }) ``` Use `head: true` to skip the body and return only the count: ```js const { count } = await supabase .from('todos') .select('*', { count: 'exact', head: true }) ``` Count modes: | Mode | Behavior | | ----------- | ------------------------------------------------------------------------------------------------------------------------------------- | | `exact` | `COUNT(*)` — precise but adds a query | | `planned` | Uses the Postgres query planner estimate | | `estimated` | `pg_class.reltuples` statistic when no filters exist, planner estimate (via `EXPLAIN`) when filters exist. Never executes `COUNT(*)`. | ## Embeds (joins) [Section titled “Embeds (joins)”](#embeds-joins) Embeds use foreign key relationships declared in `instancez.yaml` to join related tables in a single request. ### Belongs-to (many-to-one) [Section titled “Belongs-to (many-to-one)”](#belongs-to-many-to-one) When the current table has a foreign key pointing to another table, the joined row is returned as an object: ```js // comments has a FK: todo_id → todos.id const { data } = await supabase.from('comments').select('body, todos(title)') // response: [{ body: "...", todos: { title: "..." } }, ...] ``` ### Has-many (one-to-many) [Section titled “Has-many (one-to-many)”](#has-many-one-to-many) When another table has a FK pointing back to the current table, the joined rows are returned as an array: ```js // todos has many comments const { data } = await supabase.from('todos').select('title, comments(body)') // response: [{ title: "...", comments: [{ body: "..." }, ...] }, ...] ``` ### Inner join [Section titled “Inner join”](#inner-join) By default, embeds use a LEFT join — rows with no matching related record are still returned (the embed key is `null`). Use `!inner` to drop those rows: ```js // only todos that have at least one comment // GET /rest/v1/todos?select=title,comments!inner(body) ``` ### Alias [Section titled “Alias”](#alias) Rename the embed key in the response: ```js // parent:todos is returned under "parent", not "todos" const { data } = await supabase.from('comments').select('body, parent:todos(id, title)') ``` The `!left` modifier is accepted (explicit left join, the default) and can be combined with an alias: `parent:todos!left(id,title)`. ### FK disambiguation [Section titled “FK disambiguation”](#fk-disambiguation) When two FKs exist between the same tables, use `!fk_column` to pick the right one: ```plaintext select=title,assignee:users!assignee_id(name) ``` ### Spread embed [Section titled “Spread embed”](#spread-embed) Inline the joined columns directly into the parent row (belongs-to only): ```plaintext GET /rest/v1/comments?select=body,...todos(title) // response: [{ body: "...", title: "..." }, ...] ``` ### Embed-scoped filters, order, and limit [Section titled “Embed-scoped filters, order, and limit”](#embed-scoped-filters-order-and-limit) Filter, order, or paginate within a has-many embed using `<embed>.` prefixes: ```plaintext GET /rest/v1/todos?select=title,comments(body)&comments.body=like.%important%&comments.order=created_at.desc&comments.limit=5 ``` ### Nested embeds [Section titled “Nested embeds”](#nested-embeds) ```js const { data } = await supabase .from('todos') .select('title, comments(body, todos(title))') ``` ## Aggregates [Section titled “Aggregates”](#aggregates) Use PostgREST-style aggregate suffixes in the `select` parameter. When any aggregate is present, the query groups by all non-aggregate columns automatically. ### Syntax [Section titled “Syntax”](#syntax) ```plaintext col.agg() — aggregate over a column alias:col.agg() — explicit alias col.agg()::type — cast the result count() — COUNT(*) with no column ``` Supported aggregates: `count`, `sum`, `avg`, `min`, `max`. ```js // count rows per status // GET /rest/v1/todos?select=status,count() // response: [{ status: "active", count: 3 }, ...] // sum of a column // GET /rest/v1/todos?select=status,total:priority.sum() // average with cast // GET /rest/v1/todos?select=avg_priority:priority.avg()::numeric ``` ### HAVING [Section titled “HAVING”](#having) Filter on aggregate results with the `having` parameter: ```plaintext GET /rest/v1/todos?select=status,total:id.count()&having=total.gt.2 ``` ### Order by aggregate [Section titled “Order by aggregate”](#order-by-aggregate) Reference the aggregate alias in the `order` parameter: ```plaintext GET /rest/v1/todos?select=status,id.count()&order=count.desc ``` ## Inserts, updates, and deletes [Section titled “Inserts, updates, and deletes”](#inserts-updates-and-deletes) ### Insert [Section titled “Insert”](#insert) ```js const { data, error } = await supabase .from('todos') .insert({ title: 'buy milk', user_id: userId }) .select() ``` Bulk insert: ```js const { data, error } = await supabase .from('todos') .insert([ { title: 'buy milk', user_id: userId }, { title: 'buy eggs', user_id: userId }, ]) .select('id, title') ``` Control what the server returns with `Prefer: return=`: | Value | Behavior | | ---------------- | ------------------------------------ | | `minimal` | No body returned (default) | | `headers-only` | Status + headers only (201, no body) | | `representation` | Full row(s) returned | ### Upsert [Section titled “Upsert”](#upsert) ```js // merge on PK conflict const { data } = await supabase .from('todos') .upsert({ id: existingId, title: 'updated title' }) .select() // ignore on PK conflict const { data } = await supabase .from('todos') .upsert({ id: existingId, title: 'ignored' }, { ignoreDuplicates: true }) ``` To upsert on a non-PK column, pass `on_conflict=column_name` in the query string and `Prefer: resolution=merge-duplicates` or `resolution=ignore-duplicates`. ### Update [Section titled “Update”](#update) ```js const { error } = await supabase .from('todos') .update({ done: true }) .eq('user_id', userId) ``` ### Delete [Section titled “Delete”](#delete) ```js const { error } = await supabase .from('todos') .delete() .eq('id', todoId) ``` ### max-affected guard [Section titled “max-affected guard”](#max-affected-guard) Prevent accidentally broad mutations with `Prefer: max-affected=N`. The request is rejected (rolled back) if more than N rows would be affected: ```plaintext Prefer: max-affected=1 ``` ### Dry-run [Section titled “Dry-run”](#dry-run) Roll back the transaction after the query executes without committing changes: ```plaintext Prefer: tx=rollback ``` ## CSV output [Section titled “CSV output”](#csv-output) Add `.csv()` in `supabase-js` or set `Accept: text/csv` to receive results as CSV: ```js const { data } = await supabase.from('todos').select('title').order('priority').csv() ``` ## What’s next [Section titled “What’s next”](#whats-next) * [RLS](/instancez/build/rls/) — control access to rows with row-level security policies * [Auth](/instancez/build/auth/) — configure authentication and JWT handling * [RPC reference](/instancez/api-reference/rpc/) — call Postgres functions over HTTP
# RLS Policies
> Row-level security is the only authorization layer in instancez. All access decisions are Postgres policies declared in instancez.yaml.
instancez has no HTTP-level RBAC. Every access decision is a Postgres row-level security (RLS) policy declared in `instancez.yaml` under the table’s `rls:` block. The HTTP middleware validates the JWT and issues `SET LOCAL ROLE` to the correct Postgres role; from there, Postgres enforces the policies. There is no application-side role table and no separate permission system to synchronize. ## Policy syntax [Section titled “Policy syntax”](#policy-syntax) Each entry in `rls:` is a policy object with these fields: * `operations` — either exactly one of `select`, `insert`, `update`, `delete`, or all four together. Partial combinations (e.g. `[insert, update]`) are rejected. * `using` — a SQL boolean expression that decides which existing rows the operation can see or target. Read by `select`, `update`, `delete`. * `with_check` — a SQL boolean expression that decides what a written row is allowed to look like. Read by `insert`, `update`. * at least one of `using`/`with_check` is required, whichever your operations read. `select`/`delete` have no `with_check` fallback and `insert` has no `using` fallback, so those always need the matching field directly. For `update` alone, the two fields aren’t symmetric. Setting only `using` gets `with_check` auto-filled with the same expression, for both permissive and restrictive policies. Setting only `with_check` auto-fills `using` only when `type: restrictive` is set. On a permissive (default) `update` policy, `with_check` alone leaves `using` unset, and that blocks every update through the policy, so set `using` explicitly there too. An optional `type` field accepts `permissive` (default) or `restrictive`. Multiple permissive policies on the same table and operation combine with OR — a row passes if any policy allows it. A restrictive policy additionally narrows the result with AND — the row must also satisfy every restrictive policy. ```yaml tables: posts: fields: - name: id type: bigserial primary_key: true - name: user_id type: uuid required: true - name: body type: text required: true rls: # Anyone can read - operations: [select] using: "true" # Only the owner can write - operations: [insert] with_check: "auth.uid() = user_id" - operations: [update] using: "auth.uid() = user_id" with_check: "auth.uid() = user_id" - operations: [delete] using: "auth.uid() = user_id" ``` When a table has at least one `rls:` entry, instancez emits `ALTER TABLE ... ENABLE ROW LEVEL SECURITY` and `FORCE ROW LEVEL SECURITY`. Tables with no `rls:` block have RLS disabled — all rows are visible to all roles. instancez passes `using` straight through to Postgres’s `USING` clause and `with_check` to `WITH CHECK`. This matches standard Postgres semantics, including the asymmetric `update`-only auto-fill behavior described above. ## auth.uid() and auth.is\_authenticated() [Section titled “auth.uid() and auth.is\_authenticated()”](#authuid-and-authis_authenticated) instancez installs these helper functions in the `auth` schema at startup. They read session variables set by the request middleware, not application memory. | Function | Return type | Returns non-null when | | ------------------------- | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------- | | `auth.uid()` | `uuid` | Request carries a valid JWT with a `sub` claim (i.e. a signed-in user). Returns `NULL` for `anon` requests and for `service_role` tokens. | | `auth.role()` | `text` | Always returns a value: `'anon'`, `'authenticated'`, or `'service_role'`. | | `auth.email()` | `text` | Request carries a JWT with an `email` claim. | | `auth.jwt()` | `jsonb` | Request carries any JWT. Returns the full decoded payload. | | `auth.is_authenticated()` | `boolean` | Role is `authenticated` or `service_role`. Returns `false` for `anon`. | `auth.uid()` is the right function for owner-scoped policies. `auth.is_authenticated()` is useful as a simpler signed-in-only gate. The underlying implementation reads session GUCs (`app.user_id`, `app.role`, etc.) set at the start of every request transaction. ## Common patterns [Section titled “Common patterns”](#common-patterns) ### Public read, owner write [Section titled “Public read, owner write”](#public-read-owner-write) Anyone can read; only the row’s owner can modify it. ```yaml rls: - operations: [select] using: "true" - operations: [insert] with_check: "auth.uid() = user_id" - operations: [update] using: "auth.uid() = user_id" with_check: "auth.uid() = user_id" - operations: [delete] using: "auth.uid() = user_id" ``` ### Signed-in only [Section titled “Signed-in only”](#signed-in-only) Any authenticated user can access the table; anonymous requests cannot. ```yaml rls: - operations: [select, insert, update, delete] using: "auth.is_authenticated()" with_check: "auth.is_authenticated()" ``` ### Private (owner only) [Section titled “Private (owner only)”](#private-owner-only) Only the row’s owner can see or modify it. ```yaml rls: - operations: [select, insert, update, delete] using: "auth.uid() = user_id" with_check: "auth.uid() = user_id" ``` ### Divergent read/write rules on update [Section titled “Divergent read/write rules on update”](#divergent-readwrite-rules-on-update) `using` and `with_check` don’t have to match. A common case: a user can see any row they own, but can only save changes to it while it isn’t locked. ```yaml rls: - operations: [update] using: "owner_id = auth.uid()" with_check: "owner_id = auth.uid() AND status != 'locked'" ``` If you only set `using` on an `update` policy, Postgres reuses it as `with_check` too, checked against the new row. This works whether the policy is permissive or restrictive. The other direction doesn’t hold in general. Setting only `with_check` on a **permissive** `update` policy (permissive is the default `type`) does not gate writes while leaving reads open: it leaves `using` unset, so the policy matches zero rows and no update ever goes through it. Set `using` explicitly on a permissive `update` policy even when it repeats `with_check`. `with_check` alone is valid on a **restrictive** `update` policy: a missing `using` there is a no-op (restrictive policies narrow visibility granted by a permissive policy elsewhere, they don’t grant any of their own), so `with_check` still enforces the write gate. Use this to layer an extra write condition on top of a separate permissive policy: ```yaml rls: - operations: [update] using: "owner_id = auth.uid()" with_check: "owner_id = auth.uid()" - operations: [update] type: restrictive with_check: "status != 'locked'" ``` ### Admin bypass via service role [Section titled “Admin bypass via service role”](#admin-bypass-via-service-role) The `service_role` has `BYPASSRLS` in Postgres — it skips all policies. Requests made with the secret key are automatically assigned `service_role`, so they see every row regardless of any `using`/`with_check` expression. This applies both to the REST API (when the caller passes the secret key in the `apikey` header) and to code functions that use the backend client. In code functions, use `ctx.serviceClient` to get a client that runs as `service_role`: ```js export default async function handler(ctx) { // Bypasses RLS — use only for trusted server-side logic. const { data } = await ctx.serviceClient.from('posts').select('*'); return Response.json(data); } ``` Use `ctx.supabase` for operations that should respect RLS and run as the calling user. ## How roles are assigned [Section titled “How roles are assigned”](#how-roles-are-assigned) The middleware maps each request to one of three Postgres roles. The publishable key selects `anon` by default; a valid user token upgrades the request to `authenticated`; the secret key selects `service_role` outright. | Request credential | Postgres role (default name) | BYPASSRLS | | ---------------------------------- | ---------------------------- | --------- | | Publishable key, no user token | `anon` | No | | Publishable key + valid user token | `authenticated` | No | | Secret key | `service_role` | Yes | The Postgres role names default to the values in the table above, matching Supabase. They are configurable via `INSTANCEZ_DB_ANON_ROLE`, `INSTANCEZ_DB_AUTHENTICATED_ROLE`, and `INSTANCEZ_DB_SERVICE_ROLE` environment variables — but the JWT claim values (`anon`, `authenticated`, `service_role`) are fixed and cannot be changed. They are part of the Supabase wire format. The request pool logs in as the `authenticator` role, which is `NOINHERIT`. Without an explicit `SET LOCAL ROLE`, it carries no table privileges. Every request transaction starts by issuing `SET LOCAL ROLE` to the appropriate role, then runs the query, so the role is always correct for the lifetime of that transaction. ## What’s next [Section titled “What’s next”](#whats-next) * [Auth](/instancez/build/auth/) — how users sign up and get JWTs * [Tables / Schema](/instancez/build/schema/) — table and column definitions * [Storage](/instancez/build/storage/) — per-bucket RLS policies * [Querying](/instancez/build/querying/) — filtering and embedding from the client
# Tables / Schema
> Define tables, fields, types, and foreign keys in instancez.yaml. Changes apply automatically in dev mode.
Your database schema lives in `instancez.yaml`. When you run `inz dev`, the migrator watches the file and applies any changes automatically — no migration files to write or track by hand. ## Declaring a table [Section titled “Declaring a table”](#declaring-a-table) Tables go under the top-level `tables:` key. Each table gets a name and a list of fields: ```yaml tables: posts: fields: - name: id type: bigserial primary_key: true - name: user_id foreign_key: references: auth.users.id on_delete: cascade - name: title type: text required: true - name: published_at type: timestamptz default: now() ``` Every table must have at least one field marked `primary_key: true`. The migrator will not inject one for you. ## Field types [Section titled “Field types”](#field-types) The `type` field accepts standard Postgres type names. The most commonly used ones: | Type | Notes | | ----------------------------- | ----------------------------------------------------------------------- | | `bigserial` | Auto-incrementing 64-bit integer. Use for surrogate PKs. | | `uuid` | UUID. Pair with `default: uuid_v7()` or `default: uuid_v4()`. | | `text` | Unbounded string. Also accepts `varchar(n)` and `char(n)`. | | `int` / `bigint` / `smallint` | Integer variants. | | `bool` / `boolean` | True/false. | | `numeric` / `decimal` | Exact decimal arithmetic. | | `float` / `real` / `double` | Floating-point numbers. | | `date` | Calendar date (no time). | | `time` / `timetz` | Time of day, with or without timezone. | | `timestamp` / `timestamptz` | Date + time. `timestamptz` is timezone-aware and usually what you want. | | `jsonb` / `json` | JSON data. Prefer `jsonb` for indexing and operators. | | `bytea` | Raw binary data. | | `inet` / `cidr` | IP address or network. | | `serial` / `smallserial` | Smaller auto-increment variants. | `type` is validated against a fixed allowlist (roughly: the integer/serial variants, `text`/`varchar`/`char`, `boolean`, `numeric`/`decimal`/`real`/`double`/`float`, the date/time types, `uuid`, `json`/`jsonb`, `bytea`, `inet`/`cidr`/`macaddr`, `money`, the geometric types, `tsquery`/`tsvector`, `xml`, and `bit`) — not an arbitrary Postgres type name. `varchar(n)`/`char(n)`/`bit(n)` parameterization and `[]` array suffixes are supported; unlisted types (e.g. `citext`, `hstore`, `ltree`, custom enums) fail validation with `unknown type`. ## Field options [Section titled “Field options”](#field-options) | Option | Type | Description | | ------------- | ----------------- | -------------------------------------------------------------------------------------------------------------------------------- | | `name` | string | Column name. Required. | | `type` | string | Postgres type. Required unless `foreign_key` is set (see [Foreign keys](#foreign-keys) for how the type is picked in that case). | | `primary_key` | bool | Marks this column as the primary key. | | `required` | bool | Adds a `NOT NULL` constraint. | | `unique` | bool | Adds a `UNIQUE` constraint. | | `default` | string or literal | Column default. See [defaults](#defaults) below. | | `enum` | list of strings | Restricts column values to the given set. Only valid on `text`/`varchar`/`char` types. | | `foreign_key` | object | Declares a foreign key. See [Foreign keys](#foreign-keys). | | `check` | string | Arbitrary SQL check expression added as a `CHECK` constraint. | | `min` / `max` | number | Numeric range constraints. Applied as check constraints. | | `pattern` | string | Regex pattern constraint (applied as a check). | ### Defaults [Section titled “Defaults”](#defaults) The `default` option accepts: * A literal value: `true`, `0`, `"pending"` * One of the allowed SQL functions: `now()`, `uuid_v7()`, `uuid_v4()`, `current_date`, `current_time` Note: `uuid_v7()` and `uuid_v4()` currently both generate a v4 UUID (`gen_random_uuid()`) — true v7 generation isn’t wired up yet. Use whichever name reads better; there’s no behavioral difference today. ```yaml - name: status type: text required: true enum: [draft, published, archived] default: draft - name: created_at type: timestamptz required: true default: now() - name: id type: uuid primary_key: true default: uuid_v7() ``` ## Foreign keys [Section titled “Foreign keys”](#foreign-keys) Declare a foreign key with `foreign_key.references` pointing to `table.column` or `schema.table.column`: ```yaml - name: user_id foreign_key: references: auth.users.id on_delete: cascade ``` When `foreign_key` is the only option on a field, the column type uses a heuristic: `UUID` for `auth.users.id`, `BIGINT` for all other references. The type is not inferred from the actual referenced column. **`on_delete` options:** | Value | Postgres behavior | | ---------- | ------------------------------------------------------- | | `cascade` | Delete child rows when the parent is deleted. | | `restrict` | Prevent deletion of the parent if children exist. | | `set_null` | Set the FK column to `NULL` when the parent is deleted. | Omit `on_delete` to default to `restrict`. ## Table schema [Section titled “Table schema”](#table-schema) By default a table lives in the `public` Postgres schema. Set `schema:` on the table to place it elsewhere: ```yaml tables: events: schema: analytics fields: - name: id type: bigserial primary_key: true ``` `auth` and `storage` are reserved — they’re owned by the framework, and declaring a table with `schema: auth` or `schema: storage` fails validation. Table and column names must start with a lowercase letter and contain only lowercase letters, digits, and underscores, and can’t be a reserved SQL keyword. ## Indexes [Section titled “Indexes”](#indexes) Add indexes under the table’s `indexes:` key: ```yaml tables: posts: fields: - name: id type: bigserial primary_key: true - name: author_id type: bigint indexes: - columns: [author_id] - columns: [author_id, id] unique: true ``` Set `where:` for a partial index: `where: "status = 'published'"`. ## How migrations work [Section titled “How migrations work”](#how-migrations-work) * **Additive changes apply immediately.** New tables, new columns, new indexes, new policies — the migrator adds them on the next run. * **Drops need an opt-in under `inz serve`.** Removing a table or column from the YAML drops it, along with its data. `inz serve` refuses such a plan unless you pass `--allow-destructive` (or set `INSTANCEZ_ALLOW_DESTRUCTIVE=true`), and the error names what would have been dropped. `inz dev` still applies drops without asking, since rebuilding the schema is the normal dev loop, but it logs a warning listing each one. Be careful with watch mode, which re-applies the diff on every save. * **Renames are declared, not detected.** The migrator compares two versions of your YAML, and a rename looks exactly like a drop plus an add. Use `renamed_from:` so it renames in place instead. See below. * **The migrator never injects columns.** No hidden `id`, `created_at`, or `updated_at` is added. Every column must be declared. * **Dev mode watches for changes.** `inz dev` re-applies the schema diff on every save, so the feedback loop is instant. `inz validate` checks the YAML for errors without touching the database. ## Renaming a table or column [Section titled “Renaming a table or column”](#renaming-a-table-or-column) The migrator only sees two versions of your config. If you change a column’s `name`, the old name is gone and a new one has appeared, which is indistinguishable from deleting one column and adding another. Applied literally, that discards the column’s data. Declare the previous name with `renamed_from:` and the migrator issues a rename instead: ```yaml tables: notes: fields: - name: id type: bigserial primary_key: true - name: content type: text renamed_from: body ``` That produces `ALTER TABLE notes RENAME COLUMN body TO content`, so the values stay put. Tables take the same key: ```yaml tables: articles: renamed_from: posts fields: - name: id type: bigserial primary_key: true ``` Renames run before everything else in the migration, so you can rename a table and its columns in one edit, and combine a rename with a type change on the same column. A few things worth knowing: * Once the rename has been applied, `renamed_from:` does nothing on later runs, because the stored config already carries the new name. You can leave it in place or delete it at your next cleanup. * If the old name never existed, the field is treated as a plain new column. * If both names exist in the live schema, no rename happens. The migrator will not overwrite a column that is already there. * Foreign keys follow a rename automatically, since Postgres renames the constraint’s target with the object. Remember to update any `references:` in your YAML that pointed at the old name. Renaming is the one schema change you have to declare. Everything else is inferred from the config. ## What’s next [Section titled “What’s next”](#whats-next) * [RLS](/instancez/build/rls/) — lock down table access with row-level security policies * [Auth](/instancez/build/auth/) — configure authentication, JWT expiry, and OAuth providers
# Storage
> File upload and download with local or S3 backends. Bucket policies enforced by RLS.
Buckets are declared in `instancez.yaml`. Objects are stored locally or in S3. Authorization is enforced by RLS policies using the same `auth.uid()` helpers available on your own tables. ## Declaring buckets [Section titled “Declaring buckets”](#declaring-buckets) ```yaml storage: avatars: public: true max_size: 5MB types: - image/* rls: - operations: [insert] with_check: "auth.uid() IS NOT NULL" - operations: [update] using: "auth.uid() IS NOT NULL" with_check: "auth.uid() IS NOT NULL" - operations: [delete] using: "auth.uid() IS NOT NULL" documents: public: false max_size: 10MB rls: - operations: [select, insert, update, delete] using: "auth.uid() IS NOT NULL" with_check: "auth.uid() IS NOT NULL" ``` | Key | Type | Description | | ---------- | ------ | ---------------------------------------------------------------------------------------------------- | | `public` | bool | When `true`, objects are downloadable without a JWT via `/storage/v1/object/public/<bucket>/<path>`. | | `max_size` | string | Maximum object size. Accepts `KB`, `MB`, `GB` suffixes. Omit to use the default 50MB limit. | | `types` | list | Allowed MIME types. Wildcards supported (`image/*`). Omit to allow all types. | | `rls` | list | RLS policies on `storage.objects`. Same syntax as table RLS. | Buckets are managed exclusively through `instancez.yaml` — the migrator creates or updates them on boot. ## Using from a Supabase client [Section titled “Using from a Supabase client”](#using-from-a-supabase-client) instancez exposes the same storage API as Supabase. Any Supabase client library works — examples below use `@supabase/supabase-js`: ```js // Upload await supabase.storage.from('avatars').upload('photo.png', file) await supabase.storage.from('avatars').upload('photo.png', file, { upsert: true }) // Public URL (public buckets) const { data } = supabase.storage.from('avatars').getPublicUrl('photo.png') // Signed URL (private buckets, expires in seconds) const { data } = await supabase.storage.from('documents').createSignedUrl('report.pdf', 3600) // List const { data } = await supabase.storage.from('avatars').list('', { limit: 100 }) // Delete await supabase.storage.from('avatars').remove(['photo.png']) ``` Uploading to an existing path without `upsert: true` returns a 409 error. Signed URLs are authorized when they are created, not when they are redeemed. `createSignedUrl` checks the bucket’s `select` policy before returning a download URL, and `createSignedUploadUrl` checks the `insert` policy before returning an upload token. If you cannot read or write an object directly, you cannot get a signed URL for it either. Redeeming the token needs no further auth (the token is the grant), so the check happens when the URL is minted. ## Storage providers [Section titled “Storage providers”](#storage-providers) ### Local (default) [Section titled “Local (default)”](#local-default) ```yaml providers: storage: type: local path: ./uploads # optional, defaults to ./uploads ``` ### S3-compatible [Section titled “S3-compatible”](#s3-compatible) Works with AWS S3, Cloudflare R2, MinIO, Tigris, and any S3-compatible service. ```yaml providers: storage: type: s3 bucket: "${MY_S3_BUCKET}" region: "${MY_S3_REGION}" access_key_id: "${MY_S3_ACCESS_KEY_ID}" secret_access_key: "${MY_S3_SECRET_ACCESS_KEY}" endpoint: "" # optional: set for non-AWS endpoints (e.g. Cloudflare R2) ``` ## Direct upload (serverless) [Section titled “Direct upload (serverless)”](#direct-upload-serverless) When using the S3 provider, you can upload files directly to S3 without routing bytes through instancez. Call `POST /api/storage/<bucket>/sign` to get a presigned upload URL, then `PUT` the file straight to S3: ```js const { id, upload_url } = await fetch('/api/storage/avatars/sign', { method: 'POST', headers: { 'Authorization': `Bearer ${jwt}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ content_type: file.type, size: file.size }), }).then(r => r.json()) await fetch(upload_url, { method: 'PUT', headers: { 'Content-Type': file.type }, body: file }) ``` Use `GET /api/storage/<bucket>/<id>` to get a presigned download URL later. ## What’s next [Section titled “What’s next”](#whats-next) * [RLS](/instancez/build/rls/) — write the policies that gate `storage.objects` access * [Functions](/instancez/build/functions/) — process uploads server-side with `ctx.serviceClient`
# Coding Agents
> Install the instancez skill into Claude Code, Codex, Cursor, and other coding agents so they know the YAML syntax, RLS patterns, and the inz CLI.
instancez is built to be written by agents: the whole backend is one YAML file an agent can read end to end. The repo ships an [agent skill](https://github.com/instancez/instancez/blob/main/skills/instancez/SKILL.md) that teaches your agent the `instancez.yaml` syntax, RLS patterns, the `rpc:` vs `functions:` split, and the `inz` CLI, so it stops guessing and starts running `inz validate`. ## Install with the skills CLI (any agent) [Section titled “Install with the skills CLI (any agent)”](#install-with-the-skills-cli-any-agent) The [skills CLI](https://github.com/vercel-labs/skills) installs the skill from this repo into whichever agents it detects in your machine or project: ```bash npx skills add instancez/instancez ``` Run it in your project directory. It auto-detects installed agents and prompts if it finds none. To target specific agents: ```bash npx skills add instancez/instancez -a claude-code # -> .claude/skills/ npx skills add instancez/instancez -a codex # -> .agents/skills/ npx skills add instancez/instancez -a cursor # -> .agents/skills/ npx skills add instancez/instancez -a opencode # -> .agents/skills/ npx skills add instancez/instancez -a gemini-cli # -> .agents/skills/ npx skills add instancez/instancez -a github-copilot # -> .agents/skills/ ``` Add `-g` to install globally (for example `~/.claude/skills/`) instead of into the current project. ## Claude Code plugin [Section titled “Claude Code plugin”](#claude-code-plugin) Claude Code users can install it as a plugin instead, straight from this repo: ```plaintext /plugin marketplace add instancez/instancez /plugin install instancez@instancez ``` The plugin route keeps the skill updated with the marketplace; the skills CLI route vendors a copy into your project. ## Manual (everything else) [Section titled “Manual (everything else)”](#manual-everything-else) The skill is a single Markdown file, so any agent that reads project context can use it. Download it and point your agent’s instructions file at it: ```bash curl -fsSL https://raw.githubusercontent.com/instancez/instancez/main/skills/instancez/SKILL.md \ -o docs/instancez-skill.md ``` Then add a line to your `AGENTS.md` (or `CLAUDE.md`, `.cursorrules`, …): ```markdown When working on the instancez backend (instancez.yaml, functions/), read docs/instancez-skill.md first. ``` ## What the skill covers [Section titled “What the skill covers”](#what-the-skill-covers) * The edit loop: `inz init`, edit `instancez.yaml`, `inz validate`, `inz dev` * Table syntax: fields, types, defaults, enums, foreign keys, indexes * RLS policies: the three roles, `auth.uid()` helpers, common patterns, and the `update` policy pitfall * Auth, storage buckets, SQL `rpc:`, and Node.js `functions:` with the handler contract * Deploying with `inz bundle`, `inz serve`, and `inz cloud deploy` It also carries the rules agents tend to trip on: no auto-added `id` columns, YAML removals become DROPs, and the `auth`/`storage` schemas are reserved.
# instancez Cloud
> Deploy a project to instancez Cloud with inz cloud login and inz cloud deploy.
instancez Cloud runs your project as a managed service. You keep editing `instancez.yaml` locally, then push it to a hosted project with `inz cloud deploy`. The CLI handles auth, uploads the config, and deploys straight to the project — there’s no separate draft/production split to manage. ## Sign in [Section titled “Sign in”](#sign-in) ```bash inz cloud login ``` This runs a device-code flow: the CLI prints a one-time code, opens your browser to confirm it, and stores a Personal Access Token at `~/.instancez/credentials`. Later commands reuse that token, so you sign in once per machine. Pass `--force` to re-authenticate. If you run `inz cloud deploy` or `inz cloud status` while signed out on an interactive terminal, the CLI offers to sign you in first. In a non-interactive session (CI, scripts) it stops and tells you to run `inz cloud login`, since it can’t open a browser. ## Link a project [Section titled “Link a project”](#link-a-project) A cloud project is identified by `project.cloud.project_id` in your `instancez.yaml`. Create the project and write that field in one step, as part of your first deploy: ```bash inz cloud deploy --new ``` `--new` only creates a project after local validation passes, so you never end up with an empty project for an invalid config. It writes the returned id into your config: ```yaml project: name: my-app cloud: project_id: <generated-by-deploy> ``` Running `inz cloud deploy --new` again once `project.cloud.project_id` is already set is an error. Drop `--new` to deploy to the linked project, or use `--project <id>` to target a different one for that run without editing the file. ## Deploy [Section titled “Deploy”](#deploy) ```bash inz cloud deploy ``` Deploy first shows a page-free diff of what would change, then prompts `Deploy? [y/N]`. Nothing is written yet at this point, not even function sources. A bare Enter is treated as “no”. Pass `--yes` (`-y`) to skip the prompt in scripts. Only after confirming (or with `--yes`) does it upload function sources and the YAML and trigger a rebuild. ### Code functions [Section titled “Code functions”](#code-functions) If your project declares code functions, `inz cloud deploy` uploads your function sources along with the yaml, and the cloud builds the bundle. You do not need an S3 bucket or a local npm step for deployment. `--functions-bundle-dest` no longer exists on `inz cloud deploy`. For self-hosted projects using `inz serve --bundle`, use `inz bundle --output s3://my-bucket/functions/` to build and upload the bundle yourself. ## Continuous deployment (GitHub Actions) [Section titled “Continuous deployment (GitHub Actions)”](#continuous-deployment-github-actions) `inz cloud deploy` needs a Personal Access Token, but the device-code flow behind `inz cloud login` needs a browser, and CI can’t open one. Instead, sign in once from your machine, copy the token, and hand CI the value directly: ```bash inz cloud login cat ~/.instancez/credentials # copy the "pat" value ``` Store that value as a repository secret named `INSTANCEZ_CLOUD_PAT` (**Settings → Secrets and variables → Actions**). The CLI reads `INSTANCEZ_CLOUD_PAT` directly — no credentials file needs to be written in CI. Treat it like a password: it authenticates as whichever account ran `inz cloud login`, so revoke it from the instancez Cloud dashboard if it leaks. Since each project has a single deployed version (no draft to review changes in first), a review environment means a genuinely separate cloud project. Skip `project_id` in `instancez.yaml` and pass `--project` per job instead, pointing at different project ids held in their own secrets: .github/workflows/deploy.yml ```yaml name: Deploy to instancez Cloud on: pull_request: push: branches: [main] jobs: deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Install inz run: | curl -fsSL https://get.instancez.ai | sh echo "$HOME/.local/bin" >> "$GITHUB_PATH" # 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 }} ``` `--project` targets a project for that run only; it never edits `instancez.yaml`, so both jobs can check out the exact same file. ## Check status [Section titled “Check status”](#check-status) ```bash inz cloud status ``` This prints the project name, id, URL, and deploy status. It’s separate from `inz doctor`, which checks your local environment rather than the cloud project. ## Other commands [Section titled “Other commands”](#other-commands) ```bash inz cloud whoami # print the signed-in account's email inz cloud logout # forget the local Personal Access Token ``` `inz cloud logout` removes the token from `~/.instancez/credentials`. The token stays valid on the server until you revoke it from the dashboard. ## Pointing at a different API [Section titled “Pointing at a different API”](#pointing-at-a-different-api) The CLI talks to `https://my.instancez.ai/api` by default. To target a different instancez Cloud API, set `INSTANCEZ_CLOUD_API`: ```bash export INSTANCEZ_CLOUD_API=https://cloud.example.com/api ```
# Docker
> Run instancez with Docker or Docker Compose.
## Quick start with Docker Compose [Section titled “Quick start with Docker Compose”](#quick-start-with-docker-compose) instancez provisions all required Postgres roles automatically on startup — no init SQL scripts needed. Create the following files in a new directory: **`compose.yaml`** ```yaml services: postgres: image: postgres:17-alpine environment: POSTGRES_USER: postgres POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} POSTGRES_DB: instancez volumes: - pgdata:/var/lib/postgresql/data healthcheck: test: ["CMD", "pg_isready", "-U", "postgres"] interval: 2s timeout: 3s retries: 10 instancez: image: ghcr.io/instancez/instancez:1.2.3 # pin to a released version, see Image tags below ports: - "8080:8080" environment: INSTANCEZ_DATABASE_URL: postgres://postgres:${POSTGRES_PASSWORD}@postgres:5432/instancez?sslmode=disable INSTANCEZ_PUBLISHABLE_KEY: ${PUBLISHABLE_KEY} INSTANCEZ_SECRET_KEY: ${SECRET_KEY} volumes: - ./instancez.yaml:/app/instancez.yaml - uploads:/app/uploads command: ["inz", "serve", "--migrate"] depends_on: postgres: condition: service_healthy volumes: pgdata: uploads: ``` **`.env`** (never commit this file) ```plaintext POSTGRES_PASSWORD=change-me PUBLISHABLE_KEY=inz_publishable_your-key SECRET_KEY=inz_secret_your-key ``` See [Environment Variables](/instancez/deploy/env-vars/) for the full variable reference. Start everything: ```bash docker compose up ``` The API is ready at `http://localhost:8080` once the `instancez` container logs `listening`. ## Standalone Docker [Section titled “Standalone Docker”](#standalone-docker) ```bash docker run -d \ -p 8080:8080 \ -e INSTANCEZ_DATABASE_URL="postgres://postgres:password@host:5432/instancez" \ -e INSTANCEZ_PUBLISHABLE_KEY="inz_publishable_your-key" \ -e INSTANCEZ_SECRET_KEY="inz_secret_your-key" \ -v $(pwd)/instancez.yaml:/app/instancez.yaml \ ghcr.io/instancez/instancez:1.2.3 \ inz serve --migrate ``` ## Image tags [Section titled “Image tags”](#image-tags) | Tag | Description | | ---------------------------------------------- | ------------------------------------------------------------------------------- | | `ghcr.io/instancez/instancez:1.2.3` | Released version (note: no leading `v`), multi-arch (linux/amd64 + linux/arm64) | | `ghcr.io/instancez/instancez:1.2.3-standalone` | Same image, explicit alias for the default flavor | | `ghcr.io/instancez/instancez:1.2.3-lambda` | Lambda-flavored image, also multi-arch | | `ghcr.io/instancez/instancez:dev-<sha7>` | Built from every push to `main`, not a release | There is no `latest` tag — always pin to a version. For AWS Lambda, see the [Lambda deployment guide](/instancez/deploy/lambda/) — Lambda requires a single-arch image from a private ECR registry, so the multi-arch `-lambda` tag above cannot be used directly. ## Health checks [Section titled “Health checks”](#health-checks) | Endpoint | Behaviour | | ------------- | --------------------------------------------------------- | | `GET /live` | Returns `200` when the process is alive | | `GET /health` | Returns `200` when the app is initialized | | `GET /ready` | Returns `200` when Postgres is reachable; `503` otherwise | Use `/ready` for load-balancer health checks and `/live` for liveness probes.
# Environment Variables
> Complete environment variable reference for instancez.
## Required [Section titled “Required”](#required) Both API keys plus one database credential path — either the single superuser DSN or the scoped owner/auth pair — must be set before `inz serve` will start. `inz dev` generates the two keys for you on first run. | Variable | Description | | -------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `INSTANCEZ_DATABASE_URL` | Superuser Postgres DSN (e.g. `postgres://postgres:password@localhost:5432/mydb`). The CLI provisions all required roles (`instancez_owner`, `authenticator`, `anon`, `authenticated`, `service_role`) automatically on startup. This is what `inz init` scaffolds for local dev. | | `INSTANCEZ_OWNER_DATABASE_URL` + `INSTANCEZ_AUTH_DATABASE_URL` | Alternative to `INSTANCEZ_DATABASE_URL`: a pre-provisioned `instancez_owner` DSN and a pre-provisioned `authenticator` DSN, used instead of a superuser login. This is the path `inz init` writes into `.production.env`, and the one typically used in production where a superuser DSN isn’t available. | | `INSTANCEZ_PUBLISHABLE_KEY` | Publishable API key (value like `inz_publishable_…`). Client-safe, maps to the `anon` role, and is what client apps pass. Sent in the `apikey` header. Also settable with `--publishable-key`. | | `INSTANCEZ_SECRET_KEY` | Secret API key (value like `inz_secret_…`). Server-side only, maps to `service_role`, and unlocks the admin API and dashboard login. Sent in the `apikey` header. Also settable with `--secret-key`. Leave unset to disable the admin routes (they return 404). | ## Config and watch [Section titled “Config and watch”](#config-and-watch) | Variable | Flag equivalent | Default | Description | | -------------------------- | ------------------ | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `INSTANCEZ_CONFIG` | `--config` | `instancez.yaml` | Config source. Accepts a local file path or an `s3://bucket/key` URI. | | `INSTANCEZ_BUNDLE` | `--bundle` | — | Bundle source (config + function source in one archive), built with `inz bundle`. Accepts a local path or `s3://bucket/key`. Takes the place of `INSTANCEZ_CONFIG` when set. | | `INSTANCEZ_WATCH` | `--watch` | `false` | Re-apply config when the source changes. For S3 sources, polls on the watch interval. | | `INSTANCEZ_WATCH_INTERVAL` | `--watch-interval` | `60s` | Poll interval for S3 config sources. Minimum 10 s. | ## Server [Section titled “Server”](#server) | Variable | Flag equivalent | Default | Description | | -------------------- | --------------- | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | | `INSTANCEZ_PORT` | `--port` | `8080` (from config) | HTTP listen port. Overrides the value in `instancez.yaml`. | | `INSTANCEZ_BASE_URL` | — | `http://localhost:<port>` | Base URL used to build links in auth emails (verification, magic link, password recovery). Set this to your public URL in production. | ## Database role names [Section titled “Database role names”](#database-role-names) By default instancez maps the fixed JWT wire values (`anon`, `authenticated`, `service_role`) to Postgres roles of the same names. These variables let you use different Postgres role names while keeping the JWT wire protocol unchanged. | Variable | Default Postgres role | Description | | --------------------------------- | --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `INSTANCEZ_DB_AUTHENTICATOR_ROLE` | `authenticator` | Login role for the request pool | | `INSTANCEZ_DB_ANON_ROLE` | `anon` | Role assumed for unauthenticated requests | | `INSTANCEZ_DB_AUTHENTICATED_ROLE` | `authenticated` | Role assumed for requests with a valid user JWT | | `INSTANCEZ_DB_SERVICE_ROLE` | `service_role` | Role assumed for requests authenticated with the secret key; has `BYPASSRLS` | | `INSTANCEZ_DB_SEED_ROLE` | — (empty) | Optional Postgres role to run seed statements as. Unlike the roles above, there’s no default — leave unset unless you have a specific seeding role provisioned. | The JWT `role` claim values (`anon`, `authenticated`, `service_role`) are part of the Supabase wire protocol and are never renamed, regardless of the Postgres role names you configure here. ## Dashboard [Section titled “Dashboard”](#dashboard) | Variable | Flag equivalent | Default | Description | | ---------------------------------- | -------------------------- | ---------- | -------------------------------------------------------------------------------------------------------- | | `INSTANCEZ_DASHBOARD` | `--dashboard` | `disabled` | Dashboard mode: `disabled`, `readonly`, or `readwrite`. Enable `readwrite` only in trusted environments. | | `INSTANCEZ_DASHBOARD_WRITE_DOTENV` | `--dashboard-write-dotenv` | `false` | Allow the dashboard to write secrets to a `.env` file. Requires `INSTANCEZ_DOTENV_PATH`. | | `INSTANCEZ_DOTENV_PATH` | `--dotenv-path` | — | Path to the `.env` file the dashboard may write when `INSTANCEZ_DASHBOARD_WRITE_DOTENV` is set. | ## Storage providers [Section titled “Storage providers”](#storage-providers) Storage provider credentials are set in `instancez.yaml` under `providers.storage`. Use `${VAR}` interpolation to reference environment variables without hardcoding secrets: ```yaml providers: storage: type: s3 bucket: ${INSTANCEZ_S3_BUCKET} region: ${INSTANCEZ_S3_REGION} access_key_id: ${INSTANCEZ_S3_ACCESS_KEY_ID} secret_access_key: ${INSTANCEZ_S3_SECRET_ACCESS_KEY} endpoint: ${INSTANCEZ_S3_ENDPOINT} # optional; for S3-compatible stores ``` instancez interpolates `${VAR}` references in `instancez.yaml` at load time. Any environment variable name works; the names above are conventions. One variable is read directly from the process environment (no YAML reference needed): | Variable | Description | | ------------------------------ | -------------------------------------------------------------------------------------------------------------------- | | `INSTANCEZ_STORAGE_KEY_PREFIX` | Optional prefix prepended to all object keys in the storage bucket. Useful for sharing a bucket across environments. | ## Email providers [Section titled “Email providers”](#email-providers) Email provider credentials follow the same YAML interpolation pattern: ```yaml providers: email: type: resend api_key: ${INSTANCEZ_RESEND_API_KEY} ``` Set `INSTANCEZ_RESEND_API_KEY` (or any name you choose) in the environment and reference it in `instancez.yaml`. ## Config S3 credentials [Section titled “Config S3 credentials”](#config-s3-credentials) When `INSTANCEZ_CONFIG` points to an S3 URI, a separate set of variables controls the S3 client used to fetch the config file. These are distinct from the storage-provider credentials above. | Variable | Description | | ---------------------- | ---------------------------------------- | | `S3_REGION` | AWS region of the config bucket | | `S3_ENDPOINT` | Custom endpoint for S3-compatible stores | | `S3_ACCESS_KEY_ID` | Access key ID | | `S3_SECRET_ACCESS_KEY` | Secret access key | If these are unset, the S3 client falls back to the standard credential chain (IAM role, `~/.aws/credentials`, etc.). ## Code function secrets [Section titled “Code function secrets”](#code-function-secrets) Environment variables passed to code functions use the `INSTANCEZ_ENV_` prefix. Reference them in `instancez.yaml` under a function’s `env:` block: ```yaml functions: my-function: runtime: node file: functions/my-function.js env: STRIPE_SECRET_KEY: ${INSTANCEZ_ENV_STRIPE_SECRET_KEY} DATABASE_URL: ${INSTANCEZ_ENV_DATABASE_URL} ``` Only variables matching the pattern `INSTANCEZ_ENV_*` are forwarded to function workers. They are never written to the worker process environment directly; they are passed via a secure in-memory channel. Set them in the host environment (or `.production.env`) and reference them with `${INSTANCEZ_ENV_YOUR_KEY}` in the YAML. ## Migrations [Section titled “Migrations”](#migrations) | Variable | Flag equivalent | Default | Description | | ----------------------------- | --------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `INSTANCEZ_MIGRATE` | `--migrate` | `false` | Run pending schema migrations on startup | | `INSTANCEZ_ALLOW_DESTRUCTIVE` | `--allow-destructive` | `false` | Permit `DROP TABLE` and `DROP COLUMN` in migrations. `inz serve` rejects a plan that drops a table or column unless this is set. `inz dev` permits drops regardless and logs each one. | ## Cloud [Section titled “Cloud”](#cloud) | Variable | Description | | --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `INSTANCEZ_CLOUD_API` | Overrides the cloud API base URL used by `inz cloud` commands. Defaults to `https://my.instancez.ai/api`. | | `INSTANCEZ_CLOUD_PAT` | Personal access token for `inz cloud` commands, read directly from the environment. Lets CI authenticate without a `~/.instancez/credentials` file — see [Cloud deploys](/instancez/deploy/cloud/). |
# Kubernetes
> Deploy instancez on Kubernetes using the official Helm chart.
## Overview [Section titled “Overview”](#overview) instancez ships a Helm chart at `helm/instancez/` in the repository. It deploys the instancez backend and, optionally, a bundled PostgreSQL instance. Sensitive values (`adminKey` and the Postgres password) are auto-generated on first install and preserved across upgrades. ## Prerequisites [Section titled “Prerequisites”](#prerequisites) * [kubectl](https://kubernetes.io/docs/tasks/tools/) configured against your cluster * [Helm 3.x](https://helm.sh/docs/intro/install/) * A running Kubernetes cluster ## Quick start (bundled Postgres) [Section titled “Quick start (bundled Postgres)”](#quick-start-bundled-postgres) The default chart values enable a bundled Postgres instance. To install with all defaults: ```bash helm install instancez ./helm/instancez ``` `adminKey` and the Postgres password are auto-generated as 32-character random strings and stored in a Kubernetes Secret named `instancez`. They are stable — the values are generated once and reused on every subsequent `helm upgrade`. Retrieve the generated credentials: ```bash kubectl get secret instancez -o jsonpath='{.data.adminKey}' | base64 -d ``` ## Providing your own values [Section titled “Providing your own values”](#providing-your-own-values) Pass values on the command line with `--set`: ```bash helm install instancez ./helm/instancez \ --set adminKey=my-admin-key ``` Or save them in a values file and pass it with `-f`: my-values.yaml ```yaml adminKey: my-admin-key ``` ```bash helm install instancez ./helm/instancez -f my-values.yaml ``` ### Custom instancez config [Section titled “Custom instancez config”](#custom-instancez-config) The `config` value is written verbatim to a ConfigMap and mounted as `/app/instancez.yaml` inside the pod. Override it in your values file to configure tables, RLS policies, and other instancez settings: my-values.yaml ```yaml config: | version: 1 project: name: my-project server: port: 8080 tables: - name: posts columns: - name: id type: uuid primaryKey: true ``` See [instancez.yaml reference](/instancez/api-reference/config/) for the full schema. ## External Postgres [Section titled “External Postgres”](#external-postgres) To use an existing Postgres instance instead of the bundled one, disable the bundled Postgres and provide a superuser DSN: ```bash helm install instancez ./helm/instancez \ --set postgres.enabled=false \ --set externalPostgres.url="postgres://superuser:pass@your-db:5432/instancez" ``` The DSN must be a superuser connection — instancez provisions the `instancez_owner` and `authenticator` roles on startup and requires `CREATEROLE CREATEDB BYPASSRLS` privileges. ## Ingress [Section titled “Ingress”](#ingress) Enable and configure Ingress in your values file: ```yaml ingress: enabled: true className: nginx host: instancez.example.com ``` TLS can be added via the `ingress.tls` list: ```yaml ingress: enabled: true className: nginx host: instancez.example.com tls: - hosts: - instancez.example.com secretName: instancez-tls ``` ## Upgrading [Section titled “Upgrading”](#upgrading) ```bash helm upgrade instancez ./helm/instancez ``` Auto-generated secrets (`adminKey` and the bundled Postgres password) are read from the existing Kubernetes Secret and not regenerated on upgrade, so credentials are preserved. To upgrade with a new values file: ```bash helm upgrade instancez ./helm/instancez -f my-values.yaml ``` ## Password rotation [Section titled “Password rotation”](#password-rotation) To rotate credentials: 1. Update the `instancez` Kubernetes Secret directly — edit `adminKey` or `databaseUrl` (and the bundled Postgres password if applicable). 2. Restart the pod so instancez picks up the new values: ```bash kubectl rollout restart deployment/instancez ``` instancez re-reads the superuser DSN on startup and syncs all Postgres role passwords, so the `instancez_owner` and `authenticator` role passwords are updated automatically from the new DSN. ## Health checks [Section titled “Health checks”](#health-checks) | Endpoint | Behaviour | | ------------- | --------------------------------------------------------- | | `GET /live` | Returns `200` when the process is alive | | `GET /health` | Always returns `200` once the process is serving requests | | `GET /ready` | Returns `200` when Postgres is reachable; `503` otherwise | The chart configures liveness (`/live`) and readiness (`/ready`) probes automatically. Use `/ready` for external load-balancer checks.
# AWS Lambda
> Deploy instancez as an AWS Lambda container function.
instancez runs on Lambda as a container function. The Lambda Web Adapter translates Lambda invocations into HTTP requests to `inz serve` on port 8080 — no handler shim required. ## Build and push to ECR [Section titled “Build and push to ECR”](#build-and-push-to-ecr) Lambda requires a **single-arch** image in a private ECR repository. The public `ghcr.io/instancez/instancez:<version>-lambda` tag is a multi-arch manifest list — Lambda rejects manifest lists, so don’t pull, tag, and push that image directly. Instead build the per-arch image from source using `Dockerfile.lambda`: ```bash VERSION=v0.1.0 # replace with the release/commit you want to pin # Authenticate to ECR aws ecr get-login-password --region us-east-1 | \ docker login --username AWS --password-stdin \ 123456789012.dkr.ecr.us-east-1.amazonaws.com # Build a single-arch (arm64) image from source and push it straight to ECR git clone --branch ${VERSION} --depth 1 https://github.com/instancez/instancez.git cd instancez docker buildx build \ --platform linux/arm64 \ --provenance=false \ -f Dockerfile.lambda \ -t 123456789012.dkr.ecr.us-east-1.amazonaws.com/instancez/prod:${VERSION}-lambda-arm64 \ --push . ``` The image includes `inz serve`, Node.js (for code functions), and the Lambda Web Adapter. The default CMD is `inz serve --migrate`, which runs migrations on every cold start. ## Storage on Lambda [Section titled “Storage on Lambda”](#storage-on-lambda) Lambda functions are stateless and ephemeral — use the S3 storage provider, not local. With S3 configured, instancez exposes a direct upload API at `/api/storage/<bucket>/sign` that returns a presigned S3 URL. The file bytes go straight to S3 without passing through the Lambda function: ```js const { id, upload_url } = await fetch('/api/storage/avatars/sign', { method: 'POST', headers: { 'Authorization': `Bearer ${jwt}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ content_type: file.type, size: file.size }), }).then(r => r.json()) // Upload directly to S3 — Lambda is not in this path await fetch(upload_url, { method: 'PUT', headers: { 'Content-Type': file.type }, body: file }) ``` This avoids Lambda’s 6 MB payload limit and keeps large uploads off the function entirely. See [Storage — Direct upload](/instancez/build/storage/) for the full endpoint spec. ## Lambda configuration [Section titled “Lambda configuration”](#lambda-configuration) When creating or updating the function: | Setting | Value | | ------------ | --------------------------------------------------------------------- | | Architecture | `arm64` | | Memory | 512 MB minimum; 1024 MB recommended for functions with code functions | | Timeout | 30 s minimum; match your slowest expected request | | Package type | Image | ## Environment variables [Section titled “Environment variables”](#environment-variables) Set these on the Lambda function: | Variable | Required | Description | | ------------------------------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `INSTANCEZ_OWNER_DATABASE_URL` | Yes | Privileged DSN used for migrations (`instancez_owner` role) | | `INSTANCEZ_AUTH_DATABASE_URL` | Yes | Request-pool DSN (`authenticator` role) | | `INSTANCEZ_PUBLISHABLE_KEY` | Yes | Publishable API key (`inz_publishable_…`); client-safe, maps to `anon` | | `INSTANCEZ_SECRET_KEY` | Yes | Secret API key (`inz_secret_…`); server-side, maps to `service_role` and unlocks the admin API | | `INSTANCEZ_CONFIG` | No | Config source; defaults to `instancez.yaml` in the working directory. Set to `s3://bucket/key` to load from S3 (see below). Use this only if you have no code functions — it ships the YAML but not function source. | | `INSTANCEZ_BUNDLE` | No | Use instead of `INSTANCEZ_CONFIG` when you have code functions. Points at an archive built by `inz bundle --output s3://bucket/key` that packages `instancez.yaml` and `functions/` together, so function source actually reaches the Lambda instance. | See [Environment Variables](/instancez/deploy/env-vars/) for the full reference. ## Config from S3 [Section titled “Config from S3”](#config-from-s3) On Lambda the working directory is read-only, so the most practical configuration source is S3: ```plaintext INSTANCEZ_CONFIG=s3://my-bucket/my-app/instancez.yaml ``` When `INSTANCEZ_CONFIG` is an S3 URI, instancez fetches the config at startup. The S3 client uses the function’s IAM role by default. To use explicit credentials, set these environment variables (distinct from the storage-provider variables): | Variable | Description | | ---------------------- | ------------------------------------------ | | `S3_REGION` | AWS region of the config bucket | | `S3_ENDPOINT` | Custom endpoint (for S3-compatible stores) | | `S3_ACCESS_KEY_ID` | Access key ID | | `S3_SECRET_ACCESS_KEY` | Secret access key | The IAM role approach is simpler — grant the Lambda execution role `s3:GetObject` on the config object and omit the credential variables. If you have code functions, build and upload a bundle instead (`inz bundle --output s3://my-bucket/my-app/bundle.tar.gz`) and set `INSTANCEZ_BUNDLE` to that URI in place of `INSTANCEZ_CONFIG` — a bare YAML config doesn’t carry function source with it. To enable config watching (re-fetch on poll interval), set `INSTANCEZ_WATCH=true` and `INSTANCEZ_WATCH_INTERVAL=60s` on the function.
# Observability
> Export traces and logs to any OTLP backend with standard OTEL_* environment variables.
instancez exports traces and logs over OTLP using the standard `OTEL_*` environment variables — no instancez-specific config. Export is opt-in and additive: with none of those variables set, the binary behaves exactly as it does today, and stdout/stderr logging keeps working whether export is on or off. ## Enable it [Section titled “Enable it”](#enable-it) Set the usual OTel SDK variables: ```sh export OTEL_EXPORTER_OTLP_ENDPOINT=https://otel-collector.example.com export OTEL_SERVICE_NAME=my-instancez-app export OTEL_EXPORTER_OTLP_HEADERS="Authorization=Bearer secret123" ``` Export turns on if any of these is set to a non-empty value: * `OTEL_EXPORTER_OTLP_ENDPOINT` * `OTEL_TRACES_EXPORTER` * `OTEL_LOGS_EXPORTER` Leave all three unset and instancez skips OTel setup entirely. This gate exists because the OTel SDK’s default exporter points at `localhost:4318` even with no config, and without it every deployment without a collector would fail a batch export on a timer and spam the logs. Logs go out through an slog bridge, so anything already going to `log/slog` — request logs, migration logs, background job logs — is exported without call-site changes. stdout/stderr logging is unaffected either way; OTel export is teed alongside it, not a replacement. Per-request logs carry the request’s `trace_id` and `span_id`, so a log lines up with its trace in the backend. This holds in `inz dev` too: the aligned request line still prints to the console, and the same record is exported through the bridge when OTel is on. ## What gets traced [Section titled “What gets traced”](#what-gets-traced) * **HTTP requests** — every inbound request gets a server span. * **Postgres queries** — child spans for queries against both connection pools, including schema migrations run at startup. * **Resend** — outbound calls to the email API. * **S3** — outbound calls to the storage API. * **Function invocations** — the call from the Go runtime to the Node worker process. instancez injects a `traceparent` header into that call, so the trace context reaches the worker even though nothing inside the worker uses it yet (see [Known gaps](#known-gaps)). ## SDK environment knobs [Section titled “SDK environment knobs”](#sdk-environment-knobs) These are standard OTel SDK variables, read automatically — nothing instancez-specific to configure: | Variable | Purpose | | -------------------------- | ----------------------------------------------------------------------------------------------- | | `OTEL_TRACES_SAMPLER` | Trace sampling strategy (e.g. `traceidratio`, `parentbased_always_on`) | | `OTEL_BSP_*` | Batch span processor tuning (`OTEL_BSP_SCHEDULE_DELAY`, `OTEL_BSP_MAX_EXPORT_BATCH_SIZE`, etc.) | | `OTEL_RESOURCE_ATTRIBUTES` | Extra resource attributes attached to every span and log record | | `OTEL_SERVICE_NAME` | Service name in the resource; defaults to `instancez` if unset | ## Local testing without a backend [Section titled “Local testing without a backend”](#local-testing-without-a-backend) To see spans and logs on your own terminal instead of standing up a collector, use the console exporter: ```sh export OTEL_TRACES_EXPORTER=console export OTEL_LOGS_EXPORTER=console ``` ## Lambda [Section titled “Lambda”](#lambda) instancez runs long-lived behind the Lambda Web Adapter rather than as a per-invocation handler, so the batch exporter and the shutdown flush both work as they do anywhere else. The one wrinkle is that Lambda freezes the container between requests: if a batch is still buffered when the freeze happens, it goes out on the next invocation instead of the current one. Spans and logs arrive delayed, not lost. ## Known gaps [Section titled “Known gaps”](#known-gaps) * **Function code isn’t instrumented yet.** Spans and logs from code running inside the Node worker need the JS OTel SDK wired into the worker bootstrap, which is a later phase. The `traceparent` header injected into the worker call means that work can pick up the existing trace once it lands. * **No metrics yet.** This is traces and logs only; metrics export is a later phase. The existing Prometheus `/metrics` endpoint is unaffected by any of this.
# Self-hosted
> Run instancez on a bare metal server or VPS with inz serve.
## Download the binary [Section titled “Download the binary”](#download-the-binary) ```bash curl -fsSL https://get.instancez.ai | sh ``` This installs `inz` to `~/.local/bin`. Verify the install: ```bash inz version ``` Alternatively, download a release binary directly from [GitHub Releases](https://github.com/instancez/instancez/releases) and place it on your `PATH`. ## Configure [Section titled “Configure”](#configure) Create a `.production.env` file next to `instancez.yaml`. `inz serve` loads this file automatically when the config source is a local file. Shell environment variables always take precedence over values in `.production.env`. ```bash # .production.env — do not commit this file INSTANCEZ_DATABASE_URL=postgres://postgres:password@localhost:5432/mydb INSTANCEZ_PUBLISHABLE_KEY=inz_publishable_your-key INSTANCEZ_SECRET_KEY=inz_secret_your-key ``` Roles (`instancez_owner`, `authenticator`, `anon`, `authenticated`, `service_role`) are provisioned automatically by instancez on first startup. See [Environment Variables](/instancez/deploy/env-vars/) for the full list of available variables. ## Validate config [Section titled “Validate config”](#validate-config) Before deploying a config change, check it for errors: ```bash inz validate ``` This runs a structural check on `instancez.yaml` without connecting to the database. Fix any reported errors before restarting the server. ## Run [Section titled “Run”](#run) ```bash inz serve --migrate ``` `--migrate` applies pending schema migrations on startup. Drop it if you manage migrations separately. The server listens on port 8080 by default; set `INSTANCEZ_PORT` or `--port` to change it. On startup, `inz serve` logs a JSON stream to stdout. The server is ready when you see `"listening"`. ## Health checks [Section titled “Health checks”](#health-checks) | Endpoint | Behaviour | | ------------- | --------------------------------------------------------- | | `GET /live` | Returns `200` when the process is alive | | `GET /health` | Returns `200` when the app is initialized | | `GET /ready` | Returns `200` when Postgres is reachable; `503` otherwise | Use `/ready` for load-balancer health checks. Configure Nginx to check it before routing traffic: ```nginx location /ready { proxy_pass http://127.0.0.1:8080/ready; access_log off; } ```
# Ecommerce Store
> A storefront with a product catalog, per-customer orders, RLS, and Stripe Checkout end to end.
A small online store: customers browse a public product catalog, check out through Stripe, and see only their own orders. One code function builds the Stripe Checkout Session and records a pending order; a webhook marks the order paid once Stripe confirms the payment and emails a receipt. This example brings together most of what instancez does in a single project: auth, tables with RLS, and code functions that call a third-party API. Want something you can run instead of read? [`docs/examples/gearstore`](https://github.com/instancez/instancez/tree/main/docs/examples/gearstore) is a full storefront project with `docker compose up --build`. ## instancez.yaml [Section titled “instancez.yaml”](#instancezyaml) The whole project lives in one file: instancez.yaml ```yaml version: 1 project: name: Storefront description: A small online store with Stripe Checkout. auth: jwt_expiry: 1h refresh_tokens: true allow_signup: true tables: products: fields: - name: id type: uuid default: uuid_v7() primary_key: true - name: name type: text required: true - name: description type: text - name: price_cents type: integer required: true min: 0 - name: currency type: text default: "usd" - name: active type: boolean default: true rls: # Anyone can read products that are for sale. - operations: [select] using: "active" orders: fields: - name: id type: uuid default: uuid_v7() primary_key: true - name: user_id type: uuid required: true foreign_key: references: auth.users.id on_delete: cascade - name: status type: text enum: [pending, paid, fulfilled, cancelled] default: "pending" - name: total_cents type: integer required: true - name: currency type: text default: "usd" - name: stripe_session_id type: text - name: created_at type: timestamptz default: now() rls: # A customer reads only their own orders. Writes come from the functions # running as service_role (which bypasses RLS), so there's no write policy. - operations: [select] using: "auth.uid() = user_id" order_items: fields: - name: id type: uuid default: uuid_v7() primary_key: true - name: order_id type: uuid required: true foreign_key: references: orders.id on_delete: cascade - name: product_id type: uuid required: true foreign_key: references: products.id - name: quantity type: integer required: true min: 1 - name: unit_price_cents type: integer required: true rls: # Readable only through an order the caller owns. - operations: [select] using: "order_id IN (SELECT id FROM orders WHERE user_id = auth.uid())" functions: create-checkout: runtime: node file: functions/create-checkout.js auth_required: true # you have to be signed in to buy timeout: 15s env: STRIPE_SECRET_KEY: ${INSTANCEZ_ENV_STRIPE_SECRET_KEY} CHECKOUT_SUCCESS_URL: ${INSTANCEZ_ENV_CHECKOUT_SUCCESS_URL} CHECKOUT_CANCEL_URL: ${INSTANCEZ_ENV_CHECKOUT_CANCEL_URL} stripe-webhook: runtime: node file: functions/stripe-webhook.js auth_required: false # Stripe signs the request, not a logged-in user timeout: 10s env: STRIPE_SECRET_KEY: ${INSTANCEZ_ENV_STRIPE_SECRET_KEY} STRIPE_WEBHOOK_SECRET: ${INSTANCEZ_ENV_STRIPE_WEBHOOK_SECRET} RESEND_API_KEY: ${INSTANCEZ_ENV_RESEND_API_KEY} ``` A few things worth calling out: * **Products are public-read**, gated only on `active`. No sign-in needed to browse. * **Orders are private.** The `select` policy on `orders` and `order_items` means a customer can only ever see their own. Postgres enforces it, not your code. * **Nobody writes orders directly.** There’s no `insert` or `update` policy, so the only path to creating or changing an order is through the functions, which run as `service_role` and bypass RLS. The price a customer pays never comes from the client. ## How a purchase flows [Section titled “How a purchase flows”](#how-a-purchase-flows) 1. The customer signs in and browses `products`. 2. They call `create-checkout` with the items they want. 3. The function prices the cart **server-side**, writes a `pending` order, and returns a Stripe Checkout URL. 4. The customer pays on Stripe’s hosted page. 5. Stripe POSTs `checkout.session.completed` to the `stripe-webhook` function, which flips the order to `paid` and sends a receipt. ## Client setup [Section titled “Client setup”](#client-setup) ```js import { createClient } from '@supabase/supabase-js' const supabase = createClient('http://localhost:8080', '<your-publishable-key>') ``` ## Sign up and browse [Section titled “Sign up and browse”](#sign-up-and-browse) ```js await supabase.auth.signUp({ email: 'shopper@example.com', password: 'hunter2' }) await supabase.auth.signInWithPassword({ email: 'shopper@example.com', password: 'hunter2' }) // Public catalog: RLS allows reading active products without auth. const { data: products } = await supabase .from('products') .select('id, name, description, price_cents, currency') .order('name') ``` ## Start checkout [Section titled “Start checkout”](#start-checkout) ```js const { data, error } = await supabase.functions.invoke('create-checkout', { body: { items: [ { product_id: 'PRODUCT_UUID', quantity: 2 }, { product_id: 'OTHER_UUID', quantity: 1 }, ], }, }) // Send the customer to Stripe's hosted checkout. window.location.href = data.url ``` ## View my orders [Section titled “View my orders”](#view-my-orders) ```js // RLS returns only the signed-in customer's orders, with their line items embedded. const { data: orders } = await supabase .from('orders') .select('*, order_items(*)') .order('created_at', { ascending: false }) ``` ## The checkout function [Section titled “The checkout function”](#the-checkout-function) functions/create-checkout.js ```js import Stripe from 'stripe' export default async function handler(req, ctx) { // Secrets live on ctx.env, never process.env (the worker scrubs the host env). const stripe = new Stripe(ctx.env.STRIPE_SECRET_KEY) const items = req.body?.items if (!Array.isArray(items) || items.length === 0) { return { status: 400, body: { error: 'items required' } } } // Look up real prices. Never trust amounts sent by the client. const ids = items.map(i => i.product_id) const { data: products, error: lookupErr } = await ctx.serviceClient .from('products') .select('id, name, price_cents, currency, active') .in('id', ids) if (lookupErr) { ctx.log.error('product lookup failed', { error: lookupErr.message }) return { status: 500, body: { error: 'could not load products' } } } const byId = new Map(products.filter(p => p.active).map(p => [p.id, p])) const lines = [] for (const item of items) { const product = byId.get(item.product_id) if (!product) { return { status: 400, body: { error: `unavailable product ${item.product_id}` } } } lines.push({ product, quantity: Math.max(1, item.quantity | 0) }) } const currency = lines[0].product.currency const total = lines.reduce((sum, l) => sum + l.product.price_cents * l.quantity, 0) // Record the order as 'pending' first, then attach the Stripe session to it. const { data: order, error: orderErr } = await ctx.serviceClient .from('orders') .insert({ user_id: ctx.claims.sub, status: 'pending', total_cents: total, currency }) .select('id') .single() if (orderErr) { ctx.log.error('could not create order', { error: orderErr.message }) return { status: 500, body: { error: 'could not create order' } } } await ctx.serviceClient.from('order_items').insert( lines.map(l => ({ order_id: order.id, product_id: l.product.id, quantity: l.quantity, unit_price_cents: l.product.price_cents, })), ) const session = await stripe.checkout.sessions.create({ mode: 'payment', success_url: ctx.env.CHECKOUT_SUCCESS_URL, cancel_url: ctx.env.CHECKOUT_CANCEL_URL, metadata: { order_id: order.id }, line_items: lines.map(l => ({ quantity: l.quantity, price_data: { currency, unit_amount: l.product.price_cents, product_data: { name: l.product.name }, }, })), }) await ctx.serviceClient .from('orders') .update({ stripe_session_id: session.id }) .eq('id', order.id) return { status: 200, body: { url: session.url } } } ``` `ctx.claims.sub` is the signed-in customer’s user ID, taken from the JWT, which is why `auth_required: true` matters. `ctx.serviceClient` is a `service_role` client that bypasses RLS, which is what lets the function write orders that the customer themselves can’t write directly. ## The webhook [Section titled “The webhook”](#the-webhook) Stripe signs every webhook payload, and the signature is computed over the exact bytes of the request body. That’s why the handler reaches for `req.rawBody` (the unparsed `Buffer`) rather than `req.body`. A re-serialized body won’t match the signature, and verification would fail. functions/stripe-webhook.js ```js import Stripe from 'stripe' export default async function handler(req, ctx) { // Secrets live on ctx.env, never process.env (the worker scrubs the host env). const stripe = new Stripe(ctx.env.STRIPE_SECRET_KEY) const sig = req.headers['stripe-signature'] let event try { // req.rawBody is the exact bytes Stripe signed; req.body would not verify. event = stripe.webhooks.constructEvent(req.rawBody, sig, ctx.env.STRIPE_WEBHOOK_SECRET) } catch (err) { ctx.log.warn('Stripe signature verification failed', { error: err.message }) return { status: 400, body: { error: 'invalid signature' } } } if (event.type !== 'checkout.session.completed') { return { status: 200, body: { received: true } } } const session = event.data.object const orderId = session.metadata.order_id // Only flip orders still pending; Stripe may deliver the same event twice. const { error } = await ctx.serviceClient .from('orders') .update({ status: 'paid' }) .eq('id', orderId) .eq('status', 'pending') if (error) { ctx.log.error('failed to mark order paid', { error: error.message, order: orderId }) return { status: 500, body: { error: 'database error' } } } // Send a receipt. if (session.customer_details?.email) { await fetch('https://api.resend.com/emails', { method: 'POST', headers: { 'Authorization': `Bearer ${ctx.env.RESEND_API_KEY}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ from: 'orders@yourstore.com', to: session.customer_details.email, subject: 'Order confirmed', html: `<p>Thanks for your order! Total: ${session.amount_total / 100} ${session.currency.toUpperCase()}</p>`, }), }) } ctx.log.info('order paid', { order: orderId }) return { status: 200, body: { received: true } } } ``` ## Function dependencies [Section titled “Function dependencies”](#function-dependencies) Both functions import `stripe`, and they use the injected Supabase client, so declare both in `functions/package.json`: ```json { "name": "functions", "private": true, "type": "module", "dependencies": { "@supabase/supabase-js": "^2.107.0", "stripe": "^17.0.0" } } ``` Run `npm install` in `functions/` once to generate `package-lock.json`, then commit it. ## Secrets [Section titled “Secrets”](#secrets) ```sh # .env (gitignored) INSTANCEZ_ENV_STRIPE_SECRET_KEY=sk_test_... INSTANCEZ_ENV_STRIPE_WEBHOOK_SECRET=whsec_... INSTANCEZ_ENV_RESEND_API_KEY=re_... INSTANCEZ_ENV_CHECKOUT_SUCCESS_URL=https://yourstore.com/thanks INSTANCEZ_ENV_CHECKOUT_CANCEL_URL=https://yourstore.com/cart ``` ## Register the webhook in Stripe [Section titled “Register the webhook in Stripe”](#register-the-webhook-in-stripe) Point a Stripe webhook endpoint at your deployment: ```plaintext https://your-project.instancez.ai/functions/v1/stripe-webhook ``` Select the `checkout.session.completed` event. Locally, use the Stripe CLI to forward events: ```sh stripe listen --forward-to localhost:8080/functions/v1/stripe-webhook ``` ## What to explore next [Section titled “What to explore next”](#what-to-explore-next) * Add a `fulfilled` step: a second function (or a dashboard action) that flips a `paid` order to `fulfilled` once it ships. * Handle `checkout.session.expired` to release abandoned `pending` orders. * Add an admin-only `insert`/`update` policy on `products` so a store owner can manage the catalog over the API. * See [Code Functions](/instancez/build/functions/) for the full `req` / `ctx` reference, including `req.rawBody`.
# File Gallery
> Private image uploads, direct-to-S3 presigned URLs, and RLS on storage objects.
A private photo gallery where each user can upload images, list their own files, and get short-lived download links. Uploads go directly to S3, so the server never handles the bytes. A `photos` table tracks captions and ownership alongside the stored objects. Want something you can run instead of read? [`docs/examples/gearstore`](https://github.com/instancez/instancez/tree/main/docs/examples/gearstore) is a full storefront project with `docker compose up --build`. ## instancez.yaml [Section titled “instancez.yaml”](#instancezyaml) The whole project lives in one file: the S3 provider, the bucket, and the metadata table. instancez.yaml ```yaml version: 1 auth: jwt_expiry: 1h refresh_tokens: true allow_signup: true providers: storage: type: s3 bucket: "${S3_BUCKET}" region: "${S3_REGION}" access_key_id: "${S3_ACCESS_KEY_ID}" secret_access_key: "${S3_SECRET_ACCESS_KEY}" storage: photos: public: false max_size: 10MB types: - image/* rls: - operations: [select, insert, update, delete] using: "uploaded_by = auth.uid()" with_check: "uploaded_by = auth.uid()" tables: photos: fields: - name: id type: uuid default: uuid_v7() primary_key: true - name: user_id type: uuid required: true - name: object_key type: text required: true - name: caption type: text - name: created_at type: timestamptz default: now() rls: - operations: [select, insert, update, delete] using: "auth.uid() = user_id" with_check: "auth.uid() = user_id" ``` `public: false` means objects require a signed URL to download. The bucket policy scopes every operation to the uploader (`uploaded_by = auth.uid()`), and the `photos` table policy scopes each row to its owner the same way. ## Upload directly to S3 [Section titled “Upload directly to S3”](#upload-directly-to-s3) Bypass the instancez server entirely for the file bytes — only the sign request goes through it: ```js async function uploadPhoto(file, jwt) { // Step 1: get a presigned upload URL const { id, upload_url } = await fetch('/api/storage/photos/sign', { method: 'POST', headers: { 'Authorization': `Bearer ${jwt}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ content_type: file.type, size: file.size }), }).then(r => r.json()) // Step 2: PUT the file straight to S3 await fetch(upload_url, { method: 'PUT', headers: { 'Content-Type': file.type }, body: file, }) return id // store this to reference the object later } ``` `id` is the object key assigned by instancez — this is what `.createSignedUrl()` and `.remove()` expect as the file path within the bucket. Store it wherever you track your user’s files (a `photos` table, for example). ## List files [Section titled “List files”](#list-files) ```js const { data: files } = await supabase .storage .from('photos') .list('', { limit: 50 }) ``` Results are ordered by name; `sortBy` isn’t honored server-side yet, so sort client-side if you need a different order. ## Download with a signed URL [Section titled “Download with a signed URL”](#download-with-a-signed-url) ```js const { data } = await supabase .storage .from('photos') .createSignedUrl(fileName, 3600) // expires in 1 hour // data.signedUrl is a short-lived S3 URL — use it in <img src> or an anchor ``` ## Delete [Section titled “Delete”](#delete) ```js await supabase.storage.from('photos').remove([fileName]) ``` ## Tracking metadata [Section titled “Tracking metadata”](#tracking-metadata) The `photos` table in the config above stores captions and ownership beyond what the bucket itself tracks. Write a row after a successful upload, using the `id` returned by the sign step as the object key: ```js await supabase .from('photos') .insert({ user_id: userId, object_key: id, caption: 'Sunset' }) ``` ## What to explore next [Section titled “What to explore next”](#what-to-explore-next) * Switch to `public: true` and use `.getPublicUrl()` to skip signed URLs for publicly shareable galleries * Add `types: [video/*]` to the bucket to accept video uploads * See [Storage](/instancez/build/storage/) for the full bucket and provider reference
# Installation
> Install the inz CLI on macOS, Linux, or Windows.
The `inz` CLI is a single binary — no runtime dependencies, no package manager required. ## One-line install [Section titled “One-line install”](#one-line-install) * macOS ```bash curl -fsSL https://get.instancez.ai | sh ``` Installs `inz` to `~/.local/bin`. If that directory isn’t in your `PATH`, the script will tell you. * Linux ```bash curl -fsSL https://get.instancez.ai | sh ``` Installs `inz` to `~/.local/bin`. Works on x86-64 and ARM64. * Windows ```powershell irm https://get.instancez.ai/windows | iex ``` Installs `inz.exe` to `%LOCALAPPDATA%\instancez\bin`. Run in PowerShell 5.1+. Alternatively, [download the `.exe` directly](#manual-download) and place it anywhere on your `PATH`. Verify the install: ```bash inz version ``` ## Manual download [Section titled “Manual download”](#manual-download) Each link points at the binary for the [latest release](https://github.com/instancez/instancez/releases/latest): | Platform | Download | | --------------------- | ---------------------------------------------------------------------------------------------------------------- | | macOS (Apple Silicon) | [`inz_darwin_arm64`](https://github.com/instancez/instancez/releases/latest/download/inz_darwin_arm64) | | macOS (Intel) | [`inz_darwin_amd64`](https://github.com/instancez/instancez/releases/latest/download/inz_darwin_amd64) | | Linux x86-64 | [`inz_linux_amd64`](https://github.com/instancez/instancez/releases/latest/download/inz_linux_amd64) | | Linux ARM64 | [`inz_linux_arm64`](https://github.com/instancez/instancez/releases/latest/download/inz_linux_arm64) | | Windows x86-64 | [`inz_windows_amd64.exe`](https://github.com/instancez/instancez/releases/latest/download/inz_windows_amd64.exe) | | Windows ARM64 | [`inz_windows_arm64.exe`](https://github.com/instancez/instancez/releases/latest/download/inz_windows_arm64.exe) | For a specific version instead, browse [all releases](https://github.com/instancez/instancez/releases). On macOS or Linux, make the binary executable, rename it, and move it onto your `PATH`: ```bash chmod +x inz_darwin_arm64 mv inz_darwin_arm64 ~/.local/bin/inz ``` ## Updating [Section titled “Updating”](#updating) Re-run the install command to update to the latest release. The script overwrites the existing binary. ## Uninstalling [Section titled “Uninstalling”](#uninstalling) Delete the binary: * macOS / Linux ```bash rm ~/.local/bin/inz ``` * Windows ```powershell Remove-Item "$env:LOCALAPPDATA\instancez\bin\inz.exe" ``` ## What’s next [Section titled “What’s next”](#whats-next) * [Quick Start](/instancez/quick-start/) — create a project and have an API running in minutes
# Quick Start
> Install instancez and have a Supabase-compatible API running in under 5 minutes.
## Install [Section titled “Install”](#install) * macOS ```bash curl -fsSL https://get.instancez.ai | sh ``` Installs `inz` to `~/.local/bin`. Check it works: `inz version`. * Linux ```bash curl -fsSL https://get.instancez.ai | sh ``` Works on x86-64 and ARM64. Installs `inz` to `~/.local/bin`. Check it works: `inz version`. * Windows ```powershell irm https://get.instancez.ai/windows | iex ``` Installs `inz.exe` to `%LOCALAPPDATA%\instancez\bin`. Run in PowerShell 5.1+. Check it works: `inz version`. Need a manual download or more options? See the [Installation guide](/instancez/install/). ## Create a project [Section titled “Create a project”](#create-a-project) ```bash mkdir my-app && cd my-app inz init ``` This creates `instancez.yaml` (project name inferred from the directory) with a `todos` table, an `avatars` storage bucket, and a `todos` code function. Nothing touches the database yet. Code functions run in Node.js workers, so the example function is only scaffolded when Node.js 22+ is on your PATH. Without it, `inz init` prints a warning and skips the function; everything else works the same. ## Start the dev server [Section titled “Start the dev server”](#start-the-dev-server) The quickest way to start is the Postgres that ships inside `inz`. You do not need to install or run a database yourself: ```bash inz dev --embedded-pg ``` The first run downloads a Postgres 16 binary (about 30 MB) and keeps its data in `./pgdata/`. Later runs reuse that directory. To start over from an empty database, add `--reset-pg`. **Or point at your own Postgres.** If you already have a Postgres 14+ instance, drop the flag and set a superuser connection string: ```bash export INSTANCEZ_DATABASE_URL=postgres://postgres:postgres@localhost:5432/postgres inz dev ``` Either way, on first boot instancez provisions the Postgres roles it needs and writes a generated publishable key and secret key to `.development.env`. Both are printed in the `inz dev` startup output. Your API is live at `http://localhost:8080`, and saving `instancez.yaml` re-applies the schema automatically. ## Get your publishable key [Section titled “Get your publishable key”](#get-your-publishable-key) Open the dashboard at `http://localhost:8080/dashboard`. The API Keys section shows your publishable key — copy it from there. (It’s also in the `inz dev` startup output and `.development.env`.) ## Query your data [Section titled “Query your data”](#query-your-data) instancez speaks the same HTTP API as Supabase, so any Supabase client library works. Examples here use `@supabase/supabase-js`: Your project starts with a `todos` table. Paste the publishable key you copied above: ```js import { createClient } from '@supabase/supabase-js' const supabase = createClient('http://localhost:8080', '<your-publishable-key>') const { data, error } = await supabase .from('todos') .select('*') ``` > The scaffolded `todos` table has a `user_id = auth.uid()` RLS policy, so rows are filtered by the authenticated user. To read without signing in, add a separate `- operations: [select]` policy with `using: "true"` in `instancez.yaml`. Don’t loosen the existing policy: it also covers insert, update, and delete, so `using: "true"` there would open writes to anyone. ## Building with a coding agent [Section titled “Building with a coding agent”](#building-with-a-coding-agent) The repo ships an agent skill that teaches coding agents the YAML syntax, RLS patterns, and the `inz` CLI: ```bash npx skills add instancez/instancez ``` See [Coding Agents](/instancez/coding-agents/) for Claude Code plugin install, per-agent flags, and the manual route. ## What’s next [Section titled “What’s next”](#whats-next) * [Tables / Schema](/instancez/build/schema/) — add tables, columns, and enums * [Auth](/instancez/build/auth/) — sign up, sign in, OAuth, MFA * [Querying](/instancez/build/querying/) — filters, embeds, pagination, aggregates * [Deploy](/instancez/deploy/docker/) — run in production
# Supabase SDK Compatibility
> Which Supabase client SDK features instancez supports.
instancez implements the Supabase wire protocol. Any official Supabase SDK works against it — with the gaps noted below. ## supabase-js feature matrix [Section titled “supabase-js feature matrix”](#supabase-js-feature-matrix) | Feature | Status | Notes | | -------------------------------------------------- | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | **Database — `supabase.from()`** | ✅ Full | `select`, `insert`, `update`, `upsert`, `delete`. All PostgREST filter operators (`eq`, `neq`, `gt`, `gte`, `lt`, `lte`, `like`, `ilike`, `is`, `in`, `contains`, `containedBy`, `overlaps`, …). Embeds (`!inner`, `!left`, FK hints). `order`, `limit`, `offset`, Range-header pagination. `Prefer: return`, `count`, `resolution`, `missing`, `max-affected`, `tx`. CSV responses (`Accept: text/csv`). HEAD requests. | | **Auth — `supabase.auth.*`** | ✅ Full | Email + password, magic link / OTP, anonymous sign-in, session refresh, `updateUser`, `resetPasswordForEmail`, identity linking/unlinking, PKCE. | | **OAuth — `signInWithOAuth`** | ⚠️ Google and GitHub only | The `provider` field accepts `google` and `github`. Other providers return a 400. | | **Auth Admin — `supabase.auth.admin.*`** | ✅ Full | `createUser`, `listUsers` (paginated), `getUserById`, `updateUserById`, `deleteUser`, `inviteUserByEmail`, `generateLink`, `signOut` (user), `deleteFactor`. | | **MFA — `supabase.auth.mfa.*`** | ⚠️ TOTP only | `enroll`, `challenge`, `verify`, `unenroll`, `listFactors` all work for TOTP. Phone/SMS factors are not supported. | | **Storage — `supabase.storage.*`** | ✅ Full | Upload, download, move, copy, remove, list. Public URLs. Signed URLs (download and upload). Bucket management (create, update, delete, empty). Image transforms: resize (`cover`, `contain`, `fill`), quality, format (`jpeg`, `png`); WebP and AVIF output are not supported. | | **Edge Functions — `supabase.functions.invoke()`** | ✅ Full | Calls code functions at `/functions/v1/<name>`. | | **RPC — `supabase.rpc()`** | ✅ Full | Calls SQL functions declared under `rpc:` in `instancez.yaml`. | | **Realtime — `supabase.channel()`** | ❌ Not supported yet | instancez has no pub/sub listener. For event-driven patterns in the meantime, use a code function with Postgres LISTEN/NOTIFY or a webhook receiver. | ## Direct storage upload (no SDK needed) [Section titled “Direct storage upload (no SDK needed)”](#direct-storage-upload-no-sdk-needed) When using the S3 provider, you can bypass the SDK entirely and upload files straight to S3 via a presigned URL — useful in serverless environments where routing bytes through the server is expensive: ```js // Get a presigned upload URL const { id, upload_url } = await fetch('/api/storage/avatars/sign', { method: 'POST', headers: { Authorization: `Bearer ${jwt}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ content_type: file.type, size: file.size }), }).then(r => r.json()) // Upload directly to S3 — instancez is not in this path await fetch(upload_url, { method: 'PUT', headers: { 'Content-Type': file.type }, body: file }) ``` See [Storage](/instancez/build/storage/) for the full spec. The integration test suite runs `@supabase/supabase-js` against a live instancez instance on every commit. If you find a gap, [open an issue](https://github.com/instancez/instancez/issues).