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
| Requirement | Not negotiable because | |
|---|---|---|
| Database | PostgreSQL 16 | SQLite is not supported and is not a degraded mode — it is absent. The schema uses partitioned tables, partial unique indexes and JSONB. |
| Cache | Redis 7 | Sessions, rate limits, one-time codes, revocation markers, refresh-token replay detection. Treat it as a cache: nothing durable lives only there. |
| Runtime | One container, HTTP | Stateless. Run as many as you like behind one address. |
| TLS | Terminated in front of HAP | HAP serves plain HTTP and trusts X-Forwarded-*. It must not be reachable except through your proxy. |
| Outbound email | Only if people sign in by emailed code or link | Without 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
| Variable | What it is | If you get it wrong |
|---|---|---|
HAP_MASTER_ENCRYPTION_KEY | base64 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_KEY | The 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_USERNAME | SMTP user | — |
HAP_SMTP_PASSWORD | SMTP 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 — withHAP_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=dbMandatory 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 | prodHAP_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 trueHAP_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_PASSWORDAn 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_NAMESPACEto 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"
# -> 204Step 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
| Decision | Why 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 kept | It 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 slugs | They appear in every URL people and applications use. There is no rename. |
HAP_KEY_PROVIDER | Moving 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.