Sso Identity

SSO & Identity Management

Centralized authentication and authorization for all web services using Kanidm and OAuth2 Proxy.

Overview

This repository uses Kanidm as the core identity provider. It supports modern authentication standards like WebAuthn (YubiKey), OIDC, and LDAP. To protect web services that don’t natively support OIDC, we use the Forward Auth pattern with OAuth2 Proxy.

Key Components

  1. Kanidm (modules/services/auth/kanidm.nix): The “Source of Truth” for users, groups, and credentials. Runs on lab-matrix.
  2. OAuth2 Proxy (modules/services/auth/oauth2-proxy.nix): A gateway that handles the OIDC login flow with Kanidm. Uses a Unix domain socket (/run/oauth2-proxy/oauth2-proxy.sock) for secure, high-performance communication with Nginx.
  3. Nginx (modules/services/web/nginx.nix): Uses auth_request to intercept requests and verify authentication with OAuth2 Proxy before allowing access to backend services. The kanidmAuth snippet is a reusable text block injected into service vhost locations.

OAuth2-Proxy Unix Socket

OAuth2-Proxy communicates with Nginx via a Unix domain socket (not TCP). Benefits:

  • Security: No network stack exposure — only processes with filesystem access to /run/oauth2-proxy/ can reach the socket.
  • Performance: Bypasses TCP overhead. Combined with nginx upstream zone and keepalive directives for connection pooling.
  • Reliability: Unix socket upstreams don’t fail at nginx startup (socket file existence is only checked at request time).

The upstream is defined in modules/services/web/nginx.nix:

upstreams.oauth2-proxy = {
    servers."unix:/run/oauth2-proxy/oauth2-proxy.sock" = {};
    extraConfig = ''
        zone oauth2-proxy 64k;
        keepalive 2;
    '';
};

Deployment: Dual L1/L2 OAuth2-Proxy

Both the L1 gateway (vps-pacman) and L2 gateway (lab-matrix) run their own OAuth2-Proxy instances:

LayerPurposeWhy
L1 (vps-pacman)Public SSO edgeHandles SSO for services accessed through the public internet. Cookie scoped to .alienzj.org for cross-subdomain sharing.
L2 (lab-matrix)Local SSOHandles SSO for services accessed directly via Tailscale IPs or MagicDNS. Also needed because the L1 proxy forwards traffic without re-checking auth — L2’s own nginx applies kanidmAuth locally.

Both instances share the same Kanidm backend (on lab-matrix) and OIDC client. The shared cookie domain (.alienzj.org) means a login through either gateway is valid for the entire domain.

Where does it run?

  • Kanidm: Runs on lab-matrix. Exposed publicly via vps-pacman proxy for the OIDC login flow. Restart policy: RestartSec = "60" (retries every minute instead of hitting systemd start-limit).
  • OAuth2 Proxy: Runs on BOTH vps-pacman (L1) and lab-matrix (L2). Each is configured identically — same OIDC provider, same cookie domain, same Kanidm backend.
  • Environment: Kanidm sets KANIDM_TRUST_X_FORWARD_FOR = "true" for correct client IP logging behind the Nginx reverse proxy chain.

Traffic Path (Public Access — Path A)

  1. User Request: https://git.alienzj.org → Cloudflare DNS → vps-pacman (L1).
  2. Forward: L1 nginx proxies to lab-matrix (L2) over Tailscale MagicDNS.
  3. Intercept: lab-matrix nginx asks local OAuth2 Proxy (via Unix socket): “Is this user logged in?”
  4. Redirect: If no, user is sent to https://id.alienzj.org (Kanidm on lab-matrix, reachable through vps-pacman) to login with password + YubiKey.
  5. Authorize: After login, Kanidm redirects to https://auth.alienzj.org/oauth2/callback (OAuth2-Proxy on vps-pacman), which sets a secure cookie for .alienzj.org.
  6. Access: User is redirected back to git.alienzj.org — nginx sees the auth cookie, auth_request passes, service loads.

Traffic Path (Direct Tailscale Access — Path B)

  1. User Request: https://git.alienzj.org (or http://lab-matrix with Host header) → lab-matrix (L2) directly via Tailscale MagicDNS or IP.
  2. Intercept: lab-matrix nginx asks local OAuth2 Proxy for SSO verification (same as step 3 above).
  3. Redirect/Access: Same SSO flow — Kanidm is on the same host, OAuth2-Proxy is on the same host. The .alienzj.org cookie set during any prior login (via Path A or B) is recognized.

No dependency on vps-pacman: Path B works entirely within the Tailscale mesh. If vps-pacman is down, all services remain accessible from Tailscale-connected devices with SSO intact.

SSL Termination: Where and Why

SSL is terminated only at L1 (vps-pacman). lab-matrix runs all services with nginx.ssl = false (plain HTTP). This is safe because:

LinkTransportEncryption
Browser → vps-pacmanPublic internetHTTPS (Let’s Encrypt *.alienzj.org wildcard)
vps-pacman → lab-matrixTailscale meshWireGuard (ChaCha20-Poly1305)
nginx → oauth2-proxyUnix socket (/run/oauth2-proxy/)Filesystem permissions only
oauth2-proxy → KanidmLoopback TCP (127.0.0.1:8443)No network exposure
Kanidm internalSelf-signed cert (/etc/kanidm/)Loopback only

The L1→L2 link is plain HTTP, but it travels inside a WireGuard tunnel. An attacker on the public internet can only see the HTTPS stream to vps-pacman. An attacker who compromises a Tailscale peer key would have access to the entire mesh regardless of TLS.

Key consequence for Path B (direct Tailscale): oauth2-proxy sets cookie.secure = true, so browsers only send the SSO cookie over HTTPS. When accessing lab-matrix directly via http://lab-matrix (Path B), the browser won’t send the secure cookie — the user must re-authenticate. This is an acceptable trade-off: Path B is the fallback for when vps-pacman is down, and the Tailscale link is already encrypted.

ACME: Only L1 Runs Certificates

Only vps-pacman runs ACME. lab-matrix must NOT run ACME, even if it has a Cloudflare API token, because:

  1. Wildcard conflict: vps-pacman already issues and renews the *.alienzj.org wildcard cert. Having two hosts race to renew the same certificate would trigger Let’s Encrypt rate limits and cause renewal conflicts.
  2. No consumers: lab-matrix sets nginx.ssl = false for every service, so useACMEWildcardHost = false — any certificate issued on lab-matrix would be unused.
  3. No public exposure: lab-matrix has openFirewall = false. Even though DNS-01 ACME works without inbound HTTP (it uses the Cloudflare API), the issued certificate has no purpose on this host.

If you ever need HTTPS on the Tailscale-direct path, use a separate internal domain (e.g. *.lab-matrix.ts.alienzj.org) with self-signed certs or an internal CA — not the public *.alienzj.org wildcard.

ACME enablement is a host-level decision, not a service-level side effect. vps-pacman explicitly sets modules.services.web.acme.enable = true in its host config. No service module force-enables ACME — hosts opt in.


Usage: Protecting a Service

To protect any web service with Kanidm SSO, set oauth2.enable = true on the service’s nginx vhost. OAuth2 protection is configured via the per-vhost oauth2 submodule added by modules/services/auth/oauth2-proxy.nix:

services.nginx.virtualHosts."<domain>".oauth2.enable = true;
# Optionally restrict to specific Kanidm groups:
# services.nginx.virtualHosts."<domain>".oauth2.allowedGroups = [ "access_git" ];

Service modules expose this via a oauth2.enable option. In your host config:

modules.services.web.glance = {
  enable = true;
  oauth2.enable = true;
};

The vhost submodule automatically generates auth_request /oauth2/auth, auth_request_set headers ($user, $email, $auth_cookie), and internal auth/redirect locations — no manual kanidmAuth text concatenation needed.

Supported Services

Currently, the following services have integrated SSO support:

  • Affine (Knowledge Base)
  • Atuin (Shell History)
  • Calibre (E-Book Library)
  • Discourse (Forum)
  • Forgejo (Git)
  • FreshRSS (RSS Reader)
  • Gotify (Push Notifications)
  • Grocy (ERP)
  • HedgeDoc (Markdown Editor)
  • Immich (Photo Backup)
  • Linkwarden (Bookmarks)
  • LiteLLM (LLM API Proxy)
  • Mealie (Recipe Manager)
  • OnlyOffice (Document Editor)
  • Paperless-ngx (Document Management)
  • Stalwart (Mail Server)
  • Stirling-PDF (PDF Tools)
  • Vaultwarden (Password Manager)

ACME Certificate Architecture

All service vhosts share a single wildcard DNS-01 certificate (*.alienzj.org) rather than requesting individual HTTP-01 certs. Only L1 (vps-pacman) runs ACME — see SSL Termination and ACME: Only L1 Runs Certificates above.

How it works in code:

  • modules/services/web/acme.nix extends services.nginx.virtualHosts with the useACMEWildcardHost option. When set to true, the vhost is forced to useACMEHost = baseDomain (the wildcard cert) and acmeRoot = null (prevents DNS-01/HTTP-01 conflict on nixpkgs 25.11+).
  • lib/nginx.nix (genNginxVhostBase) maps ssl → useACMEWildcardHost, so useACMEWildcardHost = ssl for every vhost.
  • genNginxVhostBase maps ssl → useACMEWildcardHost, so a vhost with ssl = true automatically uses the wildcard cert. On lab-matrix, ssl = false for all services, so useACMEWildcardHost = false — no unused cert references.

Why DNS-01 instead of HTTP-01:

  • HTTP-01 (enableACME = true on a vhost) requires the domain to resolve to the requesting host. On lab-matrix, auth.alienzj.org / id.alienzj.org resolve to vps-pacman’s public IP — HTTP-01 challenges fail with 404.
  • DNS-01 (useACMEWildcardHost = true) uses Cloudflare API to create DNS TXT records, working from vps-pacman regardless of where the domain resolves.
  • Wildcard certs require DNS-01 — Let’s Encrypt will not issue *.alienzj.org via HTTP-01.

The wildcard cert definition in security.acme.certs."<baseDomain>" uses dnsProvider = "cloudflare" with webroot = null and extraDomainNames = [baseDomain] to cover both *.alienzj.org and the apex alienzj.org.

useACMEWildcardHost vs enableACME:

OptionSet byChallengeScopeWhere used
useACMEWildcardHostgenNginxVhostBase (automatic)DNS-01Per-vhost, points to shared wildcardAll service modules
enableACME(deprecated in this repo)HTTP-01Per-vhost, individual certNowhere — removed

Bootstrap: OAuth2 Client Setup

The oauth2-proxy-env.age secret contains three values that oauth2-proxy uses to connect to Kanidm as an OIDC provider. This must be created during initial homelab bootstrap and updated when credentials rotate.

Prerequisite: Recover idm_admin Account

Kanidm creates a built-in admin called idm_admin (not admin). Only idm_admin has idm_admins group membership — required to manage OAuth2 clients. The admin account either doesn’t exist or has no privileges.

# On lab-matrix, recover the privileged admin account
sudo kanidmd scripting recover-account -c /etc/kanidm/server.toml idm_admin

OAuth2-proxy requires a cookie encryption key of exactly 16, 24, or 32 bytes. A 32-byte key (AES-256) is recommended:

python3 -c 'import os,base64; print(base64.urlsafe_b64encode(os.urandom(32)).decode())'

Critical: The decoded byte length must be 16, 24, or 32. Placeholder strings like <your-generated-cookie-secret> are 30 bytes and cause oauth2-proxy to crash-loop with: cookie_secret must be 16, 24, or 32 bytes to create an AES cipher, but is 30 bytes.

Step 2: Login to Kanidm (Localhost Bypass)

During bootstrap, oauth2-proxy is not yet running, so the public URL path (through vps-pacman) returns 502. Connect directly to Kanidm on localhost:

kanidm login --name idm_admin --url https://127.0.0.1:8443 --accept-invalid-certs

--accept-invalid-certs is needed because Kanidm uses a self-signed cert internally. These flags only affect where the CLI connects — zero impact on Kanidm’s public configuration.

Step 3: OIDC Client Setup (Declarative or Manual)

You can choose to configure the OIDC client (oauth2_proxy), groups (access_*), and person account shell (alienzj) either declaratively via Nix (Option A) or manually via the CLI (Option B).

The OIDC client, groups, and user shell are configured in your dotfiles via services.kanidm.provision in modules/services/auth/kanidm.nix:

        systems.oauth2 = {
          oauth2_proxy = {
            displayName = "OAuth2 Proxy";
            originLanding = "https://auth.alienzj.org/oauth2/callback";
            originUrl = "https://auth.alienzj.org/oauth2/callback";
            basicSecretFile = config.age.secrets.kanidm-oauth2-proxy-secret.path;
            preferShortUsername = true;
            scopeMaps = {
              idm_all_persons = ["email" "groups" "openid" "profile"];
            };
          };
        };

This method automatically creates and reconciles OIDC clients, groups, and user shells on every deploy.

Option B: Manual CLI Setup (Fallback)

If you prefer to configure Kanidm manually, run the following CLI commands:

# Create the client
kanidm system oauth2 create oauth2_proxy 'OAuth2 Proxy' \
  https://auth.alienzj.org/ \
  --url https://127.0.0.1:8443 --accept-invalid-certs

# Add the redirect URL
kanidm system oauth2 add-redirect-url oauth2_proxy \
  https://auth.alienzj.org/oauth2/callback \
  --url https://127.0.0.1:8443 --accept-invalid-certs

# Map required OIDC scopes (openid is mandatory for OIDC to work)
kanidm system oauth2 update-scope-map oauth2_proxy \
  idm_all_persons openid email profile groups \
  --url https://127.0.0.1:8443 --accept-invalid-certs

# Get the client secret
kanidm system oauth2 show-basic-secret oauth2_proxy \
  --url https://127.0.0.1:8443 --accept-invalid-certs

# Prefer short username format
kanidm system oauth2 prefer-short-username oauth2_proxy \
  --url https://127.0.0.1:8443 --accept-invalid-certs

The Client ID is always oauth2_proxy (the name used in the create command). The Client Secret is the output of show-basic-secret.

Step 4: Create the Encrypted Secret File

On your development machine:

agenix -e secrets/oauth2-proxy-env.age

Paste:

OAUTH2_PROXY_CLIENT_ID=oauth2_proxy
OAUTH2_PROXY_CLIENT_SECRET=<output-from-show-basic-secret>
OAUTH2_PROXY_COOKIE_SECRET=<generated-32-byte-base64>

This .age file must be encrypted for both hosts — vps-pacman and lab-matrix each run their own oauth2-proxy instance.

Step 5: Create a Regular Person Account

Critical: idm_admin is a system administration account — it manages OAuth2 clients and groups, but it is NOT a “person” account. It has no membership in idm_all_persons. The scope map (idm_all_persons → openid email profile groups) grants it zero scopes, so logging into any service as idm_admin will always show “Access Denied” with Kanidm logging available_scopes: {}.

You MUST create a separate regular person account for day-to-day SSO login:

# Create the account
kanidm person create <username> '<Display Name>' \
  --url https://127.0.0.1:8443 --accept-invalid-certs

# Set an email address (MANDATORY — OAuth2-Proxy requires the email claim)
kanidm person update <username> --mail '<email>' \
  --url https://127.0.0.1:8443 --accept-invalid-certs

# Generate a password reset token
kanidm person credential create-reset-token <username> \
  --url https://127.0.0.1:8443 --accept-invalid-certs

Open the reset link (replace 127.0.0.1:8443 with id.alienzj.org), set a password, then log in via OAuth2 using this regular account — not idm_admin. See homelab-bootstrap.md for the full workflow.

Step 6: Verify After Deploy

After rebuilding both hosts, verify oauth2-proxy starts cleanly:

ssh vps_pacman_root "systemctl status oauth2-proxy --no-pager"
ssh lab_matrix_root "systemctl status oauth2-proxy --no-pager"
# Both should show: active (running), NOT "activating auto-restart"

Service Access Audit: Guest, Public, and Internal Auth

All homelab services sit behind the Kanidm SSO gate — anyone accessing https://<service>.alienzj.org must authenticate through OAuth2-Proxy first. “Guest” or “public” access in the table below means “any Kanidm-authenticated user,” not “anyone on the internet.”

Access Model by Service

ServiceInternal AuthNew User CreationNotes
HedgeDocGuest edit/view by default (allowAnonymous = true)Automatic (SSO identity)Any SSO user can view/edit all notes. Set allowAnonymous = false to require HedgeDoc login.
MealieSelf-registration (ALLOW_SIGNUP = "true")Any SSO user can sign upFirst SSO user to visit can create admin account. Set to "false" after initial setup.
ForgejoSSO auto-create on first loginAutomatic (SSO identity)Every SSO user gets a Forgejo account. Registration disabled.
VaultwardenInvite-only (SIGNUPS_ALLOWED = false)Admin sends invitationSafest model. No open registration, admin controls accounts.
ImmichAdmin creates accountsManual (admin UI)Admin must create each user account.
AtuinRegistration closed (openRegistration = false)Manual (admin CLI)Admin controls who can sync shell history.
LinkwardenRegistration disabled (NEXT_PUBLIC_DISABLE_REGISTRATION=true)None (no new users)Users are pre-configured or created via admin.
PaperlessInternal user systemAdmin creates accountsPer-user permissions for document access.
GrocyPHP app with own credentialsManualSeparate username/password after SSO.
FreshRSSPHP app with own credentialsManualSeparate username/password after SSO.
OnlyOfficeNo internal authN/AOpen to any SSO user. Document server only — no persistent data.
Stirling-PDFNo internal authN/AOpen to any SSO user. PDF manipulation tool — no persistent user data.
CalibreNo internal authN/AOpen to any SSO user. Read-only library browsing.
LiteLLMui_master_key auto-loginN/AAny SSO user gets admin UI access. The proxy key controls API access.
GotifyNo internal auth after SSON/AAny SSO user can see received notifications and push new ones.
StalwartSeparate admin login (admin via agenix fallback)Manual (webadmin)Email server admin is separate from SSO identity.
KanidmThe identity provider itselfManual (admin CLI)Only idm_admin can create accounts. Not self-service.
AffineInternal auth (behind SSO)ManualKnowledge base with own user model.

Risk Assessment by Household Size

ScenarioRiskRecommendation
Solo operator (current)Minimal — only you have a Kanidm accountNo changes needed
Family (2-5 trusted users)Low — trusted users can see each other’s HedgeDoc notes, Mealie recipes, notificationsOptional: lock down HedgeDoc, Mealie
Friends/extended (5+ users)Medium — shared guest access becomes a privacy concernSet allowAnonymous = false on HedgeDoc, ALLOW_SIGNUP = "false" on Mealie, add service-specific accounts
Public/internet-facingN/A — SSO gate prevents thisAll services require Kanidm account. No anonymous internet access exists.

Kanidm: No Self-Service Registration

Kanidm has no registration page — there is no “Sign Up” button in the web UI. Person accounts can only be created by an admin (member of idm_admins group) via the CLI:

kanidm person create <username> '<Display Name>'

This means random internet users cannot create accounts. Only accounts you explicitly create can pass the SSO gate. The current idm_admin account is the sole account with idm_admins membership.

For recovery: idm_admin’s password can be reset via root shell on lab-matrix (sudo kanidmd scripting recover-account -c /etc/kanidm/server.toml idm_admin).

Per-Service Group-Based Access Control

By default, every Kanidm person account can access all SSO-protected services. For a solo operator this is fine. Before adding family or collaborators, restrict sensitive services to specific groups.

Implementation (Phase 3)

Step 1: Create groups in Kanidm

# SSH to lab-matrix, login as idm_admin
ssh matrix_root
kanidm login --name idm_admin --url https://127.0.0.1:8443 --accept-invalid-certs

# Create access groups
kanidm group create access_vaultwarden
kanidm group create access_immich
kanidm group create access_forgejo
kanidm group create access_paperless
kanidm group create access_stalwart

# Add yourself to all groups
for g in access_vaultwarden access_immich access_forgejo access_paperless access_stalwart; do
  kanidm group add-members $g alienzj
done

Step 2: Wire groups to services in lab-matrix config

# hosts/lab-matrix/modules/modules.nix
services.auth.vaultwarden.oauth2.allowedGroups = [ "access_vaultwarden" ];
services.media.immich.oauth2.allowedGroups = [ "access_immich" ];
services.git.forgejo.oauth2.allowedGroups = [ "access_forgejo" ];
services.docs.paperless.oauth2.allowedGroups = [ "access_paperless" ];
services.net.stalwart.oauth2.allowedGroups = [ "access_stalwart" ];

Step 3: Deploy — OAuth2-Proxy now rejects non-members at nginx level.

Graduated Trust Model

User GroupKanidm MembershipsCan access
Admin (you)idm_admins + all access_* groupsEverything + Kanidm management
Familyidm_all_persons + access_immich + access_mealie + access_hedgedocPhotos, recipes, notes
Collaboratoridm_all_persons + access_forgejoCode only
Guestidm_all_persons onlyPublic-ok: Stirling-PDF, OnlyOffice, Calibre, Gotify

Bypass for debugging (Tailscale Path B)

Accessing services directly via Tailscale IP (http://lab-matrix:<port>) bypasses both nginx and OAuth2-Proxy. This is intentional — it provides an emergency access path if SSO is down. Restrict this path with firewall rules if the host is multi-tenant.

Group naming convention

Use access_<service> for service-specific groups. OAuth2-Proxy passes the group name in the query string: GET /oauth2/auth?allowed_groups=access_immich. Kanidm verifies group membership. Keep group names lowercase with underscores.

Key Principle

The SSO gate is the single access control point. As long as you control who has Kanidm accounts, all services are protected. Services without internal auth (Stirling-PDF, Calibre, OnlyOffice, Gotify) are intentionally open behind SSO — they’re utility tools where per-user isolation adds friction with no security benefit for a solo operator.

If you ever open Kanidm to external users (friends, collaborators), audit this table and lock down services that shouldn’t be shared.


Secrets & User Account Management

Every service module follows a consistent pattern for secrets and credentials, modeled after the reference implementations: stalwart.nix, immich.nix, forgejo.nix.

Critical: Format Matters

ConsumerExpectsWrong format =
ensures[].passwordFileRaw string (just the password)KEY=VALUE ends up as the literal password
environmentFile (systemd)KEY=VALUE\n pairsRaw password silently ignored, env var never set
services.<name>.environmentFileKEY=VALUE\n pairsSame as above
credentialsFile / secretsFileDepends on the appCheck the upstream docs

Rule: passwordFile = raw. environmentFile = KEY=VALUE. They are NOT interchangeable.

Secret Catalog — Every File, Its Format, and the Command to Create It

PostgreSQL Passwords (raw — no KEY= prefix)

Generated once per service. Used by ensures[].passwordFile for ALTER USER.

echo -n 'random-password' | agenix -e stalwart-postgresql-password.age
echo -n 'random-password' | agenix -e immich-postgresql-password.age
echo -n 'random-password' | agenix -e vaultwarden-postgresql-password.age
echo -n 'random-password' | agenix -e forgejo-database-secret.age
echo -n 'random-password' | agenix -e linkwarden-postgresql-password.age
echo -n 'random-password' | agenix -e hedgedoc-postgresql-password.age
echo -n 'random-password' | agenix -e paperless-postgresql-password.age
echo -n 'random-password' | agenix -e affine-postgresql-password.age
echo -n 'random-password' | agenix -e mealie-postgresql-password.age
echo -n 'random-password' | agenix -e freshrss-postgresql-password.age
echo -n 'random-password' | agenix -e atuin-postgresql-password.age

All PostgreSQL services connect via localhost trust auth, so the password is not required at runtime. The ensures postStart script applies it as a fallback.

SMTP Passwords (KEY=VALUE format — consumed via environmentFile)

All services send email through your self-hosted Stalwart at mail.alienzj.org. Each service authenticates with its own username (e.g., [email protected]) but can share the same SMTP password, or use separate ones.

# Vaultwarden — env var SMTP_PASSWORD
echo 'SMTP_PASSWORD=xxx' | agenix -e vaultwarden-smtp-pass.age

# Paperless — env var PAPERLESS_EMAIL_HOST_PASSWORD
echo 'PAPERLESS_EMAIL_HOST_PASSWORD=xxx' | agenix -e paperless-smtp-pass.age

# Linkwarden — env var EMAIL_SERVER_PASSWORD
echo 'EMAIL_SERVER_PASSWORD=xxx' | agenix -e linkwarden-smtp-pass.age

# HedgeDoc — env var CMD_SMTP_PASS
echo 'CMD_SMTP_PASS=xxx' | agenix -e hedgedoc-smtp-pass.age

Why SMTP? Services send email for: password resets (Forgejo, Vaultwarden), invitations (Vaultwarden), notifications (Paperless, Mealie), and verification (Immich). Without SMTP configured, these features silently fail. All email flows through Stalwart on lab-matrix — nothing leaves your infrastructure.

One password or many? Using the same SMTP password for all services is simpler to manage. Using separate passwords per service limits the blast radius if one is compromised. For a solo homelab, shared is fine — Stalwart is the only mail server and all services are on the same host.

Service Credentials

# FreshRSS default user password (raw)
echo -n 'your-password' | agenix -e freshrss-password.age

# Vaultwarden admin token (raw — any random string)
echo -n 'your-admin-token' | agenix -e vaultwarden-admin-token.age

# Paperless initial admin (multi-line KEY=VALUE)
cat > /tmp/paperless-admin.txt << 'SECRET'
PAPERLESS_ADMIN_USER=alienzj
PAPERLESS_ADMIN_PASSWORD=your-secure-password
[email protected]
SECRET
agenix -e paperless-admin.age < /tmp/paperless-admin.txt

Gotify / API Tokens (KEY=VALUE format)

echo 'GOTIFY_TOKEN=<token-from-gotify-ui>' | agenix -e watchdog-gotify-token.age
echo 'GOTIFY_TOKEN=<token-from-gotify-ui>' | agenix -e gatus-gotify-token.age

Forgejo SMTP (raw — consumed as file paths, not environmentFile)

echo -n '[email protected]'       | agenix -e forgejo-smtp-user.age
echo -n 'smtp-password'         | agenix -e forgejo-smtp-pass.age

Immich SMTP (raw — consumed via secretsFile)

echo -n 'smtp-password' | agenix -e immich-smtp-pass.age

OAuth2-Proxy Environment (multi-line KEY=VALUE)

# Created during Kanidm bootstrap — see docs/homelab-bootstrap.md Phase 1.3
cat > /tmp/oauth2-proxy-env.txt << 'SECRET'
OAUTH2_PROXY_CLIENT_ID=oauth2_proxy
OAUTH2_PROXY_CLIENT_SECRET=<from-kanidm-show-basic-secret>
OAUTH2_PROXY_COOKIE_SECRET=<generated-32-byte-base64>
SECRET
agenix -e oauth2-proxy-env.age < /tmp/oauth2-proxy-env.txt

Database Password Pattern

All PostgreSQL-backed services follow the same three-part pattern:

# 1. Declare the age secret
age.secrets.<service>-postgresql-password = {
  mode = "400";
  owner = "postgres";
};

# 2. Use in ensures block
modules.services.database.postgresql.ensures = [{
  username = "<service>";
  database = "<service>";
  passwordFile = config.age.secrets.<service>-postgresql-password.path;
}];

# 3. Service connects via localhost trust auth
#    The password from ensures is a fallback for ALTER USER,
#    not required at runtime with trust auth.

Services connect to PostgreSQL via Unix socket (/run/postgresql) or localhost:5432 with trust authentication. The passwordFile in ensures runs ALTER USER <service> WITH PASSWORD via the postStart script — so the password exists but is not required for local connections.

First-Time User Setup by Service

Every service needs an initial admin account before signup can be disabled. Here’s the step-by-step for each.

With Age Secrets (Key/Value env format)

echo 'GOTIFY_TOKEN=<watchdog-token>' | agenix -e watchdog-gotify-token.age
echo 'GOTIFY_TOKEN=<gatus-token>'    | agenix -e gatus-gotify-token.age

Paperless — Django env vars:

cat > /tmp/paperless-admin.txt << 'SECRET'
PAPERLESS_ADMIN_USER=alienzj
PAPERLESS_ADMIN_PASSWORD=your-secure-password
[email protected]
SECRET
agenix -e paperless-admin.age < /tmp/paperless-admin.txt

FreshRSS — raw password:

echo -n 'your-password' | agenix -e freshrss-password.age
# Username: alienzj (from config.user.name)

Vaultwarden — raw token:

echo -n 'your-secret-admin-token' | agenix -e vaultwarden-admin-token.age
# Go to https://bw.alienzj.org/admin, enter token, create account

Without Age Secrets (SSO or web UI)

ServiceFirst login method
Stalwartadmin via agenix stalwart-admin-hash.age — bootstrap only, create real account after
Gotifydumbledore (set in gotifyEnvironmentFiles.age)
Kanidmidm_admin via kanidmd scripting recover-account on lab-matrix
ForgejoFirst SSO-authenticated user gets account automatically
HedgeDocFirst SSO-authenticated user gets account automatically
ImmichFirst user to sign up via web UI becomes admin
GrocyCreate account via web UI on first access
MealieTemporarily set allowSignup = true, create admin, set back
LinkwardenTemporarily set DISABLE_REGISTRATION=false, create admin, set back
Atuinatuin register -u alienzj -e [email protected]
Stirling-PDFNo user accounts
CalibreNo user accounts
OnlyOfficeNo user accounts
LiteLLMui_master_key=litellm-admin-key, no user accounts
AffineComplex multi-tenant — create workspace via web UI

Services Without Internal Login

These services are intentionally open behind SSO — they have no user accounts of their own:

  • OnlyOffice: Document rendering engine only. No persistent data per user.
  • Stirling-PDF: PDF manipulation tool. No user data stored.
  • Calibre: Read-only e-book library browser.
  • LiteLLM: API proxy with ui_master_key auto-login.
  • Gotify (after initial setup): Notifications, no per-user isolation needed.

Mobile App Access

Native mobile/desktop apps connect via REST APIs, not browser sessions. The OAuth2-Proxy auth_request gate returns a 307 redirect to Kanidm, which native apps can’t follow. Two solutions coexist:

Option A: API Path Exemption (current)

Exempt the service’s API paths from SSO auth_request. The service’s own authentication protects these endpoints. The web UI remains behind SSO.

Browser:  https://bw.alienzj.org/        → SSO gate → web vault
Mobile:   https://bw.alienzj.org/api/    → auth_request off → Bitwarden auth (master password + 2FA)

API paths exempted per service:

ServiceAPI PathsApp’s Own Auth
Vaultwarden/identity/, /api/, /notifications/, /sends/, /admin/Master password + 2FA
Immich/api/, /.well-known/Email + password (or OAuth2)
Jitsi MeetWebSocket upgrade paths, /colibri/Meeting room tokens — disabled
FreshRSS/api/greader.php, /api/fever.phpApp-specific password
Gotify/message, /messages, /api-docs, /api/Client token per app
Atuin/, /sync/, /history, /user, /meSSO via browser login
Anki Sync/sync/, /msync/Username + password
Nix Cache/nix-cache-info, /nar/, *.narinfoNone — disabled 2026-06-14

These paths are protected by the service’s own authentication. SSO is the browser gate; the service’s auth is the API gate.

Implementation: Each exemption is an nginx location block with auth_request off;, added via genNginxVhost’s locations parameter:

locations = {
  "/sync/" = {
    proxyPass = "http://127.0.0.1:27701";
    extraConfig = "auth_request off;";
  };
};

The auth_request directive is set at the server level by OAuth2-Proxy. Overriding it to off in a location block exempts that path. The service’s own authentication (or lack thereof, for read-only caches) takes over from that point.

Option B: Native OIDC (future)

Configure the service to use Kanidm as its OIDC provider directly. The app opens a browser for login, Kanidm returns a token via URL scheme callback, and the app uses that token for API calls.

Mobile:   App → opens browser → Kanidm login → OIDC redirect → immich://callback → app has token
Browser:  Unchanged — still flows through OAuth2-Proxy + Kanidm

Why they coexist: Browser and mobile use different paths. OAuth2-Proxy protects / (web UI), the service’s OIDC client protects /api/ (mobile). No conflict.

Services that support native OIDC:

ServiceOIDC SupportMobile App
ImmichOAuth2/OIDC (via OAuth settings)Android, iOS
VaultwardenOIDC (via SSO_CLIENT_* env vars)All Bitwarden apps
ForgejoOAuth2 (built-in)Git clients, mobile browsers
KanidmN/A (it IS the provider)N/A

Native OIDC is worth the effort for multi-user setups. For solo use, Option A (API exemption + service auth) is sufficient.

Services with Mobile/Desktop Apps

ServiceAppPlatformAuth methodSSO exempt?
VaultwardenBitwardenAndroid, iOS, Desktop, Browser extMaster pass + 2FA/api/ exempt
ImmichImmichAndroid, iOSEmail + password/api/ exempt
GotifyGotifyAndroidClient token/message exempt
AtuinCLIAll platformsSSO via browser loginN/A (CLI handles OAuth2)
ForgejoGit CLI, mobile webAll platformsSSH key or tokenN/A (web SSO + git SSH)
FreshRSSReadably, FeedMeAndroid, iOSApp-specific password/api/ not yet exempt
PaperlessPaperless MobileAndroidAPI tokenNot yet exempt
JellyfinJellyfin, FinampAll platformsUsername + passwordOn QNAP, not SSO-protected
CalibreCalibre SyncAndroidRead-onlyNo auth needed
GrocyGrocy AndroidAndroidAPI keyNot yet exempt
MealieMealientAndroid, iOSAPI tokenNot yet exempt
LinkwardenPWA onlyAny browserSSON/A (PWA)
Anki SyncAnki, AnkiDroidDesktop, Android, iOSUsername + password/sync/ exempt
OnlyOfficeOnlyOfficeAndroid, iOSNone (connects to Nextcloud, etc.)N/A
Stirling-PDFNone
HedgeDocNone
AffineNone
LiteLLMAPI clientsAPI key
KanidmNone

The pattern is consistent: if a service has mobile apps, its API paths need SSO exemption because native apps can’t follow HTML redirects. The service’s own authentication then protects those paths.

Environment Files vs Direct Secrets

ApproachWhen to useExample
environmentFile (single file)Service expects KEY=VALUE env varsgatus, stalwart
credentialsFile (single file)Service expects a raw token/passwordmealie (SMTP)
secretsFile (single file)Service reads secrets from a dedicated fileimmich (SMTP)
password."file" (inline)Embedded in config settingsstalwart (postgres)
environmentFiles (multiple)Multiple env files mergedpaperless (SMTP on 3 units)

Rule: Always use age secrets — never put passwords, tokens, or API keys directly in .nix files. The dotfiles repo is public; secrets live in the private nix-secrets flake input encrypted with agenix per host.


Frequently Asked Questions

Can services work without SSO?

Yes. If kanidm.enable = false (the default), the service will use its own internal login system. You can always reach your services directly via Tailscale IPs if the SSO system is down.

Why use SSO?

  1. Security: Enforce Hardware MFA (YubiKey) across every single application.
  2. Convenience: Login once, access everything.
  3. Centralization: Disable one user in Kanidm, and they lose access to every service instantly.

Why use --url https://127.0.0.1:8443 during bootstrap?

The kanidm CLI defaults to id.alienzj.org → Cloudflare DNS → vps-pacman → nginx → oauth2-proxy. During initial setup, oauth2-proxy is crash-looping (cookie_secret is still a placeholder), so this path returns 502. Connecting directly to localhost bypasses the entire proxy chain. Once oauth2-proxy is configured and running, the public URL path works normally.