Homer — Self-Hosted Web Dashboard
Homer is a lightweight static dashboard that runs as a pod in your k3s cluster. It gives you a single browser page with tiles linking to every service in your infrastructure — accessible via Tailscale or Cloudflare Tunnel.
What It Looks Like
┌──────────────────────────────────────────────────────┐
│ Mini Cloud Platform │
│ Private Bare-Metal Infrastructure │
├──────────────┬──────────────┬──────────────┬─────────┤
│ MAAS │ Grafana │ ArgoCD │ GitLab │
│ Bare-metal │ Monitoring │ GitOps │ CI/CD │
│ provisioner │ dashboards │ sync │ │
├──────────────┴──────────────┴──────────────┴─────────┤
│ Kubernetes │
│ set-hog (CP) · fast-skunk · fast-heron │
│ ● ● ● All nodes Ready │
└──────────────────────────────────────────────────────┘
Homer vs Authentik Library — What's the Difference?
Both provide a list of platform services, but they serve completely different purposes:
| Authentik Library | Homer | |
|---|---|---|
| Purpose | SSO portal — only apps wired to Authentik | Navigation dashboard — every service on the platform |
| Entry criteria | Must have an Authentik Application configured (OIDC or forward-auth) | Manual ConfigMap entry |
| Auth | Clicking launches an SSO login flow | Dumb link — no auth, just navigates |
| Audience | "Log into app X via Authentik" | "Where is app X?" |
Authentik shows what it protects. Homer shows what exists.
This means:
- Services accessible only via Tailscale (MAAS, Prometheus, Alertmanager, MinIO) appear in Homer but not in Authentik — they don't need SSO because they're network-isolated.
- Authentik itself appears in Homer, but not in its own library (a portal can't protect itself).
- A new service must be added to both independently: one Authentik Application for SSO, one Homer ConfigMap entry for navigation.
:::tip Keep them in sync When you deploy a new service, add it to Homer's ConfigMap at the same time. It's easy to forget — the platform works fine without it, but Homer becomes stale. :::
Homer vs Backstage Catalog — What's the Difference?
Both show a list of platform services. The question they answer is completely different.
| Homer | Backstage Catalog | |
|---|---|---|
| The question it answers | "I need to get to X right now" | "What IS X, who owns it, how does it work?" |
| Auth required | No — loads instantly, no login | Yes — Authentik OIDC login |
| Audience | Anyone: ops, management demo, incident response | Engineers: developers, platform team |
| What it shows | Big tiles, one click to any URL | Owner, GitHub repo, ArgoCD sync state, pod status, Grafana links, API specs |
| Resilience | Works even if Authentik is down (static HTML) | Requires Authentik + PostgreSQL + k8s |
| Speed | Instant (static, no API calls) | Loads dynamically from catalog DB |
| Entry method | Manual ConfigMap tile | catalog-info.yaml committed to each repo |
Decision table — which one to open:
| Scenario | Tool |
|---|---|
| Open Grafana quickly during an incident | Homer |
Find which team owns the rag-ingest service | Backstage |
| Demo the platform to a manager (no SSO setup) | Homer |
| Onboard a new engineer who needs to understand what runs here | Backstage |
| Authentik is down, you need ArgoCD immediately | Homer |
| Find the GitHub repo for a given service | Backstage |
| Check if a deployment is healthy across all environments | Backstage (Kubernetes plugin) |
| Share a quick link to the Chat interface | Homer |
Analogy: Homer is your browser bookmarks bar. Backstage is your internal Wikipedia + ops dashboard rolled into one, per service.
Homer is not redundant — it is intentionally simpler and more resilient. Backstage going down does not affect Homer at all. Homer losing a tile does not affect Backstage. They serve different audiences and different cognitive modes (navigate fast vs understand deeply).
:::info Both must be updated when a new service is deployed
- Homer: add a tile to
manifests/homer/01-configmap.yaml+ bump theconfig-checksumannotation - Backstage: add a
catalog-info.yamlto the service repo and register it as a catalog location inbackstage-values.yaml
These are independent. A service can be in Homer only (no catalog metadata), in Backstage only (no tile), or in both. Aim for both. :::
Deploy Homer in k3s
Create the namespace and config
kubectl create namespace homer
Create the Homer config file (you'll mount it as a ConfigMap):
cat <<'EOF' > homer-config.yml
title: "Mini Cloud Platform"
subtitle: "Private Bare-Metal Infrastructure"
logo: "logo.png"
header: true
footer: false
colors:
light:
highlight-primary: "#3367d6"
highlight-secondary: "#4285f4"
highlight-hover: "#5a95f5"
background: "#f2f4f8"
card-background: "#ffffff"
text: "#363636"
text-header: "#ffffff"
text-title: "#303030"
text-subtitle: "#424242"
card-shadow: rgba(0, 0, 0, 0.1)
link: "#3273dc"
link-hover: "#363636"
dark:
highlight-primary: "#3367d6"
highlight-secondary: "#4285f4"
highlight-hover: "#5a95f5"
background: "#131313"
card-background: "#2b2b2b"
text: "#eaeaea"
text-header: "#ffffff"
text-title: "#fafafa"
text-subtitle: "#f5f5f5"
card-shadow: rgba(0, 0, 0, 0.4)
link: "#3273dc"
link-hover: "#ffdd57"
services:
- name: "Infrastructure"
icon: "fas fa-server"
items:
- name: "MAAS"
logo: "https://assets.ubuntu.com/v1/0b5f1b3e-maas-logo.svg"
subtitle: "Bare-metal provisioning"
tag: "infra"
url: "http://10.0.0.1:5240/MAAS"
target: "_blank"
- name: "set-hog"
icon: "fas fa-microchip"
subtitle: "Control plane — 10.0.0.2"
tag: "k8s"
url: "http://10.0.0.1:5240/MAAS/r/machines"
target: "_blank"
- name: "fast-skunk"
icon: "fas fa-microchip"
subtitle: "Worker — 10.0.0.4"
tag: "k8s"
url: "http://10.0.0.1:5240/MAAS/r/machines"
target: "_blank"
- name: "fast-heron"
icon: "fas fa-microchip"
subtitle: "Worker — 10.0.0.7"
tag: "k8s"
url: "http://10.0.0.1:5240/MAAS/r/machines"
target: "_blank"
- name: "Observability"
icon: "fas fa-chart-line"
items:
- name: "Grafana"
logo: "https://grafana.com/static/assets/img/fav32.png"
subtitle: "Metrics & dashboards"
tag: "monitoring"
url: "http://10.0.0.2:3000"
target: "_blank"
- name: "Prometheus"
icon: "fas fa-fire"
subtitle: "Metrics storage"
tag: "monitoring"
url: "http://10.0.0.2:9090"
target: "_blank"
- name: "DevOps"
icon: "fas fa-code-branch"
items:
- name: "ArgoCD"
icon: "fas fa-sync"
subtitle: "GitOps — continuous deployment"
tag: "gitops"
url: "http://10.0.0.2:8080"
target: "_blank"
- name: "GitLab / Gitea"
icon: "fas fa-code"
subtitle: "Git platform & CI/CD"
tag: "cicd"
url: "http://10.0.0.2:80"
target: "_blank"
EOF
Create Kubernetes manifests
cat <<'EOF' > homer-deployment.yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: homer-config
namespace: homer
data:
config.yml: |
# paste the contents of homer-config.yml here
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: homer
namespace: homer
spec:
replicas: 1
selector:
matchLabels:
app: homer
template:
metadata:
labels:
app: homer
spec:
containers:
- name: homer
# Pinned 2026-06-15 — see "Image tag pinning" section
image: b4bz/homer:v26.4.2
imagePullPolicy: IfNotPresent
ports:
- containerPort: 8080
volumeMounts:
- name: config
mountPath: /www/assets/config.yml
subPath: config.yml
volumes:
- name: config
configMap:
name: homer-config
---
apiVersion: v1
kind: Service
metadata:
name: homer
namespace: homer
spec:
type: NodePort
selector:
app: homer
ports:
- port: 8080
targetPort: 8080
nodePort: 30902
EOF
kubectl apply -f homer-deployment.yaml
Verify
kubectl get pods -n homer
kubectl get svc -n homer
Homer is now running at:
http://10.0.0.2:30902 ← from inside the network
http://100.x.x.x:30902 ← via Tailscale
https://dashboard.yourdomain.com ← via Cloudflare Tunnel
Connect to Cloudflare Tunnel
Update your ~/.cloudflared/config.yml on the MAAS controller:
- hostname: dashboard.yourdomain.com
service: http://10.0.0.2:30902
Then reload:
sudo systemctl restart cloudflared
Connect to Tailscale
No extra config needed. If the MAAS controller has the 10.0.0.0/24 route advertised via Tailscale, Homer is automatically reachable at:
http://10.0.0.2:30902
from any Tailscale-connected machine.
Customize Homer
Original method (pre-Phase 12, ad-hoc)
Edit homer-config.yml and update the ConfigMap:
kubectl create configmap homer-config \
--from-file=config.yml=homer-config.yml \
-n homer \
--dry-run=client -o yaml | kubectl apply -f -
kubectl rollout restart deployment/homer -n homer
This works for one-off local development. Do NOT use it in production — ArgoCD's selfHeal (Phase 12) reverts any kubectl-applied changes within ~3 minutes.
Production method (Phase 12 onwards — GitOps-managed)
Homer is one of the workloads managed by ArgoCD's app-of-apps pattern. Source-of-truth lives in andrelair-platform/minicloud-gitops at manifests/homer/. Two edits are needed for every tile change:
- Edit
manifests/homer/01-configmap.yaml— add/remove/modify theservicesblock - Bump
manifests/homer/02-deployment.yaml— change theconfig-checksumannotation value (e.g.v10-minio-tile→v11-github-tile)
The annotation bump is essential. Homer mounts the ConfigMap with subPath, which Kubernetes does NOT auto-propagate when the ConfigMap changes. Without the annotation change, the pod keeps serving the stale tile list forever even after the ConfigMap update succeeds. Annotation change → pod-template hash changes → rolling restart → new ConfigMap picked up. (Production teams automate this with Reloader or kustomize configMapGenerator. We do it by hand for the learning value.)
After both file edits:
cd minicloud-gitops
git add manifests/homer/
git commit -m "feat(homer): <what you changed>"
git push origin main
# ArgoCD reconciles within ~3 min:
kubectl get app homer -n argocd -w
# Watch for: Synced → OutOfSync → Synced → Progressing → Healthy
Current tile inventory (config-checksum v17, 2026-07-10)
The dashboard is organized into five sections:
| Section | Tiles | Notes |
|---|---|---|
| Infrastructure | MAAS, set-hog, fast-skunk, fast-heron, star-kitten | All 4 worker nodes + control plane |
| Observability | Grafana, Prometheus, Alertmanager, Polaris | Prometheus/Alertmanager/Polaris: internal tag (Tailscale only) |
| Identity | Authentik, Vaultwarden | Both public via Cloudflare Tunnel |
| DevOps | ArgoCD, GitHub, Harbor, MinIO | MinIO: internal (controller Tailscale IP) |
| Apps | platform-demo, Backstage, Chat, LiteLLM Admin, Langfuse, NATS, Nextcloud, Vault, ktayl-solution, podinfo, whoami | podinfo/whoami: internal |
Notes on tile design decisions:
- star-kitten added as 4th worker node (10.0.0.8) — same MAAS machine page URL as the others.
- Polaris (
polaris.10.0.0.200.nip.io) is internal-only — the dashboard blocks during its ~60s audit cycle; access via Tailscale between cycles. - MinIO tile points at
http://100.88.123.8:9001(controller's Tailscale IP) — MinIO runs as a Docker container on the controller, not in the k3s cluster. - All
*.10.0.0.200.nip.iotiles route through the cluster NGINX Ingress. Mac access works because Tailscale's subnet-route advertisement makes10.0.0.0/24reachable. - Prometheus and Alertmanager are tagged
internal— protected by Authentik forward-auth on their nip.io Ingress. Direct port-forward not needed. - Chat subtitle reflects the full AI Gateway: 7 Ollama models, RAG (bge-m3 + pgvector), SearXNG web search, LiteLLM multi-provider routing.
Alternative Dashboards
| Tool | Docker image | Features |
|---|---|---|
| Homer | b4bz/homer | Lightweight, YAML config, no DB |
| Dashy | lissy93/dashy | Rich UI, status checks, widgets |
| Heimdall | linuxserver/heimdall | App launcher, search bar |
| Flame | pawelmalak/flame | Bookmarks + weather widgets |
Homer is recommended for simplicity — no database, purely static, minimal resources (~10MB RAM).
Image tag pinning (2026-06-15)
Homer was originally installed with image: b4bz/homer:latest and imagePullPolicy: Always — the "pull the newest thing on every restart" pattern. During the 2026-06-15 platform-wide :latest audit (one of only two :latest references found cluster-wide), this was pinned to a specific version.
Change:
- image: b4bz/homer:latest
- imagePullPolicy: Always
+ # Pinned 2026-06-15
+ image: b4bz/homer:v26.4.2
+ imagePullPolicy: IfNotPresent
Applied via the GitOps workflow above: edit manifests/homer/02-deployment.yaml in minicloud-gitops → commit + push → ArgoCD reconciles. Force-sync with kubectl patch app homer -n argocd --type merge -p '{"operation":{"sync":{}}}' if you don't want to wait for the 3-min cycle.
Why v26.4.2: Newest published tag at pin time, immutable, low surprise risk (Homer is static HTML/JS — major version changes are infrequent and well-publicized).
Why imagePullPolicy: IfNotPresent: With a mutable :latest tag, Always was required to ensure pod restarts caught updates. With an immutable tag, Always is pure waste — every pod restart hits the registry for an image we know hasn't changed. IfNotPresent is correct: pull once per node, reuse from containerd cache.
Cluster-wide audit pattern (worth keeping in your shell history for periodic re-checks):
kubectl get pods -A -o jsonpath='{range .items[*]}{.metadata.namespace}/{.metadata.name}: {range .spec.containers[*]}{.image}{"\n"}{end}{end}' | grep ':latest$'
Empty output = cluster is fully pinned. The full incident story (which surfaced the same :latest anti-pattern on Backstage with much more dramatic symptoms — a NotImplementedError overlay on every page load) is documented in the Phase 18 doc.
Done When
✔ Homer pod Running in homer namespace
✔ Dashboard accessible at NodePort :30902
✔ All service tiles open correct URLs
✔ Accessible via Tailscale AND/OR Cloudflare Tunnel