Identity Management
    Passkey-First

    Deploy Kanidm on a VPS

    Self-host Kanidm, a passkey-first Rust identity platform with OAuth2/OIDC, LDAPS, and Unix PAM support, on a RamNode VPS with Let's Encrypt TLS.

    Kanidm is a modern identity management platform written in Rust. It gives you a single place to store people, groups, and service accounts, then exposes them over OAuth2/OIDC, a read-only LDAPS interface, and a Unix daemon for SSH and PAM logins. It is passkey-first, has no Java or Kerberos stack under it, and runs comfortably in under 1 GB of RAM, which makes it the best fit of the major identity servers for a small RamNode instance.

    This guide covers a single-server production deployment on a RamNode KVM VPS: sizing, DNS, TLS with Let's Encrypt, container deployment, the first admin accounts, OAuth2 application integration, Unix client enrollment, backups, and upgrades.

    Verified against Kanidm 1.11.x, released August 2026. Kanidm ships quarterly and only supports upgrades one minor version at a time, so pin your version and read the release notes before every jump.


    1. Pick the right RamNode instance

    Kanidm is a single static binary with an embedded SQLite-backed database. It is small.

    Deployment sizevCPURAMDiskNotes
    Lab or personal (under 50 accounts)11 GB20 GBComfortable
    Small business (50 to 500 accounts)22 GB40 GBRecommended baseline
    Larger, with OAuth2 traffic2 to 44 GB60 GB+Adds headroom for backups and logs

    Order a Standard or Premium KVM plan. Choose a location close to your users, since every OAuth2 redirect and every PAM login pays the round trip. Both IPv4 and IPv6 work; Kanidm binds dual-stack by default.

    Pick a distribution you already patch on schedule. This guide uses Debian 13 as the container host, but any distribution with Docker or Podman works identically.


    2. Prepare the server

    SSH in as root and update.

    shell
    apt update && apt full-upgrade -y
    apt install -y curl ca-certificates ufw

    Set a real FQDN. Kanidm ties its domain and origin values to this name and changing them later is a domain rename operation, not a config edit.

    shell
    hostnamectl set-hostname idm.example.com

    Add the host to /etc/hosts so local lookups never depend on external DNS:

    shell
    echo "203.0.113.10 idm.example.com idm" >> /etc/hosts

    Create the DNS records at your provider:

    shell
    idm.example.com.   A     203.0.113.10
    idm.example.com.   AAAA  2001:db8::10

    Set the PTR record for the VPS in the RamNode control panel under the instance's networking section. Kanidm does not require reverse DNS, but every other service you will connect to it does, and it makes log correlation sane.

    Confirm the clock is disciplined. Token and passkey validation are time-sensitive.

    shell
    timedatectl set-ntp true
    timedatectl status

    Firewall

    shell
    ufw default deny incoming
    ufw default allow outgoing
    ufw allow 22/tcp
    ufw allow 80/tcp     # Let's Encrypt HTTP-01 only
    ufw allow 443/tcp    # Web UI, OAuth2, REST API
    ufw allow 636/tcp    # LDAPS, drop this rule if you do not need LDAP
    ufw enable

    Restrict 636 to the IPs of the applications that will actually read from LDAP rather than leaving it open to the internet:

    shell
    ufw delete allow 636/tcp
    ufw allow from 198.51.100.25 to any port 636 proto tcp

    3. Install the container runtime

    shell
    curl -fsSL https://get.docker.com | sh
    systemctl enable --now docker

    Podman works equally well. Substitute podman for docker throughout and use a Quadlet unit instead of Compose if you prefer rootless operation.


    4. Issue a TLS certificate

    Kanidm refuses to run without TLS. There is no plaintext mode and no self-signed shortcut that clients will accept, so get a real certificate first.

    shell
    apt install -y certbot
    certbot certonly --standalone -d idm.example.com --agree-tos -m admin@example.com --no-eff-email

    Create the data directory and copy the certificate in. The container process runs as UID 1000, so ownership matters.

    shell
    mkdir -p /srv/kanidm/data
    cp /etc/letsencrypt/live/idm.example.com/fullchain.pem /srv/kanidm/data/chain.pem
    cp /etc/letsencrypt/live/idm.example.com/privkey.pem  /srv/kanidm/data/key.pem
    chown -R 1000:1000 /srv/kanidm/data
    chmod 640 /srv/kanidm/data/key.pem

    Automate renewal with a deploy hook so you are not hand-copying files every 60 days:

    shell
    cat > /etc/letsencrypt/renewal-hooks/deploy/kanidm.sh <<'EOF'
    #!/bin/bash
    set -e
    DOMAIN=idm.example.com
    DEST=/srv/kanidm/data
    cp /etc/letsencrypt/live/$DOMAIN/fullchain.pem $DEST/chain.pem
    cp /etc/letsencrypt/live/$DOMAIN/privkey.pem  $DEST/key.pem
    chown 1000:1000 $DEST/chain.pem $DEST/key.pem
    chmod 640 $DEST/key.pem
    docker restart kanidmd
    EOF
    chmod +x /etc/letsencrypt/renewal-hooks/deploy/kanidm.sh

    Certbot's standalone plugin needs port 80 free at renewal time. Nothing else in this guide binds it, so leave 80 open in the firewall and unused.


    5. Write the server configuration

    Create /srv/kanidm/data/server.toml:

    shell
    version = "2"
    
    bindaddress = "[::]:8443"
    ldapbindaddress = "[::]:3636"
    
    db_path = "/data/kanidm.db"
    
    tls_chain = "/data/chain.pem"
    tls_key   = "/data/key.pem"
    
    domain = "idm.example.com"
    origin = "https://idm.example.com"
    
    log_level = "info"
    
    [online_backup]
    path = "/data/backups/"
    schedule = "00 03 * * *"
    versions = 14

    Two fields decide the fate of the deployment. domain is the SPN suffix and the WebAuthn relying party ID. origin is the URL users type in their browser, including the port if it is not 443. If you front Kanidm with a proxy or change the hostname later, every passkey registered against the old domain becomes invalid. Decide the name now.

    shell
    mkdir -p /srv/kanidm/data/backups
    chown -R 1000:1000 /srv/kanidm/data

    6. Start the server

    Create /srv/kanidm/docker-compose.yml:

    shell
    services:
      kanidmd:
        image: docker.io/kanidm/server:1.11.0
        container_name: kanidmd
        restart: unless-stopped
        network_mode: host
        volumes:
          - /srv/kanidm/data:/data

    Host networking keeps the source IP intact in the logs and avoids NAT hairpinning for the LDAPS listener. Bring it up:

    shell
    cd /srv/kanidm
    docker compose up -d
    docker logs -f kanidmd

    The container listens on 8443 and 3636 internally. Publish them on the standard ports with nftables redirects, or simply change bindaddress to [::]:443 and ldapbindaddress to [::]:636 and add cap_add: [NET_BIND_SERVICE] to the Compose service. The redirect approach:

    shell
    apt install -y nftables
    nft add table ip nat
    nft 'add chain ip nat prerouting { type nat hook prerouting priority -100 ; }'
    nft add rule ip nat prerouting tcp dport 443 redirect to :8443
    nft add rule ip nat prerouting tcp dport 636 redirect to :3636

    Persist those rules in /etc/nftables.conf before you reboot.

    Confirm the service answers:

    shell
    curl -sI https://idm.example.com/status

    7. Recover the built-in admin accounts

    Kanidm ships two privileged accounts and neither has a password until you set one. admin manages the server itself (domain settings, replication, schema). idm_admin manages people and groups. Use idm_admin for daily work.

    shell
    docker exec -it kanidmd kanidmd recover-account admin
    docker exec -it kanidmd kanidmd recover-account idm_admin

    Each command prints a generated password once. Store both in your password manager immediately.

    Install the client tools on your workstation, then log in:

    shell
    kanidm login --name idm_admin
    kanidm self whoami --name idm_admin

    The client reads ~/.config/kanidm or /etc/kanidm/config:

    shell
    uri = "https://idm.example.com"

    8. Create your first people and groups

    shell
    kanidm person create alice "Alice Nguyen" --name idm_admin
    kanidm person update alice --legalname "Alice Nguyen" --mail alice@example.com --name idm_admin
    
    kanidm group create sysadmins --name idm_admin
    kanidm group add-members sysadmins alice --name idm_admin

    Do not set passwords for people from the admin CLI. Issue a self-service reset token and send it to them instead:

    shell
    kanidm person credential create-reset-token alice --name idm_admin

    The output includes a URL and a short code. The user opens it, enrolls a passkey or sets a password plus TOTP, and you never handle their credential. Tokens are single use and expire.

    Delegate group administration so you are not the bottleneck:

    shell
    kanidm group create helpdesk --name idm_admin
    kanidm group add-members idm_people_admins helpdesk --name idm_admin

    9. Wire up an OAuth2 application

    This is the main reason to run Kanidm. Any app that speaks OIDC can hand off login. Grafana as the example:

    shell
    kanidm system oauth2 create grafana "Grafana" https://grafana.example.com --name idm_admin
    kanidm system oauth2 add-redirect-url grafana https://grafana.example.com/login/generic_oauth --name idm_admin
    
    kanidm group create grafana_users --name idm_admin
    kanidm group add-members grafana_users alice --name idm_admin
    
    kanidm system oauth2 update-scope-map grafana grafana_users openid profile email --name idm_admin
    kanidm system oauth2 show-basic-secret grafana --name idm_admin

    Point the application at the discovery endpoint:

    shell
    https://idm.example.com/oauth2/openid/grafana/.well-known/openid-configuration

    Map application roles with claim maps rather than hardcoding usernames in the app:

    shell
    kanidm group create grafana_admins --name idm_admin
    kanidm system oauth2 update-scope-map grafana grafana_admins openid profile email --name idm_admin
    kanidm system oauth2 update-claim-map grafana grafana_role grafana_admins Admin --name idm_admin
    kanidm system oauth2 update-claim-map-join grafana grafana_role array --name idm_admin

    For applications that only support public clients (native or SPA), disable the basic secret and enable PKCE-only flow with kanidm system oauth2 enable-public grafana.


    10. Enroll a Linux client for SSH and PAM

    On any other RamNode instance you want to authenticate against Kanidm, install the Unix tools (kanidm-unixd), then configure /etc/kanidm/config:

    shell
    uri = "https://idm.example.com"

    And /etc/kanidm/unixd:

    shell
    pam_allowed_login_groups = ["sysadmins"]
    default_shell = "/bin/bash"
    home_prefix = "/home/"
    home_attr = "name"
    uid_attr_map = "name"
    gid_attr_map = "name"

    Create a POSIX identity for the account and the group:

    shell
    kanidm person posix set alice --shell /bin/bash --name idm_admin
    kanidm group posix set sysadmins --name idm_admin
    kanidm person posix set-password alice --name idm_admin

    Start the daemons and test resolution before touching PAM:

    shell
    systemctl enable --now kanidm-unixd kanidm-unixd-tasks
    kanidm-unix status
    getent passwd alice
    kanidm-unix auth-test alice

    Only after auth-test succeeds, add the Kanidm modules to your PAM stack and kanidm to the passwd and group lines in /etc/nsswitch.conf. Keep a second root SSH session open while you do this. Locking yourself out of a VPS is recoverable through the RamNode VNC console, but the console is a slow place to debug PAM.

    For SSH keys, publish them from Kanidm:

    shell
    kanidm person ssh add-publickey alice laptop "ecdsa-sha2-nistp521 AAAA..." --name idm_admin

    Then on the client, set AuthorizedKeysCommand /usr/sbin/kanidm_ssh_authorizedkeys and AuthorizedKeysCommandUser nobody in sshd_config.


    11. LDAPS for legacy applications

    Kanidm exposes a read-only LDAPS interface for software that cannot speak OIDC. There is no plaintext 389 listener and no StartTLS, by design.

    shell
    ldapsearch -H ldaps://idm.example.com:636 \
      -x -D 'dn=token' -w "$(kanidm service-account api-token generate ...)" \
      -b 'dc=idm,dc=example,dc=com' '(name=alice)'

    Create a service account with a read-only API token for each application rather than binding as a person. Remember that writes are not supported over LDAP; anything that wants to change a password through LDAP will fail. That is the intended behavior.


    12. Backups

    The [online_backup] block already writes a nightly consistent snapshot into /srv/kanidm/data/backups. Ship those off the VPS:

    shell
    cat > /etc/cron.daily/kanidm-offsite <<'EOF'
    #!/bin/bash
    rsync -az --delete /srv/kanidm/data/backups/ backup@backup.example.com:/srv/backups/kanidm/
    EOF
    chmod +x /etc/cron.daily/kanidm-offsite

    Also snapshot /srv/kanidm/data/server.toml, since a database without its matching domain and origin is not a working restore.

    To restore:

    shell
    docker compose down
    docker run --rm -v /srv/kanidm/data:/data docker.io/kanidm/server:1.11.0 \
      kanidmd database restore /data/backups/kanidm.backup.json
    docker compose up -d

    Take a RamNode snapshot of the whole instance before every upgrade. It is the fastest rollback available.


    13. Upgrades

    Kanidm supports upgrading exactly one minor version at a time. Going from 1.9 to 1.11 directly will refuse to start and you will be restoring from backup.

    shell
    docker compose down
    # edit docker-compose.yml, bump 1.11.0 to 1.12.0
    docker compose pull
    docker compose up -d
    docker logs -f kanidmd

    The server performs schema migrations on first boot of the new version. Watch the log until it reports ready. Client tools and sync connectors must match the server minor version, so upgrade kanidm on your workstation at the same time.


    14. Hardening checklist

    • Move SSH to key-only auth and disable root password login before Kanidm goes live.
    • Set a domain display name and enforce your credential policy: kanidm group account-policy webauthn-attestation-ca-list, credential-type-minimum, and auth-session-expiry are all set per group, so create a staff policy group and a stricter admins one.
    • Require passkeys for privileged groups: kanidm group account-policy credential-type-minimum admins passkey --name idm_admin.
    • Keep admin for break-glass only. Day-to-day work runs as idm_admin or a delegated group.
    • Log shipping: docker logs is fine for one host, but pipe to journald or a remote collector so authentication failures are visible.
    • Review kanidm system oauth2 list quarterly and delete integrations you retired.

    15. Troubleshooting

    Container exits immediately with a TLS error. The certificate files are unreadable by UID 1000, or chain.pem is missing intermediates. Use fullchain.pem, not cert.pem.

    Passkeys stop working after a hostname change. Expected. WebAuthn binds credentials to the origin. Users must re-enroll.

    Browser reports an origin mismatch. origin in server.toml must match the URL in the address bar exactly, scheme and port included.

    LDAPS bind fails with invalid credentials. People cannot bind with their normal password unless you have configured an LDAP-specific credential. Use a service account API token.

    Login works but getent passwd returns nothing on the client. The account has no POSIX attributes. Run kanidm person posix set.

    Recovery when you lose both admin passwords. Stop the container, run kanidmd recover-account admin against the data volume, and you are back in. There is no remote path to this, which is why the RamNode console access matters.


    Where to go next

    Add a second instance and configure replication once you depend on this for production logins. Kanidm's replication is designed for read-write pairs across sites, which pairs well with running a second VPS in a different RamNode location. Until then, keep the nightly backup shipping off-box and take a snapshot before every change to the domain configuration.