---
title: "Deploy Pocket ID | (Just Updated) OIDC Provider with Passkeys, Admin and API Key Preset, Data Survives Redeploys"
description: "Pocket ID OIDC provider: passkey login, API key and admin preset"
category: "Authentication"
url: https://railway.com/deploy/pocket-id-or-just-updated-oidc-provider-
---

# Deploy Pocket ID | (Just Updated) OIDC Provider with Passkeys, Admin and API Key Preset, Data Survives Redeploys

Pocket ID OIDC provider: passkey login, API key and admin preset

**[Deploy Pocket ID | (Just Updated) OIDC Provider with Passkeys, Admin and API Key Preset, Data Survives Redeploys on Railway](https://railway.com/template/pocket-id-or-just-updated-oidc-provider-)**

Machine-readable deploy manifest (JSON, validated by TemplateCI): https://railway.com/deploy/pocket-id-or-just-updated-oidc-provider-/manifest.json

- **Creator:** SuperSlowSloth
- **Category:** Authentication

## Template content

### pocket-id

- **Image:** ghcr.io/pocket-id/pocket-id:v2.19.2@sha256:3ee23895d6a3adb5dd5de801a4fd31fc31b960e6dfbea9b5200e33cf477695eb
- **Start command:** `/bin/sh -c 'export TRUST_PROXY=true ANALYTICS_DISABLED=true P="${PORT:-8080}"; HOST=127.0.0.1 /app/docker/entrypoint.sh /app/pocket-id & S=$!; i=0; while [ $i -lt 90 ] && ! wget -q -O /dev/null "http://127.0.0.1:$P/healthz"; do i=$((i+1)); sleep 1; done; if wget -q -O /dev/null --header="Content-Type: application/json" --post-data="{\"username\":\"admin\",\"firstName\":\"Admin\",\"lastName\":\"User\",\"email\":\"admin@example.com\"}" "http://127.0.0.1:$P/api/signup/setup"; then echo "[boot] admin user created"; else echo "[boot] admin user already exists"; fi; kill $S; wait $S; echo "[boot] data dir owner=$(stat -c %u:%g /app/data)"; exec /app/docker/entrypoint.sh /app/pocket-id'`
- **Health check:** /healthz
- **Public domain:** Yes

## Documentation

# Deploy and Host Pocket ID on Railway

**Built for an LLM agent to deploy and use.** There is no setup page to open and nothing to type into the deploy form: the admin account is created on first boot, the API key and encryption key are generated, and the contract below lists every address, variable name and one script that registers an OIDC client and exchanges it for a token.

Pocket ID is an open source OpenID Connect (OIDC) identity provider that signs users in with passkeys, so there are no passwords to store. Your apps use it as their single sign-on server: Grafana, Portainer, Immich, Gitea and anything else that speaks OIDC. It is a single Go binary with a SQLite database and uses little memory.

## About Hosting Pocket ID

This template runs one service: the official `ghcr.io/pocket-id/pocket-id` v2.19.2 image, pinned by digest, with a volume on `/app/data`. The volume holds the SQLite database, the signing keys and uploaded logos, so users, passkeys and OIDC clients survive redeploys. `ENCRYPTION_KEY` is generated once and kept as a variable, so the data stays readable after a redeploy.

Pocket ID's own first-run page makes whoever opens it first the administrator. On a public URL that is a race you can lose, so the start command closes it: it boots the server on the loopback address only, creates the `admin` user, stops it and then starts the real server on all interfaces. On a redeploy the admin already exists and the step does nothing. The address in `APP_URL` is your Railway domain, which is the domain every passkey is bound to, so add a custom domain only before you register passkeys.

### Agent contract

- Public URL: `POCKET_ID_PUBLIC_URL` (`https://` plus the service's `RAILWAY_PUBLIC_DOMAIN`). The OIDC issuer is this URL, discovery is at `/.well-known/openid-configuration` and the admin UI is at `/`.
- Private URL: `http://pocket-id.railway.internal:8080` (`POCKET_ID_PRIVATE_URL`). Use the public URL as the issuer in your app, since that is the issuer Pocket ID signs.
- Credentials: `STATIC_API_KEY` (full admin API access) and `ENCRYPTION_KEY` (do not share, do not change after first boot).
- Auth: send the header `X-API-KEY: $STATIC_API_KEY` on every `/api/` call. The admin user is `admin`; it has no passkey until you mint a one-time login link (last lines of the script) and open it.
- Health: `GET /healthz` returns 204 without credentials.
- API: `GET /api/users`, `POST /api/users/{id}/one-time-access-token`, `GET` and `POST /api/oidc/clients`, `POST /api/oidc/clients/{id}/secrets`, `DELETE /api/oidc/clients/{id}`, `POST /api/oidc/token`.

```bash
# run with POCKET_ID_PUBLIC_URL and STATIC_API_KEY set
U="$POCKET_ID_PUBLIC_URL"; K="$STATIC_API_KEY"
curl -s -o /dev/null -w "healthz: %{http_code}\n" "$U/healthz"
curl -s "$U/.well-known/openid-configuration" | sed -n 's/.*"issuer":"\([^"]*\)".*/issuer: \1/p'
curl -s -o /dev/null -w "no key: %{http_code}\n" "$U/api/users"
curl -s -H "X-API-KEY: $K" "$U/api/users" | grep -o '"username":"admin"' | head -1
ID=$(curl -s -H "X-API-KEY: $K" -H "Content-Type: application/json" -d '{"name":"agent-test","callbackURLs":["https://app.example.com/callback"],"isPublic":false,"pkceEnabled":true}' "$U/api/oidc/clients" | sed -n 's/^{"id":"\([^"]*\)".*/\1/p')
SECRET=$(curl -s -X POST -H "X-API-KEY: $K" "$U/api/oidc/clients/$ID/secrets" | sed -n 's/.*"secret":"\([^"]*\)".*/\1/p')
curl -s -o /dev/null -w "token: %{http_code}\n" -d grant_type=client_credentials -d client_id="$ID" -d client_secret="$SECRET" "$U/api/oidc/token"
curl -s -o /dev/null -w "delete client: %{http_code}\n" -X DELETE -H "X-API-KEY: $K" "$U/api/oidc/clients/$ID"
AID=$(curl -s -H "X-API-KEY: $K" "$U/api/users" | sed -n 's/.*"id":"\([^"]*\)","username":"admin".*/\1/p')
TOKEN=$(curl -s -X POST -H "X-API-KEY: $K" -H "Content-Type: application/json" -d '{}' "$U/api/users/$AID/one-time-access-token" | sed -n 's/.*"token":"\([^"]*\)".*/\1/p')
echo "admin login link (open once, then add a passkey): $U/lt/$TOKEN"
```

A fresh deploy prints `healthz: 204`, the issuer (your public URL), `no key: 401`, `"username":"admin"`, `token: 200`, `delete client: 204` and a login link. In your own app, use the client `id` and the secret from the `/secrets` call as the OIDC client id and secret, the public URL as the issuer, and your app's callback URL as `callbackURLs`.

## Why Deploy Pocket ID on Railway?

Railway is a singular platform to deploy your infrastructure stack. Railway will host your infrastructure so you don't have to deal with configuration, while allowing you to vertically and horizontally scale it.

By deploying Pocket ID on Railway, you are one step closer to supporting a complete full-stack application with minimal burden. Host your servers, databases, AI agents, and more on Railway.

- **Agent-ready** - generated keys, a ready admin account, a contract block and one working script; nothing to type into the deploy form.
- **No first-visitor takeover** - the admin is created before the server is reachable, so nobody else can claim it from the setup page.
- **State survives redeploys** - the database and signing keys live on a volume mounted at the path Pocket ID v2 actually writes to, and the image is pinned by digest.
- **A healthcheck that works** - `/healthz` is checked on every deploy, so a bad deploy is caught instead of served.
- **One light service** - no external database or cache to run alongside.

## Common Use Cases

- **Single sign-on for a self-hosted stack** - one login with a passkey for Grafana, Gitea, Portainer, Immich and other OIDC apps.
- **Auth for an agent-built app** - an AI agent registers an OIDC client through the API, wires the issuer and secret into the app, and deletes it when done.
- **Replace Keycloak or Authentik for a small team** - passkey-only sign-in with users, groups and per-client access, without running a heavy identity stack.

## Dependencies for Pocket ID Hosting

- One Railway volume on `/app/data`, created by the template.

### Deployment Dependencies

- [Pocket ID repository](https://github.com/pocket-id/pocket-id)
- [Official image](https://github.com/pocket-id/pocket-id/pkgs/container/pocket-id)
- [Pocket ID documentation](https://pocket-id.org/docs)

### Implementation Details

| Variable | Purpose |
| --- | --- |
| `ENCRYPTION_KEY` | Encrypts the signing keys in the database. Generated per deploy; keep it, since changing it makes stored keys unreadable. |
| `STATIC_API_KEY` | Admin-level API key. Generated per deploy. |
| `APP_URL` | HTTPS address of the instance. It is the OIDC issuer and the domain passkeys are bound to. |
| `POCKET_ID_PUBLIC_URL` | Same address, named for the contract above. |
| `POCKET_ID_PRIVATE_URL` | Address on the private network. |

The start command sets `TRUST_PROXY`, turns off usage analytics, creates the `admin` user through the setup endpoint while the server listens on loopback only, then runs the image's own entrypoint, which fixes ownership of the volume and drops to a non-root user.


## Similar templates

- [Keycloak](https://railway.com/deploy/mSwigX) — [2026-11-04] Keycloak with keywind theme + Apple & Discord providers
- [lua-protector](https://railway.com/deploy/lua-protector) — Test deployed my project first
- [bknd](https://railway.com/deploy/p4nTYL) — Feature-rich yet lightweight backend

Open this page in a browser: https://railway.com/deploy/pocket-id-or-just-updated-oidc-provider-
