Installing HAP into your own cloud account

Audience: an operator — or an installer written by one — standing up a complete, private Headless Auth Platform in an account nobody else administers. Written to be read by a program as well as a person: every value that must be decided is named, and every value that must not be guessed is marked.

This is not the guide for running HAP locally to develop against (see Run HAP locally with the Docker CLI) and not the guide for deploying it onto the shared WBSP platform (see Installation guide). It describes one private instance, serving one organisation, administered by whoever installs it.


1. What HAP needs from you

RequirementNot negotiable because
DatabasePostgreSQL 16SQLite is not supported and is not a degraded mode — it is absent. The schema uses partitioned tables, partial unique indexes and JSONB.
CacheRedis 7Sessions, rate limits, one-time codes, revocation markers, refresh-token replay detection. Treat it as a cache: nothing durable lives only there.
RuntimeOne container, HTTPStateless. Run as many as you like behind one address.
TLSTerminated in front of HAPHAP serves plain HTTP and trusts X-Forwarded-*. It must not be reachable except through your proxy.
Outbound emailOnly if people sign in by emailed code or linkWithout it, HAP prints codes to its own log — fine for a trial, useless for real users.

The container applies its own migrations on startup — its command is alembic upgrade head && gunicorn …. So a serving instance is proof the schema is current, and you never run migrations as a separate step. It also means the database user must own the schema, not merely read and write rows.

It listens on ${PORT:-8080}. The port is 8080 unless you set PORT. (Our local development guide uses 8000, which is the development server's default, not the image's — do not carry that number into a deployment.)


2. Configuration

Everything is environment-driven with a HAP_ prefix. Below is the complete set that matters for a fresh private instance; everything not listed has a default that is correct for you.

2.1 The four secrets — this list is complete

VariableWhat it isIf you get it wrong
HAP_MASTER_ENCRYPTION_KEYbase64 of exactly 32 random bytes (AES-256). Wraps every tenant's signing key and every reversible secret at rest.Lose it and you lose every tenant's signing key, and every token ever issued stops verifying. Back it up somewhere that is not the database it protects.
HAP_ADMIN_API_KEYThe root bootstrap credential. Presented as X-Admin-Key.It grants every administrative action in every tenant, the comparison is a plain equality test, and there is no rate limit and no lockout on that path. Generate it with a random generator, make it long, and treat it as the keys to the building.
HAP_SMTP_USERNAMESMTP user—
HAP_SMTP_PASSWORDSMTP password—

The two SMTP values are needed only when HAP_EMAIL_MODE=smtp. Generate the first two like this:

openssl rand -base64 32      # HAP_MASTER_ENCRYPTION_KEY  (32 bytes, base64)
openssl rand -base64 48      # HAP_ADMIN_API_KEY          (any long random string)

The master key is read lazily, not at startup. A container with it missing or malformed starts cleanly, serves /healthz, and then fails on the first operation that touches a key — creating a tenant, or the first sign-in — with HAP_MASTER_ENCRYPTION_KEY is not set. An installer should not treat "the container is up" as "the secrets are right"; §5 says what to check instead.

2.2 Set this one or lose every signing key

HAP_KEY_PROVIDER=db

Mandatory for any deployment where the container can be replaced — which is all of them. The default is env, which keeps per-tenant signing keys on the container's own filesystem. A restarted or rescheduled container does not have that filesystem, so every tenant's signing key vanishes, every issued token stops verifying, and every sign-in fails — with nothing in the logs naming the cause. It looks like an authentication bug for a week.

With db the keys persist in PostgreSQL as envelope-encrypted ciphertext, wrapped by HAP_MASTER_ENCRYPTION_KEY. The plaintext key never touches the database, so a database compromise alone yields nothing.

2.3 Identity of the instance

Four values that must all agree with the single public address people reach HAP at:

HAP_ISSUER_BASE_URL=https://hap.example.com     # no trailing slash
HAP_WEBAUTHN_RP_ID=hap.example.com              # the HOST only — no scheme, no port, no path
HAP_WEBAUTHN_ORIGIN=https://hap.example.com     # scheme + host, as a browser reports it
HAP_ENVIRONMENT=prod                            # dev | staging | prod

HAP_ISSUER_BASE_URL is published in every tenant's discovery document and signed into every token's iss. Changing it later invalidates every token in flight and every client's cached discovery. Decide the address before the first sign-in, not after.

The two WebAuthn values only matter if anyone registers a passkey — but they are checked by the browser against the page's real origin, so a mismatch makes passkeys fail in a way that looks like a browser problem. Set them correctly now; they cost nothing.

2.4 Email

HAP_EMAIL_MODE=smtp            # or `console`
HAP_EMAIL_FROM=no-reply@example.com
HAP_SMTP_HOST=smtp.example.com
HAP_SMTP_PORT=587              # default 587
HAP_SMTP_STARTTLS=true         # default true

HAP_EMAIL_MODE=smtp with no HAP_SMTP_HOST fails at startup, loudly and immediately — that one is validated on boot. console prints messages to the container log instead of sending them, which is a legitimate choice for an instance where nobody signs in by emailed code, and a trap for one where they do.

HAP_EMAIL_FROM must be an address your provider will actually send as.

2.5 Datastores

Give HAP either a URL per store:

HAP_DATABASE_URL=postgresql+asyncpg://user:password@host:5432/dbname
HAP_REDIS_URL=redis://:password@host:6379/0

…or the unprefixed variables a platform injects, which HAP assembles into URLs itself:

DATABASE_HOST DATABASE_PORT DATABASE_NAME DATABASE_USER DATABASE_PASSWORD DATABASE_SSL
REDIS_HOST REDIS_PORT REDIS_USERNAME REDIS_PASSWORD

An explicit HAP_* URL wins over the unprefixed set. With neither, HAP refuses to start and says which is missing — it never silently falls back to a local default.

If your Redis is shared and uses a per-tenant ACL, set REDIS_NAMESPACE to the key prefix your ACL confines you to. HAP applies it to every key transparently. Without it, a shared Redis with a least-privilege ACL refuses HAP's writes at runtime while every test you ran passed — because your test Redis had no ACL.


3. Bootstrapping, with nobody at a console

Four calls. All of them take X-Admin-Key: <HAP_ADMIN_API_KEY>. No screen, no human, no interactive step.

Step 1 — Create the tenant

curl -s -X POST "$HAP/v1/tenants" \
  -H "X-Admin-Key: $ADMIN_KEY" -H 'Content-Type: application/json' \
  -d '{"name":"Example Ltd","slug":"example","password_policy":{"min_length":12}}'
# -> 201 {"id":"<tenant uuid>", "slug":"example", "jwks_algorithm":"RS256"}

Keep the id. Every administrative call takes the tenant's UUID in X-Tenant-Id, never its slug — a slug there is a 400, and that is the single most common mistake made against this API. The slug is for URLs people visit (/t/example/…).

This call also seeds the tenant: a signing key, the default role, the built-in wbsp-platform:deploy and wbsp-platform:config-read roles, and the built-in hap_cli application for command-line sign-in. You do not create or seed any of those.

Step 2 — Enable interactive sign-in

curl -s -X PATCH "$HAP/v1/tenants/$TENANT_ID" \
  -H "X-Admin-Key: $ADMIN_KEY" -H 'Content-Type: application/json' \
  -d '{"hosted_login_enabled": true, "passwordless_jit_provisioning": true}'

Both default off, deliberately. Without the first, HAP's own sign-in page refuses to render. The second lets a person who has never been created become an identity the first time they prove an emailed code — convenient for an instance whose members are known by their email addresses, and not what you want if membership is meant to be explicit. It is independent of the sign-up policy, which can narrow it later but never widen it.

Step 3 — Create the first person

curl -s -X POST "$HAP/v1/identities" \
  -H "X-Admin-Key: $ADMIN_KEY" -H "X-Tenant-Id: $TENANT_ID" \
  -H 'Content-Type: application/json' \
  -d '{"email":"operator@example.com","email_verified":true}'
# -> 201 {"id":"<identity uuid>", ...}

password is optional — omit it and the person signs in by emailed code, which is what you want for someone who has never visited. email_verified: true is an operator assertion: only set it if you know the address belongs to them.

To make them an administrator of the tenant's own console as well:

curl -s -X PUT "$HAP/v1/identities/$IDENTITY_ID/tenant-admin" \
  -H "X-Admin-Key: $ADMIN_KEY" -H "X-Tenant-Id: $TENANT_ID"
# -> 204

Step 4 — Grant deploy authority

# Find the role's id — it is PER TENANT, so look it up rather than pinning it.
ROLE_ID=$(curl -s "$HAP/v1/rbac/roles" \
  -H "X-Admin-Key: $ADMIN_KEY" -H "X-Tenant-Id: $TENANT_ID" \
  | jq -r '.items[] | select(.name=="wbsp-platform:deploy") | .id')

curl -s -X POST "$HAP/v1/identities/$IDENTITY_ID/roles" \
  -H "X-Admin-Key: $ADMIN_KEY" -H "X-Tenant-Id: $TENANT_ID" \
  -H 'Content-Type: application/json' \
  -d "{\"role_id\":\"$ROLE_ID\"}"
# -> 201 {"status":"assigned"}

Read it back with GET /v1/identities/{id}/roles; remove it with DELETE /v1/identities/{id}/roles/{role_id}.

Grant the role before the first sign-in. Permissions are resolved live, at the moment a token is issued, from role state as it is then — never replayed from an earlier token. A grant made afterwards is not lost, but it reaches the person at their next token rather than retroactively, so a tool that signed in before the grant believes it has no authority until it refreshes.

Tenant administrator is not deploy authority. They are independent on purpose: one is authority over this HAP's own console, the other is a permission other systems read from the token. Granting one never implies the other.


4. Registering an application

Each application that signs people in needs a registration. That is its own guide — Registering an application in a tenant — and the short version is: name, redirect_uris (matched as exact strings), scopes, confidential: true if it can keep a secret, and home_url so it appears on people's menus.

The only point that belongs here: POST /v1/applications is gated on applications:write or identities:write, and the client secret is shown once, at creation, and is never retrievable. An installer must capture it from that response or rotate it later; there is no endpoint that will tell you what it was.


5. Verifying the installation

In order, because each step depends on the one before it.

# 1. The process is alive.
curl -s "$HAP/healthz"                      # -> 200

# 2. It can reach PostgreSQL and Redis.
curl -s "$HAP/readyz"                       # -> 200; non-200 names the store that is unreachable

# 3. The master key is right — THIS is the one "container is up" does not prove.
#    Creating a tenant generates and wraps a signing key, so it exercises the key path.
#    (Step 1 of §3 is this check.)

# 4. The tenant resolves and its signing keys are published.
curl -s "$HAP/t/example/.well-known/openid-configuration" | jq .issuer
curl -s "$HAP/t/example/oauth2/jwks" | jq '.keys | length'   # -> 1 or more

# 5. The issuer matches what you configured, exactly.
#    If it does not, fix HAP_ISSUER_BASE_URL now — before anyone signs in.

Then restart the container and run steps 4 and 5 again. This is the check that catches a missing HAP_KEY_PROVIDER=db: with the filesystem provider the keys were on the container you just replaced, so the JWKS comes back empty or different. It is the single highest-value test in this document, it takes ten seconds, and nothing else will reveal that mistake until users are locked out.


6. What to decide before you start, because changing it later is expensive

DecisionWhy it is hard to change
The public address (HAP_ISSUER_BASE_URL)It is signed into every token and published in every discovery document. Changing it invalidates tokens in flight and every client's cached configuration.
Where HAP_MASTER_ENCRYPTION_KEY is keptIt cannot be rotated by configuration alone: every signing key and reversible secret is wrapped with it. Decide where it lives and how it is backed up on day one.
Tenant slugsThey appear in every URL people and applications use. There is no rename.
HAP_KEY_PROVIDERMoving from env to db after keys exist means the existing keys are on a filesystem that will not be there. Start with db.

Everything else — session lifetimes, sign-up rules, the refresh-replay grace window, branding, email settings — is a live setting you can change whenever you like through the admin API, and takes effect without a restart.