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
- Kanidm (
modules/services/auth/kanidm.nix): The “Source of Truth” for users, groups, and credentials. Runs on lab-matrix. - 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. - Nginx (
modules/services/web/nginx.nix): Usesauth_requestto intercept requests and verify authentication with OAuth2 Proxy before allowing access to backend services. ThekanidmAuthsnippet 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
zoneandkeepalivedirectives 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:
| Layer | Purpose | Why |
|---|---|---|
| L1 (vps-pacman) | Public SSO edge | Handles SSO for services accessed through the public internet. Cookie scoped to .alienzj.org for cross-subdomain sharing. |
| L2 (lab-matrix) | Local SSO | Handles 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)
- User Request:
https://git.alienzj.org→ Cloudflare DNS → vps-pacman (L1). - Forward: L1 nginx proxies to lab-matrix (L2) over Tailscale MagicDNS.
- Intercept: lab-matrix nginx asks local OAuth2 Proxy (via Unix socket): “Is this user logged in?”
- Redirect: If no, user is sent to
https://id.alienzj.org(Kanidm on lab-matrix, reachable through vps-pacman) to login with password + YubiKey. - 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. - Access: User is redirected back to
git.alienzj.org— nginx sees the auth cookie,auth_requestpasses, service loads.
Traffic Path (Direct Tailscale Access — Path B)
- User Request:
https://git.alienzj.org(orhttp://lab-matrixwith Host header) → lab-matrix (L2) directly via Tailscale MagicDNS or IP. - Intercept: lab-matrix nginx asks local OAuth2 Proxy for SSO verification (same as step 3 above).
- Redirect/Access: Same SSO flow — Kanidm is on the same host, OAuth2-Proxy is on the same host. The
.alienzj.orgcookie 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:
| Link | Transport | Encryption |
|---|---|---|
| Browser → vps-pacman | Public internet | HTTPS (Let’s Encrypt *.alienzj.org wildcard) |
| vps-pacman → lab-matrix | Tailscale mesh | WireGuard (ChaCha20-Poly1305) |
| nginx → oauth2-proxy | Unix socket (/run/oauth2-proxy/) | Filesystem permissions only |
| oauth2-proxy → Kanidm | Loopback TCP (127.0.0.1:8443) | No network exposure |
| Kanidm internal | Self-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:
- Wildcard conflict: vps-pacman already issues and renews the
*.alienzj.orgwildcard cert. Having two hosts race to renew the same certificate would trigger Let’s Encrypt rate limits and cause renewal conflicts. - No consumers: lab-matrix sets
nginx.ssl = falsefor every service, souseACMEWildcardHost = false— any certificate issued on lab-matrix would be unused. - 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.nixextendsservices.nginx.virtualHostswith theuseACMEWildcardHostoption. When set totrue, the vhost is forced touseACMEHost = baseDomain(the wildcard cert) andacmeRoot = null(prevents DNS-01/HTTP-01 conflict on nixpkgs 25.11+).lib/nginx.nix(genNginxVhostBase) mapsssl → useACMEWildcardHost, souseACMEWildcardHost = sslfor every vhost.genNginxVhostBasemapsssl → useACMEWildcardHost, so a vhost withssl = trueautomatically uses the wildcard cert. On lab-matrix,ssl = falsefor all services, souseACMEWildcardHost = false— no unused cert references.
Why DNS-01 instead of HTTP-01:
- HTTP-01 (
enableACME = trueon a vhost) requires the domain to resolve to the requesting host. On lab-matrix,auth.alienzj.org/id.alienzj.orgresolve 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.orgvia 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:
| Option | Set by | Challenge | Scope | Where used |
|---|---|---|---|---|
useACMEWildcardHost | genNginxVhostBase (automatic) | DNS-01 | Per-vhost, points to shared wildcard | All service modules |
enableACME | (deprecated in this repo) | HTTP-01 | Per-vhost, individual cert | Nowhere — 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
Step 1: Generate a Cookie Secret
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).
Option A: Declarative Provisioning (Recommended)
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
| Service | Internal Auth | New User Creation | Notes |
|---|---|---|---|
| HedgeDoc | Guest edit/view by default (allowAnonymous = true) | Automatic (SSO identity) | Any SSO user can view/edit all notes. Set allowAnonymous = false to require HedgeDoc login. |
| Mealie | Self-registration (ALLOW_SIGNUP = "true") | Any SSO user can sign up | First SSO user to visit can create admin account. Set to "false" after initial setup. |
| Forgejo | SSO auto-create on first login | Automatic (SSO identity) | Every SSO user gets a Forgejo account. Registration disabled. |
| Vaultwarden | Invite-only (SIGNUPS_ALLOWED = false) | Admin sends invitation | Safest model. No open registration, admin controls accounts. |
| Immich | Admin creates accounts | Manual (admin UI) | Admin must create each user account. |
| Atuin | Registration closed (openRegistration = false) | Manual (admin CLI) | Admin controls who can sync shell history. |
| Linkwarden | Registration disabled (NEXT_PUBLIC_DISABLE_REGISTRATION=true) | None (no new users) | Users are pre-configured or created via admin. |
| Paperless | Internal user system | Admin creates accounts | Per-user permissions for document access. |
| Grocy | PHP app with own credentials | Manual | Separate username/password after SSO. |
| FreshRSS | PHP app with own credentials | Manual | Separate username/password after SSO. |
| OnlyOffice | No internal auth | N/A | Open to any SSO user. Document server only — no persistent data. |
| Stirling-PDF | No internal auth | N/A | Open to any SSO user. PDF manipulation tool — no persistent user data. |
| Calibre | No internal auth | N/A | Open to any SSO user. Read-only library browsing. |
| LiteLLM | ui_master_key auto-login | N/A | Any SSO user gets admin UI access. The proxy key controls API access. |
| Gotify | No internal auth after SSO | N/A | Any SSO user can see received notifications and push new ones. |
| Stalwart | Separate admin login (admin via agenix fallback) | Manual (webadmin) | Email server admin is separate from SSO identity. |
| Kanidm | The identity provider itself | Manual (admin CLI) | Only idm_admin can create accounts. Not self-service. |
| Affine | Internal auth (behind SSO) | Manual | Knowledge base with own user model. |
Risk Assessment by Household Size
| Scenario | Risk | Recommendation |
|---|---|---|
| Solo operator (current) | Minimal — only you have a Kanidm account | No changes needed |
| Family (2-5 trusted users) | Low — trusted users can see each other’s HedgeDoc notes, Mealie recipes, notifications | Optional: lock down HedgeDoc, Mealie |
| Friends/extended (5+ users) | Medium — shared guest access becomes a privacy concern | Set allowAnonymous = false on HedgeDoc, ALLOW_SIGNUP = "false" on Mealie, add service-specific accounts |
| Public/internet-facing | N/A — SSO gate prevents this | All 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 Group | Kanidm Memberships | Can access |
|---|---|---|
| Admin (you) | idm_admins + all access_* groups | Everything + Kanidm management |
| Family | idm_all_persons + access_immich + access_mealie + access_hedgedoc | Photos, recipes, notes |
| Collaborator | idm_all_persons + access_forgejo | Code only |
| Guest | idm_all_persons only | Public-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
| Consumer | Expects | Wrong format = |
|---|---|---|
ensures[].passwordFile | Raw string (just the password) | KEY=VALUE ends up as the literal password |
environmentFile (systemd) | KEY=VALUE\n pairs | Raw password silently ignored, env var never set |
services.<name>.environmentFile | KEY=VALUE\n pairs | Same as above |
credentialsFile / secretsFile | Depends on the app | Check 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)
| Service | First login method |
|---|---|
| Stalwart | admin via agenix stalwart-admin-hash.age — bootstrap only, create real account after |
| Gotify | dumbledore (set in gotifyEnvironmentFiles.age) |
| Kanidm | idm_admin via kanidmd scripting recover-account on lab-matrix |
| Forgejo | First SSO-authenticated user gets account automatically |
| HedgeDoc | First SSO-authenticated user gets account automatically |
| Immich | First user to sign up via web UI becomes admin |
| Grocy | Create account via web UI on first access |
| Mealie | Temporarily set allowSignup = true, create admin, set back |
| Linkwarden | Temporarily set DISABLE_REGISTRATION=false, create admin, set back |
| Atuin | atuin register -u alienzj -e [email protected] |
| Stirling-PDF | No user accounts |
| Calibre | No user accounts |
| OnlyOffice | No user accounts |
| LiteLLM | ui_master_key=litellm-admin-key, no user accounts |
| Affine | Complex 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_keyauto-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:
| Service | API Paths | App’s Own Auth |
|---|---|---|
| Vaultwarden | /identity/, /api/, /notifications/, /sends/, /admin/ | Master password + 2FA |
| Immich | /api/, /.well-known/ | Email + password (or OAuth2) |
WebSocket upgrade paths, /colibri/ | Meeting room tokens — disabled | |
| FreshRSS | /api/greader.php, /api/fever.php | App-specific password |
| Gotify | /message, /messages, /api-docs, /api/ | Client token per app |
| Atuin | /, /sync/, /history, /user, /me | SSO via browser login |
| Anki Sync | /sync/, /msync/ | Username + password |
/nix-cache-info, /nar/, *.narinfo | None — 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:
| Service | OIDC Support | Mobile App |
|---|---|---|
| Immich | OAuth2/OIDC (via OAuth settings) | Android, iOS |
| Vaultwarden | OIDC (via SSO_CLIENT_* env vars) | All Bitwarden apps |
| Forgejo | OAuth2 (built-in) | Git clients, mobile browsers |
| Kanidm | N/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
| Service | App | Platform | Auth method | SSO exempt? |
|---|---|---|---|---|
| Vaultwarden | Bitwarden | Android, iOS, Desktop, Browser ext | Master pass + 2FA | /api/ exempt |
| Immich | Immich | Android, iOS | Email + password | /api/ exempt |
| Gotify | Gotify | Android | Client token | /message exempt |
| Atuin | CLI | All platforms | SSO via browser login | N/A (CLI handles OAuth2) |
| Forgejo | Git CLI, mobile web | All platforms | SSH key or token | N/A (web SSO + git SSH) |
| FreshRSS | Readably, FeedMe | Android, iOS | App-specific password | /api/ not yet exempt |
| Paperless | Paperless Mobile | Android | API token | Not yet exempt |
| Jellyfin | Jellyfin, Finamp | All platforms | Username + password | On QNAP, not SSO-protected |
| Calibre | Calibre Sync | Android | Read-only | No auth needed |
| Grocy | Grocy Android | Android | API key | Not yet exempt |
| Mealie | Mealient | Android, iOS | API token | Not yet exempt |
| Linkwarden | PWA only | Any browser | SSO | N/A (PWA) |
| Anki Sync | Anki, AnkiDroid | Desktop, Android, iOS | Username + password | /sync/ exempt |
| OnlyOffice | OnlyOffice | Android, iOS | None (connects to Nextcloud, etc.) | N/A |
| Stirling-PDF | None | — | — | — |
| HedgeDoc | None | — | — | — |
| Affine | None | — | — | — |
| LiteLLM | API clients | — | API key | — |
| Kanidm | None | — | — | — |
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
| Approach | When to use | Example |
|---|---|---|
environmentFile (single file) | Service expects KEY=VALUE env vars | gatus, stalwart |
credentialsFile (single file) | Service expects a raw token/password | mealie (SMTP) |
secretsFile (single file) | Service reads secrets from a dedicated file | immich (SMTP) |
password."file" (inline) | Embedded in config settings | stalwart (postgres) |
environmentFiles (multiple) | Multiple env files merged | paperless (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?
- Security: Enforce Hardware MFA (YubiKey) across every single application.
- Convenience: Login once, access everything.
- 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.