Hanko is an open source authentication platform built around passkeys from the ground up. Rather than bolting passwordless login onto an existing password system, Hanko was designed for WebAuthn and FIDO2 first, with email passcodes, OAuth social login, and optional passwords as fallbacks. It ships as a lightweight Go backend with drop in web components, so you can add a working passkey flow to almost any front end framework quickly. The backend is open source under AGPL, so self hosting has no user limits.
This guide deploys the Hanko backend with PostgreSQL on a RamNode VPS running Ubuntu 24.04 LTS, fronted by automatic TLS, and ready to integrate with your application.
What you are deploying
The core self hosted piece is the Hanko backend: a scalable Go authentication API that handles passkeys, email passcodes, OAuth, session management, and JWT issuing. It publishes its public signing keys at a JWKS endpoint so your application can verify the tokens it issues. You then add Hanko Elements (web components) to your front end, which talk to this backend.
A note on the relying party
Passkeys are bound to a domain, called the WebAuthn relying party. The backend must be told the exact domain your users will authenticate on. This is the single most important configuration value to get right, and it is why a stable domain with TLS is a hard requirement, not a nicety.
Prerequisites
- A RamNode VPS running Ubuntu 24.04 LTS. 2 GB RAM is comfortable for the backend plus PostgreSQL.
- Root or sudo access.
- A domain for the auth backend, for example
auth.example.com, and knowledge of the domain your web app runs on, for exampleapp.example.com. The relying party ID is the registrable domain those share, for exampleexample.com. - Ports 80 and 443 open.
Step 1: Install Docker
sudo apt update && sudo apt upgrade -y
curl -fsSL https://get.docker.com | sudo sh
sudo usermod -aG docker $USERLog out and back in, then verify:
docker version
docker compose versionStep 2: Lay out the deployment
mkdir -p ~/hanko/config && cd ~/hankoCreate a docker-compose.yml that runs PostgreSQL, a one shot migration container, and the backend:
services:
postgres:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_USER: hanko
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_DB: hanko
volumes:
- hanko_pg:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U hanko"]
interval: 10s
timeout: 5s
retries: 5
hanko-migrate:
image: ghcr.io/teamhanko/hanko:latest
command: migrate up
volumes:
- ./config/config.yaml:/etc/config/config.yaml
depends_on:
postgres:
condition: service_healthy
restart: on-failure
hanko:
image: ghcr.io/teamhanko/hanko:latest
command: serve all
volumes:
- ./config/config.yaml:/etc/config/config.yaml
ports:
- "127.0.0.1:8000:8000" # public API
- "127.0.0.1:8001:8001" # admin API, keep internal
depends_on:
hanko-migrate:
condition: service_completed_successfully
restart: unless-stopped
volumes:
hanko_pg:The migration container runs migrate up and exits; the backend only starts after it completes successfully. Both API ports are bound to localhost so only the reverse proxy reaches them, and the admin API on 8001 should never be publicly exposed.
Step 3: Generate secrets
The JWT signing keys are derived from a secret you provide. It must be a randomly generated string of at least 16 characters. Generate one along with a database password:
echo "DB_PASSWORD=$(openssl rand -hex 16)" > .env
openssl rand -hex 32 # copy this for the config belowStep 4: Write the backend config
Create config/config.yaml:
database:
user: hanko
password: ${DB_PASSWORD}
host: postgres
port: 5432
dialect: postgres
database: hanko
secrets:
keys:
# at least 16 chars; paste the openssl value from Step 3
- "PASTE_YOUR_32_BYTE_HEX_SECRET_HERE"
webauthn:
relying_party:
# the registrable domain shared by your app and auth host
id: "example.com"
display_name: "Example Login"
origins:
# the full origin(s) where the widget runs
- "https://app.example.com"
session:
lifespan: "12h"
cookie:
# allow the cookie across subdomains of the relying party
domain: "example.com"
server:
public:
cors:
allow_origins:
- "https://app.example.com"The relying party id is the domain passkeys are scoped to. The origins must list the exact origin, including scheme, where the Hanko widget runs. If your app runs on a non standard port, include it in the origin. Get these wrong and passkey registration silently fails.
Docker Compose does not interpolate ${DB_PASSWORD} inside the mounted YAML by default, so either hardcode the password into the config file (and lock its permissions) or template it in at deploy time. For a single node, hardcoding into a chmod 600 config is acceptable.
Step 5: Start the backend
docker compose up -d
docker compose logs -f hankoOnce it reports listening, confirm the public API answers locally:
curl http://127.0.0.1:8000/.well-known/jwks.jsonYou should get a JSON Web Key Set. That endpoint is how your application verifies Hanko issued tokens.
Step 6: Reverse proxy and TLS
Install Caddy:
sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list
sudo apt update && sudo apt install -y caddySet /etc/caddy/Caddyfile to expose only the public API:
auth.example.com {
reverse_proxy 127.0.0.1:8000
}Reload:
sudo systemctl restart caddyDo not proxy the admin API on 8001 to the internet. If you need it, reach it over SSH tunneling or a private network only.
Step 7: Firewall
sudo ufw allow OpenSSH
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enableStep 8: Integrate the front end
In your web application, install and register the Hanko Elements web components and point them at your backend. A minimal integration looks like:
<script type="module">
import { register } from "https://cdn.jsdelivr.net/npm/@teamhanko/hanko-elements/dist/elements.js";
register("https://auth.example.com");
</script>
<hanko-auth></hanko-auth>The <hanko-auth> element renders the full login and registration flow, including the passkey prompt and email passcode fallback. After a successful login, Hanko issues a JWT; your back end validates it against the JWKS endpoint from Step 5 before trusting the session. Hanko also provides a <hanko-profile> element for self service passkey, session, and MFA management.
Email passcodes without running a mail server
When a user's device does not support passkeys, Hanko falls back to a one time passcode sent by email. RamNode VPS plans are not intended for running a mail server, and deliverability from a VPS IP is poor regardless. Configure Hanko's email_delivery.smtp settings to relay through an external transactional email provider, using the host, port, user, and password your provider supplies. This keeps the passcode fallback working without you operating any mail infrastructure. If you run a passkey only configuration, you can skip email entirely.
Production notes
- Run passkey first. Hanko's strength is passwordless. Configure it for passkeys with email passcode fallback rather than reintroducing passwords, unless you have a specific reason.
- Rotate secrets thoughtfully. Adding a new key to
secrets.keyslets you rotate JWT signing keys while old tokens remain verifiable during the transition. - Pin the image tag. Replace
latestwith a specific release tag in production so an upgrade is a deliberate act, not a side effect of a restart.
Backups
docker compose exec postgres pg_dump -U hanko hanko > hanko-$(date +%F).sqlBack up the config file too, since it holds your signing secret and relying party settings. Automate with cron and store off the VPS.
Upgrading
Bump the image tag, then:
docker compose pull
docker compose up -dThe migration container applies any schema changes before the new backend starts.
Troubleshooting
- Passkey registration fails immediately. The relying party
idororiginsdo not match the domain serving the widget. This is the most common error. Recheck Step 4 against the exact scheme, host, and port your app uses. - CORS errors in the browser console. Add your app's origin to both
webauthn.relying_party.originsandserver.public.cors.allow_origins. - Sessions do not persist across subdomains. Set the session cookie domain to the registrable parent domain.
- JWKS endpoint returns nothing. The backend did not finish migrations. Check that
hanko-migratecompleted successfully in the logs.
Wrap up
You now run the Hanko backend on a RamNode VPS with PostgreSQL, automatic TLS, an external email relay for passcode fallback, and a working JWKS endpoint your application can verify tokens against. Drop the Hanko Elements components into your front end and your users get modern passkey login, with full control over their credential data living entirely on your own infrastructure.
