Self-hosting

Gate is one Next.js application and one PostgreSQL database. The portal, HTTP API, OIDC provider, Stripe integration, and background worker all run in the same process. No cloud service is required.

Requirements

  • An already configured PostgreSQL 16+ database and connection credentials
  • Docker Engine on Linux AMD64 for published images
  • Node.js 26.5.0 and pnpm 12.3.4 when running from source

Environment variables

VariableDefaultNotes
DATABASE_URLpostgres://gate:gate@127.0.0.1:5432/gateRequired in production. The only infrastructure service
BETTER_AUTH_SECRETdevelopment placeholderRequired in production. Minimum 32 characters
GATE_CONFIG_MASTER_KEYfixed development keyRequired in production. Base64, exactly 32 bytes decoded
GATE_BASE_URLresolved from NODE_ENVMust be the public origin. Determines the issuer
RESEND_API_KEYunsetAccount mail; needs MAIL_FROM too
MAIL_FROMunsetSender verified in Resend

GATE_BASE_URL defaults to https://evonia-gate.annatarhe.com when NODE_ENV is production and to http://localhost:3621 otherwise, so a standard deployment never sets it. Self-hosting on another domain does, and the value must match the public origin exactly.

In production, assertProductionEnvironment() refuses to start if DATABASE_URL, BETTER_AUTH_SECRET, or GATE_CONFIG_MASTER_KEY is unset. A master key that does not decode to exactly 32 bytes is rejected in every environment. Verification and password-reset mail is sent only when both RESEND_API_KEY and MAIL_FROM are set.

Generate the two secrets independently:

curl
openssl rand -base64 48   # BETTER_AUTH_SECRET
openssl rand -base64 32   # GATE_CONFIG_MASTER_KEY

What is not configurable

Trusted origins, CORS origins, Stripe return-URL origins, and product token audiences all come from hard-coded lists in src/server/origins.ts. Adding a product means editing that file — one place, but still a code change rather than configuration.

A project's MCP server URL is the one audience that is configured at runtime: setting it in the project's settings makes it a valid resource within about thirty seconds.

Local setup

curl
cp .env.example .env.local
pnpm install --frozen-lockfile
# Set DATABASE_URL to your existing database and fill in the secrets first.
node --env-file=.env.local node_modules/drizzle-kit/bin.cjs migrate
pnpm dev

Gate listens on port 3621. Create an account, have your database administrator grant Gate administrator access, then create an organization and project. Registration alone does not grant dashboard access.

When the schema changes, run pnpm db:generate and commit the SQL and metadata under drizzle/.

Docker

Prepare .env.production with your existing DATABASE_URL, generated BETTER_AUTH_SECRET and GATE_CONFIG_MASTER_KEY, and your public GATE_BASE_URL. Use plain KEY=value lines, without shell quotes or interpolation. Gate does not provision PostgreSQL.

curl
set -e
# Select a published release tag or digest.
GATE_IMAGE=ghcr.io/evoniaai/gate:v0.1.1
docker pull "$GATE_IMAGE"
docker run --rm --network host --env-file .env.production \
  "$GATE_IMAGE" node scripts/migrate.mjs
docker run -d --name gate --restart unless-stopped --network host \
  --env-file .env.production -e HOSTNAME=127.0.0.1 "$GATE_IMAGE"

These commands use Linux host networking to reach your existing database and bind Gate to localhost for a host TLS proxy. The image uses Next's standalone output on node:26.5.0-alpine and runs as a non-root user. It applies committed migrations before starting; failed migration prevents startup.

Stop existing Gate replicas and back up the database before upgrades. Run only one migration process at a time. After a successful migration, replace the stopped application container with the new image. Application rollback does not undo database migrations. See the repository's docs/deployment.md for the complete deployment, backup, and recovery procedure.

Reverse proxy

Put an ordinary TLS terminator in front of port 3621 and forward Host, X-Forwarded-Proto, and X-Forwarded-For. GATE_BASE_URL must match the public origin exactly, because it determines the issuer that every relying party verifies.

Health check: GET /api/v1/health, which runs select 1.

Background jobs

The job runner starts from src/instrumentation.ts and polls every 5 seconds. Every /api/v1 request also drains pending jobs after responding.

Jobs are claimed with FOR UPDATE SKIP LOCKED, retried up to 10 times, and recovered if they sit in processing for more than five minutes. Running one job runner per application replica is safe and expected — concurrent replicas will not double-process.

Endpoints to expose

PurposePath
Portal/dashboard
Documentation/docs
API/api/v1
OpenAPI/api/v1/openapi.json
OpenAPI explorer/docs/api-reference
OIDC discovery/.well-known/openid-configuration
OAuth metadata/.well-known/oauth-authorization-server
JSON Web Keys/.well-known/jwks.json
Auth and OIDC routes/api/auth/*
Stripe webhook/api/v1/webhooks/stripe/{environmentId}
Health/api/v1/health

The .well-known routes must be reachable at the root, not under a path prefix. Relying parties find everything else through discovery, but they find discovery by convention.

Backups

Back up PostgreSQL and GATE_CONFIG_MASTER_KEY separately. A database backup without the master key cannot decrypt secrets or Stripe credentials; the master key alone is useless. Storing them together defeats the encryption.

Rolling out to a product

For each product, create one project, register its browser, CLI, and mobile apps as OAuth clients, and give its backend an API key limited to the environment it runs in.

During migration, accept both the product's existing token and the Gate token for a period, then move sign-in over. Rawback keeps Apple in-app purchase as a product-specific payment source and writes the resulting entitlement grants to Gate; new Stripe billing goes through Gate directly.

Verification

curl
pnpm lint
pnpm fmt:check
pnpm typecheck
pnpm test
pnpm build