Hasura GraphQL Engine gives you an instant GraphQL API over PostgreSQL — point it at a database, track your tables, and you have queries, mutations, and subscriptions with a permission system on top. Hasura Cloud exists, but running your own instance is a legitimate choice: you keep the database and the engine in one place, one bill, one dashboard, and there's no per-request metering to think about.
Hasura ships as a Docker image, which makes it an unusual deploy: there's no application code to build. Your "repo" is a Dockerfile plus your migrations and metadata. Done right, that repo becomes the single source of truth for your entire API — every table, relationship, and permission rule versioned in Git. Here's the full setup.
Project structure: migrations and metadata as code
Don't click your schema together in the console and hope for the best. Use the Hasura CLI so every change lands in Git:
# Install the CLI
curl -L https://github.com/hasura/graphql-engine/raw/stable/cli/get.sh | bash
# Scaffold a project (config v3)
hasura init hasura-app
cd hasura-appThis gives you:
hasura-app/
├── config.yaml
├── migrations/ # SQL, versioned, forward + down
├── metadata/ # tracked tables, relationships, permissions
└── seeds/During development, run a local Hasura + Postgres and open the console through the CLI — not the one served by the engine directly:
hasura console --endpoint http://localhost:8080 --admin-secret myadminsecretThe difference matters: the CLI console writes every change you make to migrations/ and metadata/ on disk. The built-in console at :8080 applies changes to the running instance only, and they'll silently drift from your repo. This is the single most common self-hosted Hasura mistake.
When you change the schema, migrations get created for you; you can also write them by hand:
hasura migrate create add_orders_table --database-name default
hasura migrate apply --database-name default
hasura metadata applyThe Dockerfile
Hasura publishes a cli-migrations image variant that applies your migrations and metadata automatically at startup, before the engine begins serving. That's exactly what you want on a platform deploy — no separate release step to orchestrate:
FROM hasura/graphql-engine:v2.36.1.cli-migrations-v3
COPY migrations /hasura-migrations
COPY metadata /hasura-metadata
# Production defaults — override per-environment as needed
ENV HASURA_GRAPHQL_ENABLE_CONSOLE=false
ENV HASURA_GRAPHQL_DEV_MODE=false
ENV HASURA_GRAPHQL_ENABLED_LOG_TYPES="startup, http-log, webhook-log, websocket-log"
EXPOSE 8080Notes on the details:
- Pin the version tag.
lateston a schema-owning service is asking for a surprise upgrade mid-rollout. - The image looks for migrations at
/hasura-migrationsand metadata at/hasura-metadata— those paths are the contract, keep them exact. - Hasura listens on port 8080 by default. Most platforms detect the exposed port; if you need to change it,
HASURA_GRAPHQL_SERVER_PORTcontrols it. - Startup ordering is handled for you: migrations run first, then metadata is applied, then the engine serves traffic. If a migration fails, the container exits instead of serving a half-migrated schema — which is the behavior you want.
Environment variables that actually matter
| Variable | Purpose |
|---|---|
HASURA_GRAPHQL_DATABASE_URL | Postgres connection string for your data |
HASURA_GRAPHQL_METADATA_DATABASE_URL | Where Hasura stores its own metadata (can be the same DB) |
HASURA_GRAPHQL_ADMIN_SECRET | Non-negotiable in production |
HASURA_GRAPHQL_JWT_SECRET | JWT config if your app authenticates users |
HASURA_GRAPHQL_UNAUTHORIZED_ROLE | Role for requests with no auth (omit to reject them) |
HASURA_GRAPHQL_CORS_DOMAIN | Lock this to your frontend's origin |
HASURA_GRAPHQL_PG_CONNECTIONS | Connection pool size (default 50) |
One gotcha worth calling out now: Hasura does not read a plain DATABASE_URL — it wants the HASURA_GRAPHQL_-prefixed names. We'll deal with that in a minute.
Deploying on PandaStack
1. Provision the database
Create a managed PostgreSQL instance (14.x and 16.x are both available) from the dashboard. It's a real Postgres orchestrated by KubeBlocks on Kubernetes, with scheduled daily backups — 7-day retention on the free tier, 15 on Pro, 30 on Premium.
2. Connect the repo
Push the project — Dockerfile, migrations/, metadata/ — to GitHub and connect it as a container app. PandaStack sees the Dockerfile and builds it with rootless BuildKit in an ephemeral Kubernetes job; you can watch the build logs stream live while it runs. Attach the database you created to the app.
3. Wire the connection string
Attaching the database injects DATABASE_URL into the app automatically. Since Hasura wants its own variable names, add two environment variables in the app's settings, using that same connection string as the value:
HASURA_GRAPHQL_DATABASE_URL=<the injected DATABASE_URL value>
HASURA_GRAPHQL_METADATA_DATABASE_URL=<same value>Using one database for both data and metadata is fine at this scale — Hasura keeps its metadata in a separate hdb_catalog schema, so it won't collide with your tables.
While you're in the env var screen, set the rest:
HASURA_GRAPHQL_ADMIN_SECRET=<long random string>
HASURA_GRAPHQL_CORS_DOMAIN=https://yourapp.example.comGenerate the admin secret properly — openssl rand -hex 32 — and treat it like a database password. Anyone holding it has full read/write on everything.
4. Push and verify
git push origin mainThe build runs, migrations apply on boot, and the instance goes live. Verify it:
curl https://<your-app-url>/healthz
# OK
curl https://<your-app-url>/v1/version
# {"version":"v2.36.1"}Then confirm the API is actually locked down — this should fail without the admin secret:
curl -X POST https://<your-app-url>/v1/graphql \
-H 'Content-Type: application/json' \
-d '{"query":"{ __typename }"}'If that returns data instead of an auth error, stop and check HASURA_GRAPHQL_ADMIN_SECRET before doing anything else.
Production gotchas
Connection pool vs. plan limits. Hasura's default pool is 50 connections — exactly the connection limit on PandaStack's free database tier. That leaves zero headroom for psql, a second Hasura replica, or anything else. Set HASURA_GRAPHQL_PG_CONNECTIONS=20 on the free tier; on Pro (300 connections) or Premium (1000) the default is fine.
Console off, CLI on. Keep HASURA_GRAPHQL_ENABLE_CONSOLE=false in production. When you need the console against prod, tunnel it through the CLI from your machine — changes get written to your repo, and the next git push redeploys them through the same migration path as everything else:
hasura console \
--endpoint https://<your-app-url> \
--admin-secret $HASURA_GRAPHQL_ADMIN_SECRETAuth for real users. The admin secret is for you, not your frontend. Configure HASURA_GRAPHQL_JWT_SECRET to validate tokens from your auth provider, define roles with row-level permissions in metadata, and only set HASURA_GRAPHQL_UNAUTHORIZED_ROLE if you genuinely have public data — an anonymous role with carefully scoped select permissions, nothing more.
Subscriptions and scale-to-zero. GraphQL subscriptions hold WebSocket connections open. On PandaStack's free tier, idle apps scale to zero via KEDA and cold-start on the next request — fine for a staging instance or a hobby API, but if clients rely on long-lived subscriptions in production, run it on a paid compute tier where the instance stays warm.
Rolling back. Because your schema lives in migrations/ and your permissions in metadata/, a bad release is a git revert plus a push. The down migrations you wrote (you did write down migrations?) run through the same pipeline. This is the payoff for resisting the built-in console back in step one.
That's the whole system: one repo holding the engine version, the schema, and the permission rules, deployed by a push, with the database wired in next to it. If you want to see it run, [pandastack.io](https://pandastack.io) is the place to try it.