Passkeys / WebAuthn
    Docker Compose

    Deploy Hanko Passwordless Authentication on a VPS

    Self-host the Hanko backend on a RamNode VPS — passkeys, email passcodes, OAuth, and JWT issuance with PostgreSQL and automatic TLS.

    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 example app.example.com. The relying party ID is the registrable domain those share, for example example.com.
    • Ports 80 and 443 open.

    Step 1: Install Docker

    shell
    sudo apt update && sudo apt upgrade -y
    curl -fsSL https://get.docker.com | sudo sh
    sudo usermod -aG docker $USER

    Log out and back in, then verify:

    shell
    docker version
    docker compose version

    Step 2: Lay out the deployment

    shell
    mkdir -p ~/hanko/config && cd ~/hanko

    Create a docker-compose.yml that runs PostgreSQL, a one shot migration container, and the backend:

    shell
    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:

    shell
    echo "DB_PASSWORD=$(openssl rand -hex 16)" > .env
    openssl rand -hex 32   # copy this for the config below

    Step 4: Write the backend config

    Create config/config.yaml:

    shell
    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

    shell
    docker compose up -d
    docker compose logs -f hanko

    Once it reports listening, confirm the public API answers locally:

    shell
    curl http://127.0.0.1:8000/.well-known/jwks.json

    You 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:

    shell
    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 caddy

    Set /etc/caddy/Caddyfile to expose only the public API:

    shell
    auth.example.com {
        reverse_proxy 127.0.0.1:8000
    }

    Reload:

    shell
    sudo systemctl restart caddy

    Do 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

    shell
    sudo ufw allow OpenSSH
    sudo ufw allow 80/tcp
    sudo ufw allow 443/tcp
    sudo ufw enable

    Step 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:

    shell
    <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.keys lets you rotate JWT signing keys while old tokens remain verifiable during the transition.
    • Pin the image tag. Replace latest with a specific release tag in production so an upgrade is a deliberate act, not a side effect of a restart.

    Backups

    shell
    docker compose exec postgres pg_dump -U hanko hanko > hanko-$(date +%F).sql

    Back 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:

    shell
    docker compose pull
    docker compose up -d

    The migration container applies any schema changes before the new backend starts.

    Troubleshooting

    • Passkey registration fails immediately. The relying party id or origins do 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.origins and server.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-migrate completed 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.