Overview
Hasura GraphQL Engine connects to a database and serves a GraphQL API for every tracked table: filtering, sorting, pagination, aggregations, mutations and live subscriptions, with access rules per role. Custom logic is attached through Actions (your HTTP handler behind a GraphQL field) and Event Triggers (webhooks on row changes).
This skill covers Hasura v2 — the hasura/graphql-engine server, the hasura CLI and YAML metadata. Hasura DDN (v3) is a separate product with its own ddn CLI, supergraph.yaml projects, HML metadata and data connectors; nothing below (env vars, metadata files, CLI commands) carries over to it. hasura.io/docs/latest now points at DDN, so use hasura.io/docs/2.0 for v2.
Instructions
Quick Start
# docker-compose.yml — local development
services:
postgres:
image: postgres:16
environment:
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes: ["pgdata:/var/lib/postgresql/data"]
healthcheck: # Hasura exits if Postgres is not accepting connections yet
{ test: ["CMD-SHELL", "pg_isready -U postgres"], interval: 5s, retries: 10 }
hasura:
image: hasura/graphql-engine:v2.50.3.cli-migrations-v3 # server + bundled CLI
user: "${HOST_UID}:${HOST_GID}" # files the CLI writes stay yours
ports: ["8080:8080"]
environment:
# Registers this database as the source named "default"
HASURA_GRAPHQL_DATABASE_URL: postgres://postgres:${POSTGRES_PASSWORD}@postgres:5432/postgres
HASURA_GRAPHQL_ADMIN_SECRET: ${HASURA_GRAPHQL_ADMIN_SECRET}
HASURA_GRAPHQL_ENABLE_CONSOLE: "true"
HASURA_GRAPHQL_DEV_MODE: "true" # detailed errors; turn off in production
ACTION_BASE_URL: http://api:3000
EVENT_WEBHOOK_SECRET: ${EVENT_WEBHOOK_SECRET}
volumes: ["./hasura:/project"]
depends_on:
postgres:
condition: service_healthy
volumes:
pgdata:
export HOST_UID=$(id -u) HOST_GID=$(id -g) # plus the three secrets, or put all five in .env
mkdir -p hasura && docker compose up -d # create ./hasura yourself, or Docker creates it root-owned
curl -s localhost:8080/healthz # OK
# Console at http://localhost:8080/console, GraphQL at http://localhost:8080/v1/graphql
HASURA_GRAPHQL_DATABASE_URL is kept for backwards compatibility. The official manifests instead set HASURA_GRAPHQL_METADATA_DATABASE_URL plus a variable of your choice such as PG_DATABASE_URL; with that layout no source exists until you add one in the Console or in metadata/databases/databases.yaml (database_url: { from_env: PG_DATABASE_URL }).
Auto-Generated API
# After tracking a "users" table, Hasura serves these operations:
query {
users(
where: { plan: { _eq: "pro" }, created_at: { _gte: "2026-01-01" } }
order_by: { created_at: desc }, limit: 20, offset: 0
) {
id email
# Relationship defined on the users → orders foreign key
orders(where: { status: { _eq: "paid" } }) { id amount }
orders_aggregate { aggregate { count sum { amount } } }
}
users_aggregate(where: { plan: { _eq: "pro" } }) { aggregate { count } }
}
mutation {
insert_users_one(object: { email: "maria@northwind.dev", plan: "free" }) { id }
update_users(where: { id: { _eq: "60d686a1-c947-487b-a53e-3ed6c078f3ff" } }, _set: { plan: "pro" }) {
affected_rows
returning { id plan }
}
}
# Live query over WebSocket: re-sent whenever the result changes
subscription {
orders(where: { status: { _eq: "pending" } }) { id amount created_at }
}
Permissions (Row-Level Security)
# metadata/databases/default/tables/public_orders.yaml
table: { name: orders, schema: public }
object_relationships:
- name: user
using: { foreign_key_constraint_on: user_id }
# Select: users can only see their own orders
select_permissions:
- role: user
permission:
columns: [id, amount, status, created_at]
filter:
user_id: { _eq: X-Hasura-User-Id }
limit: 100
# Insert: users create orders for themselves
insert_permissions:
- role: user
permission:
columns: [amount]
set:
user_id: X-Hasura-User-Id # Taken from the session, not from the client
status: pending
check:
amount: { _gt: 0 }
# Update: users can only cancel their own pending orders
update_permissions:
- role: user
permission:
columns: [status]
filter:
user_id: { _eq: X-Hasura-User-Id }
status: { _eq: pending }
check:
status: { _eq: cancelled }
List each table file in metadata/databases/default/tables/tables.yaml (- "!include public_orders.yaml"). The user relationship is inconsistent unless users is tracked too: add a public_users.yaml containing table: { name: users, schema: public } and list it as well. The admin role bypasses all permissions and needs no rules. A role can only select, filter on and return the columns its select permission lists; aggregate fields appear only with allow_aggregations: true.
Actions (Custom Business Logic)
An Action is declared in two files. The GraphQL signature and its types go in metadata/actions.graphql, the handler in metadata/actions.yaml:
type Mutation {
processPayment(order_id: uuid!, payment_method: String!): PaymentResult
}
type PaymentResult {
success: Boolean!
payment_id: String!
}
# metadata/actions.yaml — handler and permissions
actions:
- name: processPayment
definition:
kind: synchronous
handler: "{{ACTION_BASE_URL}}/actions/process-payment" # env var on the Hasura server
forward_client_headers: true
permissions:
- role: user
custom_types:
enums: []
input_objects: []
objects:
- name: PaymentResult
scalars: []
// api/actions/process-payment.ts — Hasura POSTs { action, input, session_variables, request_query }
export default async function handler(req: Request) {
const { input, session_variables } = await req.json();
const userId = session_variables["x-hasura-user-id"]; // keys are always lowercase
const order = await db.query(
"SELECT id, amount FROM orders WHERE id = $1 AND user_id = $2 AND status = 'pending'",
[input.order_id, userId]
);
if (!order.rows[0]) {
// Errors: 4xx status and a body with "message" (plus optional "extensions")
return Response.json({ message: "Order not found", extensions: { code: "not-found" } }, { status: 400 });
}
const paymentId = await chargeCard(order.rows[0], input.payment_method);
await db.query("UPDATE orders SET status = 'paid' WHERE id = $1", [input.order_id]);
return Response.json({ success: true, payment_id: paymentId }); // 2xx + the output type's shape
}
Event Triggers
# metadata/databases/default/tables/public_orders.yaml (same file as the permissions)
event_triggers:
- name: on_order_paid
definition:
enable_manual: false
update:
columns: [status]
retry_conf: { num_retries: 3, interval_sec: 10, timeout_sec: 60 }
webhook: http://api:3000/webhooks/order-paid
headers:
- name: x-webhook-secret
value_from_env: EVENT_WEBHOOK_SECRET
// api/webhooks/order-paid.ts — body: { event: { op, data: { old, new } }, table, trigger, id }
export default async function handler(req: Request) {
if (req.headers.get("x-webhook-secret") !== process.env.EVENT_WEBHOOK_SECRET) return new Response("Forbidden", { status: 403 });
const { old: oldRow, new: newRow } = (await req.json()).event.data;
// The trigger fires on every change of "status" — filter for the transition you want
if (newRow.status === "paid" && oldRow?.status !== "paid") await sendEmail(newRow.user_id, `Order #${newRow.id} paid`);
return Response.json({ success: true }); // any non-2xx response is retried per retry_conf
}
Migrations
# One-time: scaffold config.yaml, metadata/, migrations/, seeds/
hasura init shop --endpoint http://localhost:8080
# Create and apply a schema change
hasura migrate create create_shop_tables --database-name default \
--up-sql "CREATE TABLE users (id uuid PRIMARY KEY DEFAULT gen_random_uuid(), email text UNIQUE NOT NULL, plan text NOT NULL DEFAULT 'free', created_at timestamptz NOT NULL DEFAULT now());
CREATE TABLE orders (id uuid PRIMARY KEY DEFAULT gen_random_uuid(), user_id uuid NOT NULL REFERENCES users(id), amount integer NOT NULL, status text NOT NULL DEFAULT 'pending', created_at timestamptz NOT NULL DEFAULT now());" \
--down-sql "DROP TABLE orders; DROP TABLE users;"
hasura migrate apply --database-name default
# Push the metadata directory to the server and confirm it is consistent
hasura metadata apply && hasura metadata ic list
# Capture what was changed in the Console
hasura metadata export
hasura migrate create init --from-server --database-name default
The CLI reads the admin secret from the HASURA_GRAPHQL_ADMIN_SECRET environment variable. Passing --admin-secret to hasura init writes it into config.yaml.
Installation
# CLI: bundled in the cli-migrations image as hasura-cli; the admin secret comes from the container env (run `init shop` with -w /project)
docker compose exec -e HOME=/tmp -w /project/shop hasura \
hasura-cli migrate status --database-name default
# CLI via npm: community-maintained wrapper, not supported by Hasura; npm "latest" is 2.38.0
npm install --save-dev hasura-cli
Pin image versions (hasura/graphql-engine:v2.50.3 is the plain server); latest moves between minor releases. Standalone CLI binaries (cli-hasura-linux-amd64, cli-hasura-darwin-arm64, …) are attached to each GitHub release, without published checksums. The cli-migrations-v3 image also applies /hasura-migrations and /hasura-metadata on startup when those directories are mounted, which suits CI/CD deployments.
Examples
Example 1: Local Hasura where each user sees only their own orders
User request: "Set up Hasura locally for my users and orders tables. A logged-in user must only see their own orders."
The agent writes the compose file, the migration and public_orders.yaml above, runs docker compose up -d, hasura migrate apply --database-name default and hasura metadata apply, then checks the rule by sending the same query as two different users (the admin secret lets a request impersonate any role):
curl -s localhost:8080/v1/graphql -H "content-type: application/json" \
-H "x-hasura-admin-secret: $HASURA_GRAPHQL_ADMIN_SECRET" \
-H "x-hasura-role: user" \
-H "x-hasura-user-id: 60d686a1-c947-487b-a53e-3ed6c078f3ff" \
-d '{"query":"{ orders { amount status } }"}'
# {"data":{"orders":[{"amount":4900,"status":"pending"}]}}
With another user's ID in x-hasura-user-id the result is {"data":{"orders":[]}}. With no headers at all: "x-hasura-admin-secret" required, but not found.
Example 2: Let users cancel an order but never mark it paid
User request: "Customers should be able to cancel their own pending orders from the app, and nothing else."
The agent adds the update_permissions rule above and applies the metadata. As role user, setting status to paid is rejected and cancelled goes through:
mutation {
update_orders_by_pk(pk_columns: { id: "03b7104f-c239-416b-abf6-c7f91ea5779c" }, _set: { status: "paid" }) { id status }
}
{"errors":[{"message":"check constraint of an insert/update permission has failed","extensions":{"path":"$","code":"permission-error"}}]}
{"data":{"update_orders_by_pk":{"id":"03b7104f-c239-416b-abf6-c7f91ea5779c","status":"cancelled"}}}
Guidelines
- v2 or DDN — Check which product the project uses before touching it:
config.yamlwithversion: 3and ametadata/directory of YAML is v2;supergraph.yamland theddnCLI mean DDN, and this skill does not apply - Permissions on every table — Tables without permissions are invisible to non-admin roles; define permissions as part of your schema
- Use relationships — Define foreign key relationships; Hasura auto-generates nested queries (no N+1 problem)
- Event triggers for side effects — Use event triggers (not polling) for email, notifications, and external API calls; delivery is at-least-once and may arrive out of order, so make handlers idempotent
- Actions for business logic — Complex operations (payments, multi-step workflows) go in Actions, not in client code; verify a shared secret header in the handler so only Hasura can call it
- Migrations in CI/CD — Export metadata and migrations; apply them with the CLI or the
cli-migrations-v3image in your deployment pipeline - Admin secret ≠ user auth — Always set
HASURA_GRAPHQL_ADMIN_SECRET; without it the GraphQL endpoint and Console are open to anyone. Use JWT (HASURA_GRAPHQL_JWT_SECRET, claims underhttps://hasura.io/jwt/claimswithx-hasura-default-roleandx-hasura-allowed-roles) or webhook auth for users; never ship the admin secret to a browser - Production settings — Set
HASURA_GRAPHQL_ENABLE_CONSOLEandHASURA_GRAPHQL_DEV_MODEto"false"; dev mode leaks internal error details - Subscriptions for real-time — Use GraphQL subscriptions instead of polling; Hasura handles WebSocket efficiently
- Use views for complex queries — Create PostgreSQL views for complex aggregations; track them as tables in Hasura