Skip to main content

Phase 18 β€” Backstage (Internal Developer Portal)

Backstage is Spotify's open-source developer-portal framework. Its core value is the Software Catalog β€” a unified registry of services, APIs, docs, and ownership metadata β€” plus a plugin system for embedding Kubernetes/ArgoCD/Grafana/etc. dashboards per service.

For a single-operator homelab like this one, Backstage is honestly portfolio-only β€” not operationally needed. Homer + the live docs site already do 90% of what a developer portal would. Same architectural question we asked at every "do we need this?" decision: GitLab in Phase 13, Crossplane in Phase 11, Vault in Phase 15, Backstage here.

The skill of installing, configuring, and reasoning about an IDP is real and recruiter-recognizable. So Phase 18 ships a focused minimal Backstage β€” catalog-only, no plugins, no SSO β€” that demonstrates the architecture without burning a weekend on plugins nobody will use.


Architecture​

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
Browser β”‚ backstage namespace β”‚
β”‚ HTTPS β”‚ β”‚
β”‚ + guest auth β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚
β–Ό β”‚ β”‚ Backstage Pod (1 replica)β”‚ β”‚
cert-manager TLS β”‚ β”‚ off-the-shelf image: β”‚ β”‚
+ NGINX Ingress ──────▢│ β”‚ ghcr.io/backstage/ β”‚ β”‚
β”‚ β”‚ backstage:1.51.2 β”‚ β”‚
β”‚ β”‚ pulled through Harbor's β”‚ β”‚
β”‚ β”‚ ghcr proxy (Phase 16) β”‚ β”‚
β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚
β”‚ β”‚ β”‚
β”‚ β–Ό β”‚
β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚
β”‚ β”‚ PostgreSQL (StatefulSet) β”‚ β”‚
β”‚ β”‚ bitnami/postgresql 18.x β”‚ β”‚
β”‚ β”‚ standalone, Longhorn 1Gi β”‚ β”‚
β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚
β”‚ β”‚
β”‚ Catalog refresh every 100s: β”‚
β”‚ pulls 5 catalog-info.yaml from β”‚
β”‚ raw.githubusercontent.com/... β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

After this phase: https://backstage.10.0.0.200.nip.io shows a Software Catalog with 1 System (minicloud-platform) and 5 Components (one per repo in andrelair-platform).


Decisions​

DecisionChoiceRationale
Install methodHelm chart backstage/backstage v2.7.0Standard install path; bundles Postgres subchart
ImageOff-the-shelf ghcr.io/backstage/backstage:1.51.2 (pulled through Harbor ghcr proxy cache, pinned 2026-06-15 β€” see "Image tag pinning" section)Avoids 3+ hours of Node.js scaffold + Docker build for marginal portfolio gain. Custom build deferred.
DatabasePostgreSQL (bitnami subchart, standalone), 1 GiB on LonghornSQLite is documented as "for development only"; Postgres is the right shape for portfolio even at this scale
AuthenticationGuest auth (no SSO) with dangerouslyAllowOutsideDevelopment: trueGitHub OAuth requires GitHub App + browser setup; SSO via Authentik deployed in Phase 24. Guest gets us to "logged in" instantly.
PluginsCatalog-only (chart's default β€” no Kubernetes/ArgoCD/Grafana plugins)Each plugin requires custom-build with its node module. Deferred.
Catalog source5 catalog-info.yaml files, one per repo, fetched via raw.githubusercontent.com URLsAvoids needing a GitHub integration token (anonymous reads of public raw URLs work)
Hostnamebackstage.10.0.0.200.nip.io via NGINX Ingress + cert-manager TLSSame single-IP host-routing pattern
Resource budgetBackstage 384Mi req / 1 GiB limit; Postgres 128Mi / 512 MiBConservative; cluster has ~30 GiB headroom

What's deferred (with future homes)​

Same scope-reduction pattern as Phase 11 (Crossplane), Phase 13 (GitLab), Phase 15 (Vault), Phase 16 (n8n/Temporal/Airflow).

ComponentReasonFuture home
Custom Backstage image build3+ hours of Node.js scaffold + Docker build for marginal gain over off-the-shelfFuture "Backstage Plugins" phase, when we wire Kubernetes/ArgoCD/Grafana plugins
GitHub OAuth / SSORequires GitHub AppAuthentik OIDC deployed in Phase 24 β€” GitHub App integration deferred
Kubernetes / ArgoCD / Grafana pluginsEach requires the custom-build pipeline; the off-the-shelf image doesn't include themFuture "Backstage Plugins" phase
Software Templates ("Golden Paths")Most valuable feature, needs scaffolder backend + template repos. ~6 hours focused work.Dedicated future phase, after SSO
VaultSame reasons as Phase 15 β€” single-control-plane SPOF, no current workload needs dynamic credsFuture phase pairing with external-DB-credentials need
CrossplanePromised "alongside Phase 18" in Phase 11's deferral; still no compelling external-infra use caseFuture phase when there's a real cloud account or self-service template need

This means Phase 18 ships ~30% of "real Backstage" β€” the catalog. Honest about what's missing and why.


Image tag pinning (2026-06-15)​

The incident​

Six weeks after install, opening Backstage from a browser surfaced this React error overlay:

NotImplementedError
Message: No implementation available for apiRef{plugin.notifications.service}

The catalog still worked β€” dismissing the overlay or hard-refreshing the page got past it β€” but the error reappeared on every fresh load. Root cause: the :latest tag is a rolling reference that points at whatever the Backstage team most recently published from the off-the-shelf example app, and over those six weeks the frontend bundle started including a Notifications-plugin UI component that calls a backend API client that the example app's backend doesn't actually wire up. Spotify's own off-the-shelf image is a demonstration of plugin capability, not a production deployment β€” the frontend/backend can drift out of sync on any new build.

What pinning did and didn't fix​

Edited backstage-values.yaml:

image:
registry: ghcr.io
repository: backstage/backstage
- tag: latest
+ tag: "1.51.2"

Applied with helm upgrade backstage backstage/backstage -n backstage -f backstage-values.yaml. Rollout took ~24 s (336 MB image, pulled through Harbor's ghcr proxy cache).

What the pin DID fix: silent drift on every pod restart. Previously, any random pod reschedule could land on a new :latest image with subtly different behavior. Now the image identity is frozen β€” pod restarts, node reboots, full cluster wipes all converge on the exact same byte-for-byte image.

What the pin DID NOT fix: the NotImplementedError overlay itself. Verification after the pin took effect:

# pod confirmed at 1.51.2:
kubectl get pod -n backstage -o jsonpath='{.items[0].spec.containers[0].image}'
# β†’ ghcr.io/backstage/backstage:1.51.2

# bundle hash served by 1.51.2:
curl -s https://backstage.10.0.0.200.nip.io/ | grep -oE 'module-backstage\.[a-f0-9]+\.js'
# β†’ module-backstage.5c25c313.js

# This is the SAME hash as the original error stack trace, meaning :latest
# at audit time was already 1.51.2 β€” the pin was a no-op in functional terms.

The off-the-shelf image at 1.51.2 still ships the same apiRef{plugin.notifications.service} registration gap. An early version of this section claimed "1.49.0 shipped a no-op fallback" β€” that was wishful inference, not verified upstream behavior. Corrected here for honesty.

Why the off-the-shelf image carries this bug at every version (so far)​

The image is built from packages/app and packages/backend in the Backstage repo β€” the example app the maintainers ship to demonstrate what's possible, not a turnkey production deployment. Over the past ~18 months they added the notifications plugin's UI components to packages/app/src/App.tsx (the bell icon in the header) without registering the matching notificationsApiRef factory in packages/app/src/apis.ts. Frontend imports the plugin β†’ plugin code runs on every page load β†’ tries to resolve its API client β†’ no implementation registered β†’ NotImplementedError overlay.

This is a structural issue with running the upstream example app, not a per-version bug that can be pinned around. Every tag from when the notifications UI was added until someone wires the API factory in packages/app/src/apis.ts exhibits this.

Working with the bug today β€” what actually works​

What does NOT work (and the doc said it would β€” corrected here):

  • "Dismiss the error overlay" β€” the overlay rendering is React's ErrorBoundary catching the failed apiRef lookup, but the lookup re-fires on every route render because the notifications plugin is registered globally in the Header component. There's no dismissable state; the overlay re-appears immediately on any navigation.
  • Hard-refresh, incognito mode, different browsers, direct URL navigation to /catalog β€” none of these bypass the error, because the failing component is in the always-rendered Header tree.

Also tried (2026-06-15) β€” downgrade to 1.25.0 (a tag that likely predates the notifications-plugin-in-frontend era):

sed -i 's|tag: "1.51.2"|tag: "1.25.0"|' backstage-values.yaml
helm upgrade backstage backstage/backstage -n backstage -f backstage-values.yaml
# Result: CrashLoopBackOff on backstage-77745c54d-5przd
# Logs: error in BackendInitializer.start at backend-app-api/dist/index.cjs.js:1486

The backend config schema between 1.25.0 and the chart backstage-2.7.0 is incompatible. The pod can't even reach the point where the frontend would be served. Reverted to 1.51.2 via helm upgrade; old pod kept running throughout (rolling deploy preserved availability). Net result: pinning experiments are exhausted in both directions β€” neither newer nor older off-the-shelf tags resolve the UI issue.

What actually works β€” use the catalog via API, not the UI.

At Phase 18's scope, Backstage is a read-only catalog. The catalog data is served cleanly by the API regardless of UI state. A shell function gives equivalent functionality without the broken UI:

bs_catalog() {
local CA="${BS_CA:-$HOME/minicloud-ca.crt}"
local TOKEN
TOKEN=$(curl -sf --cacert "$CA" -X POST https://backstage.10.0.0.200.nip.io/api/auth/guest/refresh | jq -r '.backstageIdentity.token')
curl -sf --cacert "$CA" -H "Authorization: Bearer $TOKEN" \
"https://backstage.10.0.0.200.nip.io/api/catalog/entities?filter=kind=${1:-component}" \
| jq -r '.[] | "\(.metadata.name) [\(.kind)] (\(.spec.type // "n/a")) β€” owner: \(.spec.owner // "n/a") β€” \(.metadata.description // "no description")"'
}

Usage:

bs_catalog # all Components
bs_catalog system # all Systems
bs_catalog api # all APIs
bs_catalog location # all catalog Locations

The function is stashed durably in minicloud-ansible/scripts/bs-catalog.sh so it survives a controller wipe and is usable from any machine with the CA cert.

The pin to 1.51.2 is kept because the drift-control value is real and orthogonal to the visible UI error. The pinning lesson generalizes (and uncovered 2 more :latest references in Phase 3 + Phase 19). What was wrong was the specific claim that a tag-level fix exists for this bug.

Real fix (Option 3 β€” deferred to a future "Backstage Plugins" phase)​

The proper resolution is to stop running the off-the-shelf example app and ship a custom image:

  1. npx @backstage/create-app minicloud-backstage β€” scaffolds the TypeScript monorepo locally
  2. Edit packages/app/src/apis.ts β€” either remove the notifications-plugin import OR register a no-op notificationsApiRef factory
  3. Add the plugins we actually want (@backstage/plugin-kubernetes, @backstage/plugin-argocd, @backstage/plugin-grafana, @backstage/plugin-techdocs)
  4. yarn build-image β†’ custom Docker image
  5. crane copy to harbor.10.0.0.200.nip.io/library/backstage:<sha>
  6. Point backstage-values.yaml at the custom image, ship via Helm upgrade

Realistic effort: one focused 3-4 hour session. The notifications mismatch gets fixed for free because you control apis.ts. Same session unlocks Kubernetes/ArgoCD/Grafana plugins β€” converting Backstage from a read-only catalog into a true Internal Developer Portal.

Why the pinning section still belongs in this doc​

The visible bug is the trigger that uncovered a broader anti-pattern: :latest on a long-running cluster is a time bomb regardless of whether a specific symptom is visible. The cluster-wide audit one-liner that came out of this work found 2 more :latest references (Phase 3 Homer, Phase 19 Ollama), both of which were pinned the same day for the same drift-control reason. The lesson holds even though the specific Backstage symptom didn't go away.

Lessons for future workloads​

This is a generic anti-pattern, not Backstage-specific. Any off-the-shelf image pulled with :latest is a time bomb on a long-running cluster β€” silently drifts on every pod restart, until it suddenly doesn't work. Audit other workloads for :latest references:

kubectl get pods -A -o jsonpath='{range .items[*]}{.metadata.namespace}/{.metadata.name}: {range .spec.containers[*]}{.image}{"\n"}{end}{end}' | grep ':latest$'

Pin every result to a specific version. Use a renovate-bot or Dependabot equivalent to surface upgrade PRs rather than hoping :latest does the right thing.

Where the pinned values file lives​

The backstage-values.yaml is not in minicloud-gitops (Backstage is Helm-installed, not yet ArgoCD-managed). To survive a controller wipe, the file is mirrored into the minicloud-ansible repo under helm-values/backstage-values.yaml. Migration to ArgoCD-managed Helm is a future phase β€” it requires preserving the live Postgres StatefulSet + 1 GiB PVC through the cut-over, which is non-trivial and deserves its own focused work block.


Pre-flight​

helm repo add backstage https://backstage.github.io/charts
helm repo add bitnami https://charts.bitnami.com/bitnami
helm repo update

# Generate Postgres password (mode 600, never committed)
openssl rand -base64 24 > ~/.backstage-postgres
chmod 600 ~/.backstage-postgres

Out-of-band Postgres Secret​

The bitnami chart's existingSecret mechanism expects a Secret with specific keys. We pre-create it in the chart's expected format:

kubectl create namespace backstage

PG_PW=$(cat ~/.backstage-postgres)
kubectl create secret generic backstage-postgres-secret \
-n backstage \
--from-literal=postgres-password="$PG_PW" \
--from-literal=password="$PG_PW"

backstage-values.yaml​

backstage:
replicas: 1
image:
registry: ghcr.io
repository: backstage/backstage
# Pinned 2026-06-15 (see "Image tag pinning" section below)
tag: "1.51.2"
# ghcr.io is routed through Harbor's ghcr proxy via the Phase 16
# mirror config in /etc/rancher/k3s/registries.yaml β€” no override needed

resources:
requests: { cpu: 100m, memory: 384Mi }
limits: { cpu: 1000m, memory: 1Gi }

containerPorts:
backend: 7007

appConfig:
app:
title: minicloud platform
baseUrl: https://backstage.10.0.0.200.nip.io
organization:
name: andrelair-platform
backend:
baseUrl: https://backstage.10.0.0.200.nip.io
listen: { port: 7007 }
cors:
origin: https://backstage.10.0.0.200.nip.io
methods: [GET, POST, PUT, DELETE]
credentials: true
# URL whitelist β€” without this, raw.githubusercontent.com reads
# are blocked by Backstage's default policy.
reading:
allow:
- host: raw.githubusercontent.com
- host: github.com
database:
client: pg
connection:
host: ${POSTGRES_HOST}
port: ${POSTGRES_PORT}
user: ${POSTGRES_USER}
password: ${POSTGRES_PASSWORD}
auth:
providers:
guest:
# Required when NODE_ENV=production (off-the-shelf image runs
# in production mode); without this, /api/auth/guest/refresh
# returns 403 NotAllowedError. Acceptable here: internal-only
# via Tailscale + cluster network, no sensitive catalog data.
dangerouslyAllowOutsideDevelopment: true
catalog:
rules:
- allow: [Component, System, API, Resource, Location]
locations:
- type: url
target: https://raw.githubusercontent.com/andrelair-platform/minicloud-platform-docs/main/catalog-info.yaml
- type: url
target: https://raw.githubusercontent.com/andrelair-platform/minicloud-ansible/main/catalog-info.yaml
- type: url
target: https://raw.githubusercontent.com/andrelair-platform/minicloud-opentofu/main/catalog-info.yaml
- type: url
target: https://raw.githubusercontent.com/andrelair-platform/minicloud-gitops/main/catalog-info.yaml
- type: url
target: https://raw.githubusercontent.com/andrelair-platform/platform-demo/main/catalog-info.yaml

extraEnvVars:
- name: POSTGRES_HOST
value: backstage-postgresql
- name: POSTGRES_PORT
value: "5432"
- name: POSTGRES_USER
value: bn_backstage
- name: POSTGRES_PASSWORD
valueFrom:
secretKeyRef:
name: backstage-postgres-secret
key: password

ingress:
enabled: false # we add our own with cert-manager TLS

postgresql:
enabled: true
image:
registry: docker.io
repository: bitnamilegacy/postgresql
architecture: standalone
auth:
username: bn_backstage
database: backstage_plugin_catalog
existingSecret: backstage-postgres-secret
secretKeys:
adminPasswordKey: postgres-password
userPasswordKey: password
primary:
persistence:
enabled: true
storageClass: longhorn
size: 1Gi
resources:
requests: { cpu: 50m, memory: 128Mi }
limits: { cpu: 500m, memory: 512Mi }

Install + bootstrap order trap​

helm install backstage backstage/backstage -n backstage \
-f backstage-values.yaml \
--wait --timeout 10m

Likely first failure: Backend startup failed... ECONNREFUSED 5432.

Postgres needs ~5 min to first-boot (init scripts, schema setup, checkpoint). Backstage's startup happens in parallel and tries to connect immediately. Backstage 1.x doesn't retry on startup-time DB connection failures β€” it logs BackendStartupError and stays broken.

Fix: restart the Backstage pod once Postgres is fully Ready.

kubectl rollout restart deployment/backstage -n backstage
kubectl rollout status deployment/backstage -n backstage --timeout=120s

This is a real production gotcha β€” even with helm install --wait, the chart's readiness probes wait for Backstage's HTTP server to be alive, which can succeed before Backstage's database connection is. The --wait then times out at 10min on the readiness probe, but the helm install itself reports STATUS: deployed. Restart the pod and you're fine.


TLS Ingress​

apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: backstage-tls
namespace: backstage
spec:
secretName: backstage-tls
issuerRef:
name: minicloud-ca
kind: ClusterIssuer
dnsNames: [backstage.10.0.0.200.nip.io]
duration: 2160h
renewBefore: 720h
privateKey: { algorithm: ECDSA, size: 256 }
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: backstage
namespace: backstage
annotations:
nginx.org/redirect-to-https: "true"
spec:
ingressClassName: nginx
tls:
- hosts: [backstage.10.0.0.200.nip.io]
secretName: backstage-tls
rules:
- host: backstage.10.0.0.200.nip.io
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: backstage
port: { number: 7007 }

The 5 catalog-info.yaml files​

One per repo, at the repo's root. Components reference the umbrella System (spec.system: minicloud-platform) for tree-view navigation.

minicloud-platform-docs/catalog-info.yaml β€” System + Component​

apiVersion: backstage.io/v1alpha1
kind: System
metadata:
name: minicloud-platform
description: 3-node bare-metal Kubernetes platform on ThinkPads
tags: [homelab, bare-metal, portfolio]
spec:
owner: andrelair-platform
---
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
name: minicloud-platform-docs
description: Docusaurus 3 documentation site
annotations:
backstage.io/source-location: url:https://github.com/andrelair-platform/minicloud-platform-docs
github.com/project-slug: andrelair-platform/minicloud-platform-docs
tags: [documentation, docusaurus]
links:
- { url: https://andrelair-platform.github.io/minicloud-platform-docs/, title: Live docs site }
spec:
type: documentation
lifecycle: production
owner: andrelair-platform
system: minicloud-platform

platform-demo/catalog-info.yaml (most-decorated example)​

apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
name: platform-demo
description: Phase 13 β€” tiny Go HTTP service end-to-end CI/CD demo
annotations:
backstage.io/source-location: url:https://github.com/andrelair-platform/platform-demo
github.com/project-slug: andrelair-platform/platform-demo
argocd/app-name: platform-demo
prometheus.io/rule: http_request_duration_seconds_count{namespace="gitops-demo"}
grafana/dashboard-selector: namespace=gitops-demo
tags: [go, service, demo, phase-13, cicd]
links:
- { url: https://platform-demo.10.0.0.200.nip.io/, title: Live service }
- { url: https://argocd.10.0.0.200.nip.io/applications/platform-demo, title: ArgoCD Application }
spec:
type: service
lifecycle: production
owner: andrelair-platform
system: minicloud-platform

The other three (ansible / opentofu / gitops) follow the same shape with appropriate type (tool), tags, and links.

The argocd/app-name, prometheus.io/rule, grafana/dashboard-selector annotations don't do anything yet β€” but they're the right metadata for when we add the corresponding plugins. Future-proofing.


Verification​

# Pods
kubectl get pods -n backstage
# Expected: backstage-... (1/1 Running) + backstage-postgresql-0 (1/1 Running)

# UI reachable
curl -sIL --cacert ~/minicloud-ca.crt https://backstage.10.0.0.200.nip.io/

# Page title
curl -s --cacert ~/minicloud-ca.crt https://backstage.10.0.0.200.nip.io/ \
| grep -oE "<title>[^<]+</title>"
# <title>minicloud platform</title>

# Catalog query (with guest auth)
TOKEN=$(curl -s --cacert ~/minicloud-ca.crt -X POST \
https://backstage.10.0.0.200.nip.io/api/auth/guest/refresh \
| jq -r '.backstageIdentity.token')

curl -s --cacert ~/minicloud-ca.crt \
-H "Authorization: Bearer $TOKEN" \
"https://backstage.10.0.0.200.nip.io/api/catalog/entities?filter=kind=component" \
| jq -r '.[] | "\(.metadata.name) β€” \(.spec.type)"'
# minicloud-ansible β€” tool
# minicloud-gitops β€” tool
# minicloud-opentofu β€” tool
# minicloud-platform-docs β€” documentation
# platform-demo β€” service

In the browser at https://backstage.10.0.0.200.nip.io:

  1. Click "Sign in as Guest"
  2. Catalog page β†’ switch from "OWNER" to "ALL" filter
  3. See 5 Components + 1 System with descriptions, tags, owner, links

Three real install gotchas​

1. Postgres race on first install​

helm install --wait reports STATUS: deployed even when Backstage's DB connection failed. Always restart the Backstage pod manually if logs show ECONNREFUSED.

2. Default URL reading whitelist blocks raw.githubusercontent.com​

Without backend.reading.allow: [{host: raw.githubusercontent.com}], catalog refresh logs:

Unable to read url, Reading from 'https://raw.githubusercontent.com/...'
is not allowed.

3. Guest auth gated in production-mode​

The off-the-shelf image runs NODE_ENV=production. Without auth.providers.guest.dangerouslyAllowOutsideDevelopment: true, every guest sign-in returns:

NotAllowedError: The guest provider cannot be used outside of a development environment

Done When​

βœ” 2 pods Running in backstage namespace (backstage + backstage-postgresql-0)
βœ” 1 PVC Bound on Longhorn (backstage-postgresql)
βœ” Cert + Ingress for backstage.10.0.0.200.nip.io
βœ” HTTPβ†’HTTPS 301 redirect; HTTPS returns 200 with title "minicloud platform"
βœ” Guest auth issues a JWT (~514 chars)
βœ” /api/catalog/entities returns 6 entities: 1 System + 5 Components
βœ” Each Component has its system: minicloud-platform link
βœ” Homer has a Backstage tile

Phase 24 β€” Custom Image + Authentik OIDC SSO (2026-07-10)​

Phase 18 ran the off-the-shelf Backstage image with guest auth. Phase 24 replaced both:

  • Custom image built from andrelair-platform/minicloud-backstage β€” TypeScript monorepo compiled on CI, pushed to Harbor, cosign-signed, auto-bumped via GPG-signed gitops commit.
  • Authentik OIDC β€” replaces guest auth. User signs in via the platform's central SSO; identity is resolved against a real catalog User entity.

Authentik provider setup​

In Authentik: Applications β†’ Providers β†’ Create β†’ OAuth2/OpenID Connect Provider

FieldValue
Namebackstage
Client typeConfidential
Client ID(auto-generated)
Client Secret(auto-generated)
Grant typesAuthorization Code + Refresh Token ← mandatory, defaults to empty
Signing Keyauthentik Self-signed Certificate
Property MappingsSelect all OpenID mappings
Redirect URIshttps://backstage.devandre.sbs/api/auth/oidc/handler/frame
Sub modeBased on hashed User ID

:::danger Grant types β€” the most commonly missed field The Authentik provider form defaults grant_types to an empty list when accessed via the API. If you create the provider via API (as Terraform/IaC tools do), authorization_code is never enabled. Symptom in Backstage logs: invalid_request (The request is otherwise malformed). Fix: open the provider in Authentik UI β†’ edit β†’ enable Authorization Code + Refresh Token. :::

Authorization flow: use default-provider-authorization-implicit-consent (NOT explicit-consent). Explicit-consent rejects prompt=none β€” Backstage's silent token refresh uses prompt=none and will always fail with login_required on explicit-consent flows.


Backstage Helm values β€” OIDC config​

Key additions in minicloud-gitops/helm-values/backstage-values.yaml:

backstage:
appConfig:
app:
baseUrl: https://backstage.devandre.sbs
extensions:
- page:catalog:
config:
path: / # ← catalog page at root; see "Root route" gotcha below
backend:
baseUrl: https://backstage.devandre.sbs
cors:
origin: https://backstage.devandre.sbs
auth:
environment: production
providers:
oidc:
production:
metadataUrl: https://auth.devandre.sbs/application/o/backstage/.well-known/openid-configuration
clientId: ${AUTH_OIDC_CLIENT_ID}
clientSecret: ${AUTH_OIDC_CLIENT_SECRET}
callbackUrl: https://backstage.devandre.sbs/api/auth/oidc/handler/frame
additionalScopes: []
signIn:
resolvers:
- resolver: emailMatchingUserEntityProfileEmail
- resolver: emailLocalPartMatchingUserEntityName
catalog:
rules:
- allow: [Component, System, API, Resource, Location, User, Group]
locations:
- type: url
target: https://raw.githubusercontent.com/andrelair-platform/minicloud-gitops/main/catalog-info.yaml
# ... (one entry per repo)

Frontend auth module (auth.tsx)​

The new Backstage frontend system (@backstage/frontend-defaults) requires an explicit ApiBlueprint for the OIDC provider. Guest auth is removed entirely.

// packages/app/src/modules/auth.tsx
import { createFrontendModule } from '@backstage/frontend-plugin-api';
import {
ApiBlueprint, createApiRef, configApiRef,
discoveryApiRef, oauthRequestApiRef,
} from '@backstage/frontend-plugin-api';
import { SignInPageBlueprint } from '@backstage/plugin-app-react';
import { OAuth2 } from '@backstage/core-app-api';
import { SignInPage } from '@backstage/core-components';

export const oidcAuthApiRef = createApiRef<...>({ id: 'auth.oidc' });

export const authModule = createFrontendModule({
pluginId: 'app',
extensions: [
ApiBlueprint.make({
name: 'oidc',
params: defineParams => defineParams({
api: oidcAuthApiRef,
deps: { configApi: configApiRef, discoveryApi: discoveryApiRef, oauthRequestApi: oauthRequestApiRef },
factory: ({ configApi, discoveryApi, oauthRequestApi }) =>
OAuth2.create({
configApi, // ← required in new system; omitting causes 404 on /api/config
discoveryApi, oauthRequestApi,
environment: 'production',
provider: { id: 'oidc', title: 'Authentik', icon: () => null },
defaultScopes: ['openid', 'email', 'profile'],
}),
}),
}),
SignInPageBlueprint.make({
params: {
loader: async () => (props: any) => (
<SignInPage {...props} providers={[{
id: 'oidc-auth-provider',
title: 'Login with Authentik',
message: 'Sign in using your Authentik SSO account',
apiRef: oidcAuthApiRef,
}]} />
// ← 'guest' removed: causes 404 on /api/auth/guest/refresh in production
),
},
}),
],
});

Catalog User + Group entities​

The sign-in resolver (emailMatchingUserEntityProfileEmail) looks for a User entity with matching email in the catalog. The catalog must have the User entity before login can complete.

Add to minicloud-gitops/catalog-info.yaml:

---
apiVersion: backstage.io/v1alpha1
kind: Group
metadata:
name: andrelair-platform
annotations:
backstage.io/source-location: url:https://github.com/andrelair-platform/minicloud-gitops
spec:
type: team
profile:
displayName: andrelair-platform
children: []
---
apiVersion: backstage.io/v1alpha1
kind: User
metadata:
name: kanmegnea
annotations:
backstage.io/source-location: url:https://github.com/andrelair-platform/minicloud-gitops
spec:
profile:
displayName: AndreLair
email: kanmegnea@gmail.com
memberOf:
- andrelair-platform

Also add User and Group to catalog.rules.allow in the Helm values β€” without this, these kinds are silently rejected even if the entity is present in the source file.


OIDC debug chain β€” 6 errors fixed in sequence​

Error 1: invalid_request (The request is otherwise malformed)​

Cause: Authentik provider grant_types: [] β€” authorization_code was never enabled.
Symptom in Authentik logs: "Invalid grant_type for provider", "grant_type": "authorization_code"
Fix: Edit provider in Authentik UI β†’ enable Authorization Code + Refresh Token.

Error 2: Failed to sign-in, unable to resolve user identity​

Cause: No User entity in the catalog with matching email.
Fix: Add User entity to catalog-info.yaml AND add User, Group to catalog.rules.allow.

Error 3: 404 on /api/auth/guest/refresh​

Cause: 'guest' provider left in SignInPage.providers array; the production Backstage image doesn't register the guest endpoint.
Fix: Remove 'guest' from the providers array.

Error 4: login_required on every Sign In attempt​

Root cause: metadataUrl pointed at auth.10.0.0.200.nip.io. The OIDC discovery document's authorization_endpoint therefore used that hostname. The login popup opened at auth.10.0.0.200.nip.io. The user's Authentik session cookie was scoped to auth.devandre.sbs (different hostname). Browser didn't send the cookie β†’ no active session β†’ Authentik rejected prompt=none β†’ login_required.

Fix: Change metadataUrl to https://auth.devandre.sbs/application/o/backstage/.well-known/openid-configuration. The discovery document then returns authorization_endpoint: auth.devandre.sbs/... β€” matching the hostname where the user's session cookie lives.

Key insight: the domain in metadataUrl must be the SAME domain where users authenticate in their browser. Backstage uses the OIDC discovery document to determine where to send the popup.

Error 5: Page Not Found after successful login​

Cause: app-config.yaml (baked into the image) contains app.extensions.page:catalog.config.path: /. But the Backstage pod only loads the Helm-injected ConfigMap at runtime β€” the base config is NOT loaded. Without the extension config, the catalog page is at /catalog, not /. After login, the main window was at https://backstage.devandre.sbs/ with no route registered there β†’ "Page Not Found".

Fix: Add the extension config explicitly to the Helm values appConfig:

app:
extensions:
- page:catalog:
config:
path: /

Key insight: in the new Backstage frontend system, the pod only loads the configs listed in the startup --config args. The base app-config.yaml is NOT auto-loaded in production unless explicitly included. Any runtime-critical config from the base file must be duplicated in the Helm values.

Confirmed by the pod's startup log:

Loading config from MergedConfigSource{
FileConfigSource{path="/app/app-config.session.yaml"},
FileConfigSource{path="/app/app-config-from-configmap.yaml"},
EnvConfigSource{count=1}
}

Error 6: catalog returns 0 entities (false alarm)​

Symptom: Backend access log showed GET /api/catalog/entities... 200 0 β€” the "0" looked like an empty response body.

Reality: Backstage 3.x catalog API uses Transfer-Encoding: chunked (no fixed Content-Length header). The HTTP access logger records 0 when no Content-Length header is present. The catalog had 36 entities in PostgreSQL the whole time, confirmed by:

PGPASS=$(kubectl get secret -n backstage backstage-postgres-secret \
-o jsonpath='{.data.password}' | base64 -d)
kubectl exec -n backstage backstage-postgresql-0 -- bash -c \
"PGPASSWORD='$PGPASS' psql -U bn_backstage -d backstage_plugin_catalog \
-c 'SELECT entity_ref FROM final_entities ORDER BY entity_ref;'"

The token issuance was also confirmed in the backend logs:

{"message":"Issuing token for user:default/kanmegnea, with entities user:default/kanmegnea,group:default/andrelair-platform","plugin":"auth"}

Verification​

# Pod running
kubectl get pod -n backstage -l app.kubernetes.io/name=backstage

# Catalog entity count (36 expected)
PGPASS=$(kubectl get secret -n backstage backstage-postgres-secret \
-o jsonpath='{.data.password}' | base64 -d)
kubectl exec -n backstage backstage-postgresql-0 -- bash -c \
"PGPASSWORD='$PGPASS' psql -U bn_backstage -d backstage_plugin_catalog \
-c 'SELECT COUNT(*) FROM final_entities;'"

# User entity present
kubectl exec -n backstage backstage-postgresql-0 -- bash -c \
"PGPASSWORD='$PGPASS' psql -U bn_backstage -d backstage_plugin_catalog \
-c \"SELECT entity_ref FROM final_entities WHERE entity_ref LIKE 'user:%' OR entity_ref LIKE 'group:%';\""

Manual login test:

  1. Navigate to https://backstage.devandre.sbs
  2. Click SIGN IN
  3. Authenticate via Authentik (kanmegnea + TOTP)
  4. Expect: catalog page at / showing all 14 Components + System + Group + User

Catalog refresh β€” how it actually works​

The catalog does NOT log individual location fetches. The processing loop runs via internal PostgreSQL state (refresh_state table). Scheduled tasks visible in logs:

TaskCadencePurpose
catalog_orphan_cleanup30sRemoves entities with no provider claiming them
search_index_software_catalog10mUpdates search index from catalog

The entity processing loop (reading GitHub URLs, processing YAML) runs asynchronously via the refresh_state Knex queue β€” it is NOT logged as a Registered scheduled task. Entities persist in PostgreSQL across pod restarts.


Real-world skills demonstrated​

SkillIndustry context
Off-the-shelf vs custom Backstage trade-offThe single most important Backstage adoption decision. Real teams agonize over this.
Software Catalog as the entry pointCatalog-first adoption is the canonical Backstage rollout pattern at scale (Spotify, Netflix, Roadie's customers, etc.)
PostgreSQL bitnami subchart with existingSecretStandard pattern for any Helm chart that ships a DB subchart β€” pre-create the secret out-of-band, reference it via existingSecret
raw.githubusercontent.com reads to avoid GitHub rate limitsReal production knowledge: even reading public repo files via the GitHub API hits unauthenticated rate limits. raw.githubusercontent.com is faster and rate-limit-free.
Annotations for future pluginsAdding argocd/app-name and prometheus.io/rule annotations even before the corresponding plugins are wired is the high-signal move β€” when plugins arrive, the metadata is already there
Production-mode auth gatingNODE_ENV=production + dangerouslyAllow is a Backstage-specific quirk that's documented but easily missed. Real install knowledge.
Risk-aware scope reductionChoosing minimal-IDP over full custom-build with all plugins is the same skill as Phase 11's Crossplane deferral, Phase 13's GitLab deferral, etc.
Honest "this is portfolio-only" framingNaming what isn't operationally needed alongside what is. A portfolio that says "we don't actually use this much" is more credible than one that pretends the homelab needs Backstage.