Auto-generated GraphQL API from your PostgreSQL schema — queries, mutations, subscriptions, RBAC, event triggers, scheduled triggers, and REST endpoints, all wired by nSelf with zero configuration.
Hasura quick reference
https://api.{domain}/v1/graphqlwss://api.{domain}/v1/graphqlhttps://api.{domain}/api/rest/https://api.{domain}/healthzhttps://api.{domain}/v1/metricshttps://api.{domain}/console (dev only)Hasura starts automatically with nself start. nSelf pre-configures all environment variables. To open the console in development:
# Start the stack (Hasura included)
nself start
# Open Hasura Console in your browser
nself db hasura console
# Reload metadata after schema changes
nself db hasura metadata reload
# Export metadata to version control
nself db hasura metadata export
# Apply metadata from version control
nself db hasura metadata applyConsole disabled in production
HASURA_GRAPHQL_ENABLE_CONSOLE=false is set in .env.prod. Never expose the console on a production server. Use nself db hasura console from your local machine targeting staging.
All Hasura settings flow through the environment cascade. Dev stubs are in .env.dev. Production secrets go in .env.secrets (gitignored).
# .env.dev — team defaults (committed)
HASURA_ENABLED=true
HASURA_VERSION=v2.44.0
HASURA_GRAPHQL_ADMIN_SECRET=devsecret # rotated at prod deploy
HASURA_GRAPHQL_ENABLE_CONSOLE=true
HASURA_GRAPHQL_DEV_MODE=true
HASURA_GRAPHQL_LOG_LEVEL=info
HASURA_GRAPHQL_ENABLED_LOG_TYPES=startup,http-log,webhook-log,websocket-log
HASURA_GRAPHQL_STRINGIFY_NUMERIC_TYPES=false
HASURA_GRAPHQL_UNAUTHORIZED_ROLE=anonymous
# JWT configuration (see Authentication section below)
HASURA_GRAPHQL_JWT_SECRET={"type":"HS256","key":"min-32-char-dev-secret"}
# .env.prod — production settings (committed, no secrets)
HASURA_GRAPHQL_ENABLE_CONSOLE=false
HASURA_GRAPHQL_DEV_MODE=false
HASURA_GRAPHQL_LOG_LEVEL=warn
# .env.secrets — production secrets (gitignored, never committed)
HASURA_GRAPHQL_ADMIN_SECRET=<real-strong-secret>
HASURA_GRAPHQL_JWT_SECRET={"type":"HS256","key":"<real-64-char-secret>"}nSelf configures Hasura to auto-track all tables with the np_* prefix. When you create a migration that adds a new np_ table, Hasura picks it up automatically on the next nself db hasura metadata reload.
# After creating a new np_ table in a migration
nself db migrate up
nself db hasura metadata reload
# Verify the table is tracked
nself db hasura metadata status
# Manual tracking (if needed for non-np_ tables)
nself db hasura track --table my_external_table
nself db hasura track --view active_users_view# A new np_posts table immediately generates:
query {
np_posts(
where: { deleted_at: { _is_null: true } }
order_by: { created_at: desc }
limit: 20
offset: 0
) {
id
source_account_id
tenant_id
title
body
created_at
updated_at
}
}
# Aggregate
query {
np_posts_aggregate(
where: { source_account_id: { _eq: "primary" } }
) {
aggregate {
count
}
}
}Hasura validates JWT tokens from the Auth service. nSelf adds custom claims so Hasura row filters can reference the authenticated user, their source account, and their Cloud tenant.
{
"sub": "uuid-of-user",
"iat": 1716000000,
"exp": 1716086400,
"https://hasura.io/jwt/claims": {
"x-hasura-allowed-roles": ["user", "me"],
"x-hasura-default-role": "user",
"x-hasura-user-id": "uuid-of-user",
"x-hasura-source-account-id": "primary",
"x-hasura-tenant-id": "uuid-of-tenant-or-empty"
}
}| Claim | Use in row filter | Purpose |
|---|---|---|
| x-hasura-user-id | X-Hasura-User-Id | Filter rows owned by this user |
| x-hasura-source-account-id | X-Hasura-Source-Account-Id | Multi-app isolation filter |
| x-hasura-tenant-id | X-Hasura-Tenant-Id | Cloud SaaS tenant filter |
| x-hasura-default-role | — | Role applied when no role header sent |
| x-hasura-allowed-roles | — | Roles the user may request via X-Hasura-Role |
# With JWT token (user request)
curl -X POST https://api.{domain}/v1/graphql \
-H "Authorization: Bearer <jwt-token>" \
-H "Content-Type: application/json" \
-d '{"query": "{ np_posts { id title } }"}'
# With admin secret (server-side, bypasses all permissions)
curl -X POST https://api.{domain}/v1/graphql \
-H "X-Hasura-Admin-Secret: <admin-secret>" \
-H "Content-Type: application/json" \
-d '{"query": "{ np_posts { id title } }"}'
# Request a specific role
curl -X POST https://api.{domain}/v1/graphql \
-H "Authorization: Bearer <jwt-token>" \
-H "X-Hasura-Role: moderator" \
-H "Content-Type: application/json" \
-d '{"query": "{ np_posts { id title } }"}'Hasura permissions operate at four levels: table, operation, row filter, column filter. nSelf ships preconfigured roles for the np_users and np_sessionstables. You define permissions for your own tables in Hasura metadata.
| Role | Who | Typical row filter |
|---|---|---|
| anonymous | Unauthenticated requests | Public rows only (e.g. published content) |
| user | Any authenticated user | source_account_id matches + not deleted |
| me | User accessing own data | user_id = X-Hasura-User-Id |
| moderator | Content moderators | Broader access within source_account_id |
| admin | Application admins | tenant_id-scoped full access |
// SELECT permission for 'user' role on np_posts
// Hasura console → Data → np_posts → Permissions → user → select
{
"filter": {
"_and": [
{ "source_account_id": { "_eq": "X-Hasura-Source-Account-Id" } },
{ "deleted_at": { "_is_null": true } }
]
},
"columns": ["id", "title", "body", "created_at", "author_id"]
}
// INSERT permission — set author_id from JWT, prevent tenant_id injection
{
"check": {
"source_account_id": { "_eq": "X-Hasura-Source-Account-Id" }
},
"set": {
"author_id": "X-Hasura-User-Id",
"source_account_id": "X-Hasura-Source-Account-Id"
},
"columns": ["title", "body"]
}
// UPDATE permission — user can only update their own non-deleted rows
{
"filter": {
"_and": [
{ "author_id": { "_eq": "X-Hasura-User-Id" } },
{ "deleted_at": { "_is_null": true } }
]
},
"check": {},
"columns": ["title", "body"]
}Hasura auto-detects foreign key relationships. Add manual relationships for computed or cross-schema joins.
# Auto-detected foreign key: np_posts.author_id → np_users.id
query PostsWithAuthor {
np_posts(
where: { deleted_at: { _is_null: true } }
order_by: { created_at: desc }
limit: 10
) {
id
title
author { # object relationship (many-to-one)
id
display_name
}
comments { # array relationship (one-to-many)
id
body
created_at
}
}
}
# Nested insert (insert post + comments in one mutation)
mutation CreatePostWithComment {
insert_np_posts_one(object: {
title: "My post"
body: "Hello world"
comments: {
data: [{ body: "First comment!" }]
}
}) {
id
comments {
id
}
}
}Event triggers fire a webhook on insert/update/delete. Use them to send emails on user signup, sync data to an external system, or kick off async workflows.
# Create an event trigger via CLI
nself db hasura event-trigger create \
--name user_signup_welcome \
--table np_users \
--operation INSERT \
--webhook http://functions:3000/welcome-email
# List event triggers
nself db hasura event-trigger list
# View delivery logs
nself db hasura event-trigger logs --trigger user_signup_welcome --tail 50
# Redeliver a failed event
nself db hasura event-trigger redeliver --id <event-id>Event trigger webhook payload:
{
"event": {
"op": "INSERT",
"data": {
"old": null,
"new": {
"id": "uuid",
"email": "user@example.com",
"source_account_id": "primary",
"created_at": "2026-05-07T10:00:00Z"
}
}
},
"table": { "schema": "public", "name": "np_users" },
"trigger": { "name": "user_signup_welcome" }
}# hasura/metadata/databases/default/event_triggers/user_signup_welcome.yaml
name: user_signup_welcome
definition:
table:
name: np_users
schema: public
insert:
columns: "*"
webhook: "{{FUNCTIONS_URL}}/welcome-email"
headers:
- name: X-Webhook-Secret
value_from_env: WEBHOOK_SECRET
retry_conf:
num_retries: 3
interval_sec: 15
timeout_sec: 60Scheduled triggers run a webhook on a cron schedule. Use them for recurring jobs: digest emails, cleanup, report generation.
# Create a scheduled trigger
nself db hasura scheduled-trigger create \
--name daily_digest \
--cron "0 8 * * *" \
--webhook http://functions:3000/send-digest \
--comment "Send daily digest email at 8am UTC"
# One-time scheduled trigger (runs once, then deletes itself)
nself db hasura scheduled-trigger create \
--name post_import_cleanup \
--at "2026-05-10T02:00:00Z" \
--webhook http://functions:3000/cleanup-import
# List scheduled triggers
nself db hasura scheduled-trigger list
# View upcoming runs
nself db hasura scheduled-trigger status --name daily_digest# hasura/metadata/cron_triggers.yaml
- name: daily_digest
webhook: "{{FUNCTIONS_URL}}/send-digest"
schedule: "0 8 * * *"
include_in_metadata: true
retry_conf:
num_retries: 2
retry_interval_seconds: 60
timeout_seconds: 120
comment: "Send daily digest email at 8am UTC"
- name: cleanup_deleted_rows
webhook: "{{FUNCTIONS_URL}}/cleanup"
schedule: "0 3 * * 0" # weekly at 3am Sunday
include_in_metadata: true
comment: "Hard-delete rows where deleted_at < 90 days ago"Convert any GraphQL query or mutation into a REST endpoint. Useful for third-party integrations that cannot consume GraphQL, or for simple webhook consumers.
# Create a REST endpoint from a named GraphQL operation
# First, define the named operation in Hasura console → API → REST
# Or use metadata YAML
# Example: GET /api/rest/posts/:id
# Maps to:
# query PostById($id: uuid!) {
# np_posts_by_pk(id: $id) { id title body author { display_name } }
# }
# Test the REST endpoint
curl https://api.{domain}/api/rest/posts/some-uuid \
-H "Authorization: Bearer <jwt-token>"
# POST mutation as REST
curl -X POST https://api.{domain}/api/rest/posts \
-H "Authorization: Bearer <jwt-token>" \
-H "Content-Type: application/json" \
-d '{"title": "Hello", "body": "World"}'# hasura/metadata/rest_endpoints.yaml
- name: get_post
url: /posts/:id
methods:
- GET
definition:
query:
query_name: PostById
collection_name: allowed_queries
comment: "Fetch a single post by ID"
- name: create_post
url: /posts
methods:
- POST
definition:
query:
query_name: CreatePost
collection_name: allowed_queries
comment: "Create a new post"In production, enable the allow list to restrict Hasura to only named GraphQL operations you have explicitly approved. This prevents arbitrary query execution and is a key hardening step.
# Enable allow list
HASURA_GRAPHQL_ENABLE_ALLOWLIST=true # in .env.prod
# Add all operations from a query collection to the allow list
nself db hasura allowlist add --collection allowed_queries
# Export current query collections (for version control)
nself db hasura metadata export
# collections are in hasura/metadata/query_collections.yaml
# Validate that an operation is in the allow list
nself db hasura allowlist check --operation-name PostById# hasura/metadata/query_collections.yaml
- name: allowed_queries
definition:
queries:
- name: PostById
query: |
query PostById($id: uuid!) {
np_posts_by_pk(id: $id) {
id title body created_at
author { display_name }
}
}
- name: CreatePost
query: |
mutation CreatePost($title: String!, $body: String!) {
insert_np_posts_one(object: { title: $title, body: $body }) {
id
}
}
- name: UsersPage
query: |
query UsersPage($limit: Int!, $offset: Int!) {
np_users(
limit: $limit
offset: $offset
order_by: { created_at: desc }
where: { deleted_at: { _is_null: true } }
) {
id display_name created_at
}
}Allow list in development
Keep HASURA_GRAPHQL_ENABLE_ALLOWLIST=false in .env.devso the console and ad-hoc queries work freely. Enable it only in staging and production. The CI pipeline runs a check that every frontend query appears in query_collections.yaml.
Remote schemas stitch an external GraphQL API into your Hasura graph. nSelf uses this for the plugin system — each plugin that adds its own GraphQL surface registers a remote schema automatically.
# Add a remote schema
nself db hasura remote-schema add \
--name payment_service \
--url http://nself-stripe:3830/graphql \
--forward-header Authorization
# List remote schemas
nself db hasura remote-schema list
# Test remote schema merge
nself db hasura metadata reload
nself db hasura console # inspect combined graph in GraphiQL# hasura/metadata/remote_schemas.yaml
- name: nself_stripe_plugin
definition:
url_from_env: STRIPE_PLUGIN_GRAPHQL_URL
timeout_seconds: 30
forward_client_headers: true
headers:
- name: X-Plugin-Secret
value_from_env: STRIPE_PLUGIN_SECRET
permissions:
- role: user
definition:
schema: |
type Query {
myInvoices: [Invoice!]!
}Actions add custom mutations and queries to your graph. The handler runs in your Functions service or any HTTP endpoint.
# Action definition (in Hasura console → Actions)
type Mutation {
sendVerificationEmail(userId: uuid!): SendVerificationResponse!
exportUserData(userId: uuid!): ExportDataResponse!
}
type Query {
searchUsers(query: String!, limit: Int): [UserSearchResult!]!
}
type SendVerificationResponse {
success: Boolean!
messageId: String
}
type ExportDataResponse {
downloadUrl: String!
expiresAt: String!
}
type UserSearchResult {
id: uuid!
displayName: String!
score: Float!
}// functions/src/send-verification-email.ts
// Handler for the sendVerificationEmail action
export async function handler(req: Request): Promise<Response> {
const { input: { userId }, session_variables } = await req.json()
// Verify the requester can act on this user
const requesterId = session_variables['x-hasura-user-id']
if (requesterId !== userId) {
return Response.json({ message: 'Unauthorized' }, { status: 403 })
}
// Fetch user from Hasura (admin secret bypasses permissions)
const user = await fetchUser(userId)
const messageId = await sendEmail(user.email, 'verification', { user })
return Response.json({ success: true, messageId })
}-- Computed field: full name from first + last
CREATE OR REPLACE FUNCTION np_users_full_name(user_row np_users)
RETURNS TEXT AS $$
SELECT COALESCE(user_row.first_name || ' ' || user_row.last_name, user_row.display_name)
$$ LANGUAGE sql STABLE;
-- Computed field: unread message count
CREATE OR REPLACE FUNCTION np_users_unread_count(user_row np_users)
RETURNS BIGINT AS $$
SELECT COUNT(*) FROM np_messages
WHERE recipient_id = user_row.id AND read_at IS NULL AND deleted_at IS NULL
$$ LANGUAGE sql STABLE;# After adding computed fields in Hasura console → Data → np_users → Computed Fields
query UserProfile {
np_users_by_pk(id: "some-uuid") {
id
display_name
full_name # computed: first + last
unread_count # computed: unread messages
}
}Subscriptions use WebSocket connections. Hasura supports live queries (polling) and streaming subscriptions (cursor-based).
# Live query subscription — re-runs when underlying data changes
subscription LiveMessages($roomId: uuid!) {
np_messages(
where: {
room_id: { _eq: $roomId }
deleted_at: { _is_null: true }
}
order_by: { created_at: desc }
limit: 50
) {
id
body
created_at
author { display_name }
}
}
# Streaming subscription (cursor-based, high-volume)
# Only delivers new/changed rows since the cursor
subscription MessageStream($roomId: uuid!, $cursor: timestamptz!) {
np_messages_stream(
where: { room_id: { _eq: $roomId } }
batch_size: 10
cursor: { initial_value: { created_at: $cursor }, ordering: ASC }
) {
id
body
created_at
}
}// Client-side with @nhost/nhost-js
import { NhostClient } from '@nhost/nhost-js'
const nhost = new NhostClient({ backendUrl: 'https://api.your.domain' })
// Subscribe to live messages (LIVE_QUERY = re-runs on any data change)
const LIVE_MESSAGES = `
subscription LiveMessages($roomId: uuid!) {
np_messages(
where: { room_id: { _eq: $roomId }, deleted_at: { _is_null: true } }
order_by: { created_at: desc }
limit: 50
) { id body created_at }
}
`
const subscription = nhost.graphql.subscribe(
LIVE_MESSAGES,
{ variables: { roomId: 'room-uuid' } },
(response) => {
console.log(response.data?.np_messages)
}
)
// Unsubscribe when done
subscription.unsubscribe()Hasura metadata (permissions, relationships, event triggers, remote schemas) is stored as YAML files in hasura/metadata/. Always version-control metadata.
# Export all metadata to hasura/metadata/
nself db hasura metadata export
# Apply metadata from hasura/metadata/ to a running instance
nself db hasura metadata apply
# Reload metadata (faster than apply — picks up schema changes)
nself db hasura metadata reload
# Check metadata consistency
nself db hasura metadata status
# Reset metadata (dangerous — removes all permissions, triggers, etc.)
nself db hasura metadata reset # requires --confirm in prod
# Metadata diff (shows what changed vs current running instance)
nself db hasura metadata diff# hasura/metadata/ directory structure
hasura/
metadata/
databases/
default/
tables/
np_users.yaml # permissions + relationships
np_posts.yaml
...
functions/
np_users_full_name.yaml
remote_schemas.yaml
cron_triggers.yaml
query_collections.yaml # allow list
rest_endpoints.yaml
actions.yaml
version.yaml# Prevent deeply nested, expensive queries
HASURA_GRAPHQL_QUERY_DEPTH_LIMIT=10
HASURA_GRAPHQL_NODE_LIMIT=1000
# Per-role rate limiting
HASURA_GRAPHQL_RATE_LIMIT_PER_ROLE='{"user":{"max_reqs_per_min":300},"anonymous":{"max_reqs_per_min":30}}'
# Disable introspection for anonymous users in production
# (set in Hasura console → Security → Disable Schema Introspection → anonymous role)# Hasura uses its own internal connection pool
# Tune via environment variables
HASURA_GRAPHQL_PG_CONNECTIONS=50 # max connections per Hasura instance
HASURA_GRAPHQL_PG_TIMEOUT=60 # query timeout in seconds
HASURA_GRAPHQL_PG_CONN_LIFETIME=600 # max connection age in seconds
# Monitor connection usage
curl -H "X-Hasura-Admin-Secret: <secret>" \
https://api.{domain}/v1/metadata \
-d '{"type":"pg_get_source_tables","args":{"source":"default"}}'| Type | Mechanism | Best for |
|---|---|---|
| Live query | Polling (1s interval) | Small result sets, high-priority freshness |
| Streaming | Cursor-based CDC | High-volume event streams (chat, feeds) |
# 1. Update .env.secrets with new admin secret
# 2. Rebuild and redeploy
nself build
nself deploy prod
# 3. Rotate any services that use the admin secret (Functions, plugins)
nself env rotate --key HASURA_GRAPHQL_ADMIN_SECRET
# 4. Verify health
nself doctor --env prod --check hasura# View Hasura logs (last 100 lines, follow)
nself logs hasura --tail 100 --follow
# Prometheus metrics (requires monitoring bundle)
curl -H "X-Hasura-Admin-Secret: <secret>" \
https://api.{domain}/v1/metrics
# Key metrics to watch:
# hasura_graphql_requests_total — total request count by role/operation
# hasura_graphql_execution_time_seconds — query execution latency histogram
# hasura_event_trigger_http_workers — event trigger worker count
# hasura_active_subscription_pollers — live subscription count
# hasura_pg_connections — PostgreSQL connection count
# Health check (returns 200 if healthy, 500 if not)
curl https://api.{domain}/healthzThe monitoring bundle ships a pre-configured Hasura dashboard in Grafana at monitor.{ domain }. Key panels: request rate, error rate (5xx), p50/p95/p99 latency, subscription count, event trigger delivery rate, and PostgreSQL connection saturation.
Table not showing in GraphQL schema
Cause: Table not tracked, or auto-tracking only covers np_* prefix
Fix: nself db hasura track --table <name> && nself db hasura metadata reload
Permission denied errors for a user
Cause: Role permissions not defined for the table, or row filter too narrow
Fix: Check Hasura console → Data → <table> → Permissions → <role>
Metadata inconsistency error
Cause: Schema changed without updating Hasura metadata
Fix: nself db hasura metadata reload; if persists, run nself db hasura metadata status
Event trigger not firing
Cause: Webhook URL unreachable, or payload > 64KB
Fix: nself db hasura event-trigger logs --trigger <name> --tail 50
Subscription connection drops
Cause: Load balancer or Nginx timeout on idle WebSocket
Fix: Set NGINX_PROXY_READ_TIMEOUT=3600 and NGINX_PROXY_SEND_TIMEOUT=3600
Allow list blocking a query in production
Cause: Named operation not in query_collections.yaml
Fix: Add to allowed_queries collection, export metadata, deploy
Authentication
JWT, OAuth, MFA — feeds into Hasura claims
PostgreSQL
Extensions, RLS, indexes, pgvector
Schema Design
Drizzle ORM, np_* conventions, relationships
Migrations
Generate, apply, verify, rollback
Multi-Tenancy
Convention Wall, row filters, isolation
Architecture
How Hasura fits into the full nSelf stack