SSO Across the Stack: Building a Unified Lab Identity Layer
The previous post in this series covered deploying a self-hosted development stack — Redmine for issue tracking, a wiki, a Git forge, and a team chat platform. What it didn’t cover was authentication: each service had its own user database, its own login page, and its own password to manage.
That’s not a lab. That’s a collection of services that happen to run on the same machine.
The follow-up work was building a proper identity layer: Keycloak as the OIDC provider, every service authenticated through it, users managed in one place. This post covers what that process actually looked like — including the parts that didn’t work.
Why Keycloak
The short answer is that Keycloak is what you’d run in production. Auth0, Okta, and Entra ID are all excellent — and all subscription-based. For a lab environment where the point is to learn the tools before you need them professionally, running the enterprise-grade open-source equivalent makes more sense than paying for managed identity or wiring up something half-functional.
Keycloak handles:
- OIDC and SAML (the two protocols you’ll encounter in enterprise environments)
- Realm isolation — multiple tenants on one instance
- Fine-grained client scopes and protocol mappers
- MFA, brute force protection, and session management
- A user federation layer for LDAP and Active Directory integration
The mosburn realm on the lab Keycloak instance is the single source of truth for lab identity. One user account. One password. Works across every service in the stack.
The stack
The lab runs as a Docker Compose stack with an nginx reverse proxy routing traffic by subdomain:
| Service | URL | Role |
|---|---|---|
| Keycloak | keycloak.mosburn.lab | Identity provider |
| Redmine | redmine.mosburn.lab | Issue and project tracking |
| Wiki.js | wiki.mosburn.lab | Documentation and wiki |
| Forgejo | forgejo.mosburn.lab | Git forge |
Each service runs with its own PostgreSQL database. The nginx config proxies *.mosburn.lab subdomains to the relevant containers, and each container has extra_hosts pointing keycloak.mosburn.lab to the host gateway — so backchannel token exchange calls route correctly through nginx rather than failing to resolve the hostname inside the Docker network.
That last part took longer than it should have. OIDC has two distinct communication patterns that get confused in a containerised setup: the browser-facing redirects (which need the public hostname) and the server-to-server token exchange (which needs a hostname the container can actually resolve). Getting both working simultaneously requires understanding which path each call takes.
What the “free tier” actually means for SSO
The first lesson was expensive in time: many self-hosted applications have quietly moved SSO behind a paywall.
Mattermost was the original choice for team chat. It’s widely deployed, has a good mobile app, and seemed like the obvious pick. The OpenID Connect option in System Console has a prominent upsell wall requiring Professional. The GitLab OAuth option — historically free — also now requires Professional. Mattermost as a free self-hosted platform no longer supports SSO in any meaningful sense.
Docmost was the original wiki choice. Clean interface, good editor, PostgreSQL backend. Version 0.80.2 ships with OIDC code visible in the dist bundle under ee/sso — enterprise edition. The OIDC_ENABLED environment variable that appears in older documentation does nothing in current releases.
Docmost was replaced with Wiki.js, which has free OIDC via a Generic OpenID Connect strategy. Mattermost was not replaced with another self-hosted chat platform — and the reasoning is worth spelling out, because it applies more broadly.
The pattern here is worth noting for anyone else building a self-hosted stack: always verify SSO support against the current version before building your deployment around a service. Documentation tends to lag behind licensing changes by months.
Alerts need to reach you
A self-hosted chat platform sounds good in theory. In practice, the primary value of a chat system in a lab context isn’t discussion — it’s automation output. Build failures, deployment events, monitoring alerts, cron job completions. The things that interrupt you, or should.
Self-hosted chat doesn’t deliver those notifications to your phone when you’re out riding. A pipeline that fails overnight doesn’t show up until you’re back at a desk. A monitoring alert at 2am goes nowhere. The Redmine plugin that posts to a webhook might as well be logging to /dev/null if the webhook endpoint isn’t running a client you have in your pocket.
Discord already handles this. Webhooks are first-class, the mobile app is reliable, and the notification model actually works. CI/CD integrations are a few lines of configuration. You get the same “build failed, retry?” message over lunch that you’d see in a professional Slack or Teams setup — without running infrastructure to achieve it.
The lab stack uses Discord for automation output via webhooks. The Redmine redmine_messenger plugin posts issue activity to a dedicated channel. Forgejo CI can post pipeline results the same way. No server to maintain, no database to back up, no container to debug when a MongoDB replica set election decides to happen at an inconvenient time.
Configuring OIDC across heterogeneous services
Each service has a different approach to OIDC configuration, which is instructive.
Redmine uses the devopskube/redmine_openid_connect plugin — a CAS-style patch rather than OmniAuth. Configuration is through the plugin admin UI, not configuration.yml. The discovery document is cached; clearing tmp/cache/ is required when changing realm configuration. There was also a Ruby bug in the plugin where dynamic_config_expiry was passed as a String to Rails.cache.fetch which expects a Numeric — patched in the Dockerfile with a sed one-liner after clone.
Forgejo has native OAuth2 support with an OpenID Connect provider type. Configuration is through the admin UI with the Keycloak discovery URL. The only complication was ensuring the discovery URL used the public hostname without the internal port — Keycloak’s KC_HOSTNAME needs to be set to the full URL (http://keycloak.mosburn.lab) rather than just the hostname, otherwise the issuer includes :8080 and backchannel calls from containers fail.
Wiki.js uses a Generic OpenID Connect strategy (not Generic OAuth2 — they’re different entries and using the wrong one wastes time). The callback URL is UUID-based, not derived from the strategy display name, so you have to check the Configuration Reference section to find the actual redirect URI to register in Keycloak. Setting the Site URL in the admin UI is required to get the correct protocol — Wiki.js will advertise HTTPS if it detects a forwarded-HTTPS header, regardless of what you set in environment variables.
Ansible integration
The Docker Compose stack is the lab testing environment. The production deployments — Redmine, Forgejo, and the others — are managed by the Ansible roles in the mosburn.* namespace and deployed to dedicated VMs via lab.yml.
The compose stack mirrors the production architecture closely enough that configuration validated here translates directly. Client IDs, redirect URIs, and token endpoint URLs carry over. The main difference is the hostname scheme: *.mosburn.lab with subdomains points at the local nginx in the lab, and at real hostnames with TLS in production.
The Keycloak mosburn realm and its client configurations are the portable artifact. When a new service is validated in the lab stack, the same Keycloak client configuration works in production — different URL, same auth flow.
What’s left
The other gap is Vaultwarden — and it’s staying a gap. The technical case is straightforward: 1password ships as a managed package in mosburn.common, Vaultwarden would replace it, and the SSO infrastructure built here would handle authentication with no extra work. The implementation would be an afternoon.
The question is whether it should be on the list at all.
There’s a maintenance cost attached to every self-hosted service. Updates, database backups, the occasional broken container, the plugin that stops working after a version bump. Most of the time that cost is invisible — until it isn’t, and you’re debugging a Redmine cache issue on a Sunday instead of being somewhere else.
I stopped running Gentoo on my daily driver for the same reason. It’s a spectacular operating system and I learned more maintaining it than I did from any formal source. But at some point the ratio of time spent maintaining the environment to time spent doing things in the environment tips the wrong way. A weekend afternoon that could go toward a long ride or an afternoon buried in medieval history is worth more than another self-hosted service I’ll spend three hours debugging when the next major release drops a breaking change.
1Password is paid software. It works. It gets security updates without my involvement. For credentials — which are the one category of data where a self-hosting failure has real consequences — the maintenance-free option is the right call.
The lab exists to learn things that transfer to professional contexts and to run services that genuinely benefit from local control. A password manager that syncs across devices and has a mobile app that works in a car park is not in that category. Neither is a chat platform.
Some things are worth paying for. Identity is infrastructure. Knowing which parts of that infrastructure to outsource is part of the discipline.