A while back I wrote about how I set up Authentik forward auth with Traefik on NixOS. It worked, but over time Authentik got a bit heavy for what I actually needed from it, i.e. one identity provider and a forward auth in front of a few self-hosted apps. So I swapped it out for Pocket ID (a small OIDC provider) and TinyAuth (a tiny forward auth that talks to it).
This post is the sequel to that one, showing how I did the migration on NixOS. The services themselves are NixOS modules, and the OIDC clients and their secrets are declared in nix too but applied with OpenTofu via Terranix, so the whole thing is declarative end to end.
Why move off Authentik
Authentik does a lot, outposts, flows, property mappings, RBAC, the works. I was only really using a sliver of it, log in once, forward auth in front of the arr stack and a few other apps that don’t have their own auth. The embedded outpost plus forward auth setup from the last post worked but it was always a bit fiddly, and Authentik itself was eating a fair chunk of RAM on my VPS for something I was barely using.
Pocket ID is just OIDC, nothing else. TinyAuth is just forward auth. Two small binaries instead of one big app, and a much smaller surface for me to keep in my head.
Pocket ID
Pocket ID is the OIDC provider, so this is the thing my apps and TinyAuth actually log in against. On NixOS I run it with the pocket-id NixOS module, backed by the local Postgres. The config looks something like this:
{
sops.secrets = {
pocketid_encryption_key = {
owner = config.services.pocket-id.user;
inherit (config.services.pocket-id) group;
mode = "0400";
};
pocketid_static_api_key = {
owner = config.services.pocket-id.user;
inherit (config.services.pocket-id) group;
mode = "0400";
};
};
services.postgresql = {
ensureDatabases = [ "pocketid" ];
ensureUsers = [
{
name = "pocketid";
ensureDBOwnership = true;
}
];
};
services.pocket-id = {
enable = true;
user = "pocketid";
group = "pocketid";
settings = {
APP_URL = "https://id.haseebmajid.dev";
TRUST_PROXY = true;
HOST = "127.0.0.1";
PORT = 1411;
DB_CONNECTION_STRING = "postgres://pocketid@/pocketid?host=/run/postgresql";
UI_CONFIG_DISABLED = true;
ALLOW_USER_SIGNUPS = "withToken";
ANALYTICS_DISABLED = true;
VERSION_CHECK_DISABLED = true;
};
credentials = {
ENCRYPTION_KEY = config.sops.secrets.pocketid_encryption_key.path;
STATIC_API_KEY = config.sops.secrets.pocketid_static_api_key.path;
};
};
services.traefik.dynamicConfigOptions.http = lib.nixicle.mkTraefikService {
name = "pocket-id";
port = 1411;
subdomain = "id";
domain = "haseebmajid.dev";
};
}
A few things worth pointing out. The ENCRYPTION_KEY and STATIC_API_KEY come from sops,
not from the module’s env file, so they live in my secrets repo and not in the nix store.
The DB connection uses the Unix socket (host=/run/postgresql) so Pocket ID doesn’t need a
password, it just authenticates as the pocketid postgres role over the socket. And
UI_CONFIG_DISABLED = true means I can’t change settings from the web UI, everything has to
go through the nix config, which is what I want.
mkTraefikService is a little helper I wrote that spits out the router, service and tls
block for a given subdomain, so I don’t have to repeat that boilerplate per app.
TinyAuth
TinyAuth is the forward auth, same job the Authentik embedded outpost was doing before. It sits in front of Traefik and redirects you to Pocket ID to log in, then checks each request after that. I run it as a plain systemd service:
let
authPort = 3000;
domain = "haseebmajid.dev";
in
{
systemd.services.tinyauth = {
description = "TinyAuth forward-auth (pocket-id OIDC)";
wantedBy = [ "multi-user.target" ];
after = [ "network-online.target" ];
wants = [ "network-online.target" ];
environment = {
TINYAUTH_APPURL = "https://auth.${domain}";
TINYAUTH_SERVER_PORT = toString authPort;
TINYAUTH_DATABASE_PATH = "/var/lib/tinyauth/tinyauth.db";
TINYAUTH_OAUTH_PROVIDERS_pocketid_AUTHURL = "https://id.${domain}/authorize";
TINYAUTH_OAUTH_PROVIDERS_pocketid_TOKENURL = "https://id.${domain}/api/oidc/token";
TINYAUTH_OAUTH_PROVIDERS_pocketid_USERINFOURL = "https://id.${domain}/api/oidc/userinfo";
TINYAUTH_OAUTH_PROVIDERS_pocketid_REDIRECTURL = "https://auth.${domain}/api/oauth/callback/pocketid";
TINYAUTH_OAUTH_PROVIDERS_pocketid_SCOPES = "openid profile email groups";
TINYAUTH_OAUTH_PROVIDERS_pocketid_NAME = "Pocket ID";
TINYAUTH_OAUTH_AUTOREDIRECT = "pocketid";
};
serviceConfig = {
ExecStart = lib.getExe pkgs.tinyauth;
EnvironmentFile = config.sops.secrets.tinyauth_env.path;
User = "tinyauth";
Group = "tinyauth";
StateDirectory = "tinyauth";
Restart = "on-failure";
};
};
}
The tinyauth_env sops secret holds the OAuth client id and secret for the TinyAuth client
I made in Pocket ID (more on how that gets there in a bit).
Then the Traefik side is just a forwardAuth middleware plus a router so the callback URL is reachable:
{
services.traefik.dynamicConfigOptions.http = {
middlewares.tinyauth.forwardAuth = {
address = "http://127.0.0.1:${toString authPort}/api/auth/traefik";
authResponseHeaders = [
"Remote-User"
"Remote-Name"
"Remote-Email"
"Remote-Groups"
];
};
routers.tinyauth = {
entryPoints = [ "websecure" ];
rule = "Host(`auth.${domain}`)";
service = "tinyauth";
tls.certResolver = "letsencrypt";
};
services.tinyauth.loadBalancer.servers = [
{ url = "http://127.0.0.1:${toString authPort}"; }
];
};
}
This is the bit that replaces the authentik middleware from the old post. The address is
TinyAuth’s /api/auth/traefik endpoint instead of the Authentik outpost on 9443, and the
headers it sends back are Remote-User and friends instead of the X-authentik-* ones.
Same idea, less moving parts.
Putting an app behind it
To protect an app I just add the tinyauth middleware to that app’s Traefik router, same as
I used to add authentik:
{
services.traefik.dynamicConfigOptions.http.routers.sonarr = {
entryPoints = [ "websecure" ];
rule = "Host(`sonarr.bare.homelab.haseebmajid.dev`)";
service = "sonarr";
tls.certResolver = "letsencrypt";
middlewares = [ "tinyauth" ];
};
}
Hit the app unauthenticated and TinyAuth bounces you to Pocket ID to log in, then back. That’s the whole flow.
Provisioning the OIDC clients with Terranix
This is the bit I’m most happy with. With Authentik I used to click around the admin UI to make a new client for each app. With Pocket ID I declare the clients in nix and apply them with OpenTofu (via Terranix), so they’re version controlled and reproducible.
The clients live in infra/terranix/modules/pocketid-apps.nix:
let
apps = {
tinyauth = {
name = "TinyAuth";
client_id = "tinyauth";
launch_url = "https://auth.haseebmajid.dev";
callback_urls = [
"https://auth.haseebmajid.dev/api/oauth/callback/pocketid"
];
is_public = false;
pkce_enabled = true;
};
lettucego = {
name = "LettuceGo";
client_id = "lettucego";
launch_url = "https://lettucego.haseebmajid.dev";
callback_urls = [
"https://lettucego.haseebmajid.dev/callback"
];
is_public = false;
pkce_enabled = true;
};
# ...gothreads, grafana, tandoor, papra, etc.
};
mkClient = key: cfg: {
resource.pocketid_client.${key} = {
inherit (cfg)
name
client_id
callback_urls
is_public
pkce_enabled
launch_url
;
};
output."${key}_client_id" = {
value = "\${pocketid_client.${key}.id}";
};
output."${key}_client_secret" = {
value = "\${pocketid_client.${key}.client_secret}";
sensitive = true;
};
};
in
{
imports = builtins.attrValues (builtins.mapAttrs mkClient apps);
}
Each entry becomes a pocketid_client Terraform resource, so tofu apply creates the
client in Pocket ID and prints the id and secret as Terraform outputs.
The clever bit is the next file. Rather than copying those secrets into each app’s sops file by hand, I push them straight into OpenBao (a Vault fork) KV from the same Terraform run:
let
apps = [
"goroutinely"
"gothreads"
"lettucego"
"sure"
"tinyauth"
"grafana"
];
mkSecret = app: {
resource.vault_kv_secret_v2."${app}_oidc" = {
mount = "\${vault_mount.kv.path}";
name = "apps/${app}/oidc";
data_json = builtins.toJSON {
client_id = "\${pocketid_client.${app}.id}";
client_secret = "\${pocketid_client.${app}.client_secret}";
};
};
};
in
{
resource.vault_mount.kv = {
path = "kv";
type = "kv";
options.version = "2";
};
imports = map mkSecret apps;
}
So one tofu apply makes the client in Pocket ID and writes its client_id and
client_secret into OpenBao at kv/apps/<app>/oidc. The apps then pull from OpenBao at
runtime, no secrets sitting in git, no manual copy paste. Add a new app, append one entry to
both lists, apply, done.
From nix to OpenTofu
So there are two declarative layers doing different jobs. Pocket ID and TinyAuth are
NixOS services, so they’re declared in nix and applied with nixos-rebuild, same as the
rest of the box. The OIDC clients and their secrets can’t really be a NixOS module, they
live behind Pocket ID’s and OpenBao’s APIs, so I declare those in nix too but as Terranix,
which compiles down to terraform JSON, and then OpenTofu applies them.
The whole Terranix config is just one file that imports three modules:
# infra/terranix/prod.nix
{
imports = [
./modules/terraform.nix
./modules/pocketid-apps.nix
./modules/openbao-secrets.nix
];
}
pocketid-apps.nix and openbao-secrets.nix are the ones from the last section. The
third one, terraform.nix, is the boring bit that sets up the providers and variables:
{
terraform = {
required_version = ">= 1.0";
backend.local = { };
required_providers = {
pocketid = {
source = "trozz/pocketid";
version = "~> 0.1";
};
vault = {
source = "hashicorp/vault";
version = "~> 4.0";
};
};
};
provider.pocketid = {
base_url = "\${var.pocketid_base_url}";
api_token = "\${var.pocketid_api_token}";
};
provider.vault = {
address = "\${var.openbao_address}";
token = "\${var.openbao_token}";
skip_child_token = true;
};
variable.pocketid_base_url.type = "string";
variable.pocketid_api_token = {
type = "string";
sensitive = true;
};
variable.openbao_address = {
type = "string";
default = "https://openbao.homelab.haseebmajid.dev";
};
variable.openbao_token = {
type = "string";
sensitive = true;
};
}
The pocketid provider (trozz/pocketid) talks to Pocket ID’s admin API, the vault
provider talks to OpenBao. Both tokens come in as terraform variables, i.e. env vars at
apply time, so they’re not in the nix config.
To actually run it I have a go-task task that turns the nix into terraform JSON and then
hands it to OpenTofu:
# tasks/tofu.yml
tasks:
generate:
cmds:
- mkdir -p infra/terranix/generated/prod
- terranix infra/terranix/prod.nix > infra/terranix/generated/prod/config.tf.json
apply:
deps: [generate]
dir: infra/terranix/generated/prod
cmds:
- tofu init
- tofu apply
So task tofu:apply runs terranix prod.nix to spit out a config.tf.json, then tofu init && tofu apply creates the clients in Pocket ID and pushes the secrets into OpenBao.
No terraform apply with hand-written HCL, the HCL is generated from nix, so the clients
and the NixOS services stay in sync and in the same repo.
Both opentofu and terranix are just in the flake’s devShell, so anyone cloning the repo
gets them for free, no separate terraform install to manage:
# flake devShell
nativeBuildInputs = [
# ...sops, age, git, etc.
opentofu
terranix
go-task
];
What I lost and what I gained
The main thing I gave up is Authentik’s flows, i.e. the ability to build custom login /
recovery / enrolment flows in the UI. I never actually used those, so I don’t miss them.
Pocket ID also doesn’t do groups-based access control to the same degree, but TinyAuth’s
Remote-Groups header lets me do the simple version in Traefik if I ever need it.
What I gained is a much smaller footprint on the VPS, no more clicking in an admin UI to register clients, and the whole identity setup (provider, forward auth, client provisioning, secret distribution) is now declared in nix and applied with OpenTofu. That alone was worth the swap for me.