Run HAP locally with the Docker CLI (local login server)

Run the Headless Auth Platform on your own machine from the public image tooltwist/wbsp-hap:v1.0 (Docker Hub) and use it as the OIDC/OAuth2 login server for apps you are developing locally. This guide uses the plain docker CLI (no Docker Compose) and covers the environment variables, the admin key ("admin account"), and the one-time tenant/app setup so a locally-run application (e.g. on http://localhost:3000) can authenticate against it.

Scope: a self-contained local dev/test setup. It is not a production deployment — it uses console email, a single bootstrap admin key, and local datastores. For real hosting see installation-guide.md.

What you'll run

Three containers on one Docker network:

ContainerImagePurpose
hap-postgrespostgres:16PostgreSQL 16 (required; SQLite is not supported)
hap-redisredis:7Redis 7 (sessions, rate limits, revocation markers)
haptooltwist/wbsp-hap:v1.0the auth server — serves OIDC at http://localhost:8000/t/{tenant}/…

The HAP image applies database migrations automatically on startup (alembic upgrade head), then serves on port 8000.

Prerequisites

  • Docker installed and running (docker version).
  • The image is public, so no login is needed: docker pull tooltwist/wbsp-hap:v1.0.

Step 1 — Create a network

So the containers can reach each other by name:

docker network create hap-net

Step 2 — Start PostgreSQL and Redis

docker run -d --name hap-postgres --network hap-net \
  -e POSTGRES_USER=hap -e POSTGRES_PASSWORD=hap -e POSTGRES_DB=hap \
  -p 54390:5432 postgres:16

docker run -d --name hap-redis --network hap-net \
  -p 63390:6379 redis:7

The published host ports (54390, 63390) are optional — only for inspecting the datastores from your host. HAP reaches them inside the network on their standard ports (5432, 6379).

Wait a few seconds for Postgres to accept connections:

until docker exec hap-postgres pg_isready -U hap >/dev/null 2>&1; do sleep 1; done; echo "postgres ready"

Step 3 — Generate the secrets (incl. the admin key)

HAP needs two secrets. Generate them once and keep them:

# 1. At-rest encryption key — base64-encoded 32 bytes (required by the env key provider)
openssl rand -base64 32        # e.g. on macOS/Linux

# 2. The ADMIN API KEY — this IS the platform "admin account" (see note below). Any strong string:
openssl rand -hex 24

What "admin account" means here. HAP has no admin username/password. The platform administrator credential is the bootstrap admin API key you set as HAP_ADMIN_API_KEY. You present it as the X-Admin-Key header to create tenants, register applications, and toggle tenant settings. Treat it like a root password. (A per-tenant tenant admin — a real user who can use the browser Admin Console — is a separate, optional concept; see the note at the end of Step 8.)

Step 4 — Write an env file

Create hap.env (one KEY=VALUE per line, no quotes), pasting the two secrets from Step 3:

# Datastores — reached by container name on the hap-net network
HAP_DATABASE_URL=postgresql+asyncpg://hap:hap@hap-postgres:5432/hap
HAP_REDIS_URL=redis://hap-redis:6379/0

# Secrets
HAP_MASTER_ENCRYPTION_KEY=<paste the base64 32-byte key>
HAP_ADMIN_API_KEY=<paste the admin key>

# Public issuer — apps use {HAP_ISSUER_BASE_URL}/t/{tenant_slug} as the OIDC issuer
HAP_ISSUER_BASE_URL=http://localhost:8000

# WebAuthn/passkeys (defaults are fine for the email-OTP login flow below)
HAP_WEBAUTHN_RP_ID=localhost
HAP_WEBAUTHN_ORIGIN=http://localhost:8000

# Runtime
HAP_ENVIRONMENT=dev

# Email: "console" prints OTP / magic-link codes to the container LOGS instead of sending
# real email — this is how you receive login codes locally. (Use "smtp" + HAP_SMTP_* for real mail.)
HAP_EMAIL_MODE=console

# Signing-key storage: "env" persists keys to /app/.keystore (mount a volume in Step 5 so they
# survive restarts). Alternatively set HAP_KEY_PROVIDER=db to keep them in Postgres (no volume needed).
HAP_KEY_PROVIDER=env

Keep hap.env out of version control — it holds your keys.

Step 5 — Start HAP

docker run -d --name hap --network hap-net -p 8000:8000 \
  --env-file hap.env \
  -v hap-keystore:/app/.keystore \
  --restart on-failure \
  tooltwist/wbsp-hap:v1.0
  • -v hap-keystore:/app/.keystore keeps the generated per-tenant signing keys across restarts (env key provider). Omit it if you set HAP_KEY_PROVIDER=db.
  • --restart on-failure lets the container retry if it raced Postgres on first boot.

Watch it migrate and start:

docker logs -f hap          # look for alembic running, then "Uvicorn/Gunicorn running on 0.0.0.0:8000"

Step 6 — Verify it's up

curl -s localhost:8000/healthz   # {"status":"ok"}
curl -s localhost:8000/readyz    # {"status":"ok","checks":{"database":"ok","redis":"ok"}}

If /readyz reports ok for both database and redis, the server is ready.


Step 7 — Create a tenant and enable interactive login

A fresh server has no tenants. Create one (platform-admin call — X-Admin-Key only, no X-Tenant-Id). Use the admin key from hap.env:

ADMIN_KEY=<your HAP_ADMIN_API_KEY>

curl -s -X POST localhost:8000/v1/tenants \
  -H "X-Admin-Key: $ADMIN_KEY" -H "Content-Type: application/json" \
  -d '{"name":"Local","slug":"local","password_policy":{"min_length":8}}'
# → note the "id" (the tenant UUID) from the response

Turn on Hosted Login (the platform-rendered sign-in page) and just-in-time provisioning so a new email can log in without pre-creating the user. This is what lets your apps delegate login to HAP:

TENANT_ID=<id from the create response>

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

Confirm discovery resolves (this is the URL your app's OIDC client points at):

curl -s localhost:8000/t/local/.well-known/openid-configuration   # JSON config = success

Step 8 — Register your local application

Register the app you run on http://localhost:3000 (tenant-scoped admin call — needs both X-Admin-Key and X-Tenant-Id). Use a confidential web client for a server-side app (Next.js, etc.); use application_type:"spa" with confidential:false for a browser-only SPA (PKCE, no secret):

curl -s -X POST localhost:8000/v1/applications \
  -H "X-Admin-Key: $ADMIN_KEY" -H "X-Tenant-Id: $TENANT_ID" \
  -H "Content-Type: application/json" \
  -d '{"name":"My Local App","application_type":"web","confidential":true,
       "redirect_uris":["http://localhost:3000/api/auth/callback"],
       "scopes":["openid","profile","email","offline_access"]}'

The response returns client_id and (for confidential clients) a client_secretshown once, so copy it now. redirect_uris must match your app's callback exactly; adjust the path to your framework (e.g. Auth.js/NextAuth uses /api/auth/callback/<provider-id>).

(Optional) Promote a tenant admin for the browser console

The Admin Console at http://localhost:8000/t/local/hosted/admin requires a signed-in tenant admin (a user identity flagged is_tenant_admin), which the admin API key bootstraps. It's not needed to authenticate apps via the API flow above — see hosted-login-guidelines.md and user-guide-detailed.md.

Step 9 — Point your application at this HAP

Configure your app's OIDC client with:

SettingValue
Issuerhttp://localhost:8000/t/local
Discovery URLhttp://localhost:8000/t/local/.well-known/openid-configuration
Client IDthe client_id from Step 8
Client secretthe client_secret from Step 8 (confidential clients)
Redirect URIhttp://localhost:3000/api/auth/callback (must match exactly)
Scopesopenid profile email offline_access

Logging in (fully local, no real email):

  1. In your app, start login. It redirects the browser to http://localhost:8000/t/local/oauth2/authorize?....
  2. With no session cookie, HAP renders the Hosted Login page. Enter any email address.
  3. Because HAP_EMAIL_MODE=console, the one-time code is printed to the HAP logs, not emailed. Read it: docker logs --tail 20 hap.
  4. Enter the code. HAP creates the user (JIT), sets the session cookie, and redirects the browser back to your redirect_uri with an authorization code.
  5. Your app exchanges the code at http://localhost:8000/t/local/oauth2/token for tokens.

That's a complete local login. See the Integration guide for the exact authorize/token request shapes.


Managing the containers

docker stop hap hap-redis hap-postgres            # stop (data + keystore preserved in volumes)
docker start hap-postgres hap-redis hap           # start again (order matters: datastores first)
docker logs -f hap                                # follow logs (OTP codes appear here)

# Full teardown (DELETES all data: users, tenants, apps, signing keys):
docker rm -f hap hap-redis hap-postgres
docker volume rm hap-keystore                     # only if you mounted it
docker network rm hap-net

Troubleshooting

  • hap container exits immediately / restartsdocker logs hap. Usually Postgres wasn't ready yet (it will retry with --restart on-failure) or a datastore URL is wrong.
  • database connection not configured / redis connection not configuredHAP_DATABASE_URL / HAP_REDIS_URL are unset or unreachable. Inside hap.env the host must be the container name (hap-postgres, hap-redis) and all three containers must share --network hap-net.
  • /readyz shows database: error — Postgres isn't healthy or credentials don't match the ones in Step 2.
  • Startup error about the master keyHAP_MASTER_ENCRYPTION_KEY must be a base64-encoded 32-byte value (Step 3).
  • Admin calls return 401 — the X-Admin-Key header must equal HAP_ADMIN_API_KEY. Tenant creation/PATCH take no X-Tenant-Id; application calls require it.
  • 404 unknown tenant on app callsX-Tenant-Id must be the tenant UUID from the create response, not the slug.
  • No login code arrives — that's expected with HAP_EMAIL_MODE=console; the code is in docker logs hap. For real email set HAP_EMAIL_MODE=smtp and the HAP_SMTP_* vars.
  • Tokens become invalid after a restart — the env key provider regenerated signing keys because /app/.keystore wasn't persisted. Mount the hap-keystore volume (Step 5) or use HAP_KEY_PROVIDER=db.

Environment variable reference

VariableRequiredExample / defaultPurpose
HAP_DATABASE_URLyespostgresql+asyncpg://hap:hap@hap-postgres:5432/hapPostgres DSN
HAP_REDIS_URLyesredis://hap-redis:6379/0Redis URL
HAP_MASTER_ENCRYPTION_KEYyesbase64 32 bytesat-rest encryption (env key provider)
HAP_ADMIN_API_KEYyesstrong random stringthe platform admin credential (X-Admin-Key)
HAP_ISSUER_BASE_URLrecommendedhttp://localhost:8000base of the per-tenant OIDC issuer
HAP_KEY_PROVIDERnoenv (default) / dbwhere tenant signing keys are stored
HAP_WEBAUTHN_RP_ID / _ORIGINnolocalhost / http://localhost:8000passkey relying party
HAP_ENVIRONMENTnodevdev | staging | prod
HAP_EMAIL_MODEnoconsole (default) / smtpconsole prints OTP to logs; smtp sends mail
PORTno8000container listen port

See also