Skip to main content

Family landing page

One page per family member, with their services and how to use them.

The family landing page at family.web.kueber.eu is a first-party static site that lives in this repository under family/. It answers the questions the family actually asks: which of the services here are mine, what is each one for, how do I log in, and how do I get it onto my phone. Every service page carries the official logo, a short "what it is for", the login box and the phone setup steps with store links. The copy is German and written for the family, not for the operator.

Installation​

Architecture​

Hugo renders the site, nginx (the unprivileged image) serves it, and the public Envoy Gateway does the login against Keycloak. Three pieces make it personal without any application code:

  • One page per person. Service pages name the family members they are for; Hugo turns each member into a page at /<name>/ that collects their services, with the person's accent colour and a wall of logo stickers that open the services directly.
  • The gateway says who you are. The route's SecurityPolicy runs the usual OIDC login and forwards the access token to a JWT provider on the same route, which copies the preferred_username claim into an x-family-user header. nginx maps that header to a person and sends a known user from / straight to their page; everyone else sees the picker. Only the gateway can reach the pod, so the header cannot be forged.
  • The links come from the repository. A build script reads every public hostname from the HTTPRoutes under k8s/apps into a data file; a service page whose hostname lost its public route fails the build instead of linking into the void.

The image is built by a Gitea workflow in this repository on every change under family/, signed with the homelab cosign key and pushed to the private Gitea registry; Renovate's overnight digest automerge rolls new content out.

Security​

The site is private: every path sits behind the gateway's Keycloak login, gated by the realm's role-check browser flow (client family, role user, mapped to the family group), all of it created by the app's workspace from spec.oidc, which also hands the policy its client secret. The page itself has no JavaScript and makes no external requests; the container runs as the nginx user on a read-only root filesystem with a strict Content-Security-Policy. The pages hold nothing secret, only first names, links and how-tos.

Updates​

Hugo is pinned in mise.toml, the two base images in the Dockerfile and the deployment's image digest are pinned by tag and digest; Renovate keeps all of them current.

Data Management​

Stateless. The content is the repository, the container is rebuilt from it.

User Management​

People live in family/data/people.yaml with their Keycloak username; access is the Keycloak family group. A person without a Keycloak username (the kids share one page today) is reachable from the picker only.

Configuration Management​

Everything is in family/: the Hugo configuration, the data files for people, categories and login texts, one markdown file per service, the layouts and stylesheet, the nginx configuration and the Dockerfile. family/README.md documents how to add a person or a service.

Usage​

Open family.web.kueber.eu, log in with the family account and you land on your own page. Tap a sticker to open a service; the rows underneath lead to the explanation pages with the login box and the phone setup. "Abmelden" ends the gateway session. Locally, make family-dev serves the site with live reload and make family-image builds the container as the workflow does.

Cluster Deployment​

Depends on

Family landing page — Talos cluster

Deviations from defaults​

  • First-party image from the private Gitea registry — gitea.web.kueber.eu/johnny/family:latest, pinned by digest, pulled via regcred-docker-gitea (namespace label secrets.docker-gitea). Unlike the other first-party apps the source is this repository: .gitea/workflows/family-image.yaml builds and signs the image on every change under family/, and Renovate's overnight digest automerge rolls it out. The first image was pushed by hand from a local build and is unsigned; the policy is in Audit, and the first CI build replaces it.
  • OIDC at the gateway, with the username passed on — security-policy-oidc.yaml is the ironscan policy (Keycloak client family, realm kueber.eu, redirect https://family.web.kueber.eu/oauth2/callback, issuer reached through the keycloak-backend Backend) plus two things: forwardAccessToken hands the access token to a jwt provider on the same route, whose claimToHeaders turns preferred_username into x-family-user. nginx maps that header to a person page (family/nginx/default.conf); the ingress NetworkPolicy makes the header trustworthy by admitting only the public gateway's Envoy pods. logoutPath: /logout backs the page's "Abmelden" link. The Keycloak client comes from the TofuWorkspace (workspace/patch-oidc.yaml): created with the realm's usual role-check browser flow (family-browser, client role user mapped to the family group), its minted secret read by the policy from workspace-outputs.
  • Stateless and tiny — no PVC, no backups, no Workspace; read-only root filesystem with an emptyDir at /tmp for nginx's temp paths, 10m/16Mi requested, tier-3.
Kubernetes Metadata
  • Image: gitea.web.kueber.eu/johnny/family:latest@sha256:e41d9681087ae1bfc91d1b7801125e40bbd091189e4119559ccd88a42704db53
Rendered manifests (kustomize build)
apiVersion: apps/v1
kind: Deployment
metadata:
labels:
app.kubernetes.io/name: family
name: family
namespace: family
spec:
replicas: 1
revisionHistoryLimit: 2
selector:
matchLabels:
app.kubernetes.io/name: family
template:
metadata:
labels:
app.kubernetes.io/name: family
spec:
automountServiceAccountToken: false
containers:
- image: gitea.web.kueber.eu/johnny/family:latest@sha256:e41d9681087ae1bfc91d1b7801125e40bbd091189e4119559ccd88a42704db53
imagePullPolicy: Always
livenessProbe:
httpGet:
path: /healthz
port: http
periodSeconds: 30
timeoutSeconds: 2
name: nginx
ports:
- containerPort: 8080
name: http
protocol: TCP
readinessProbe:
failureThreshold: 1
httpGet:
path: /healthz
port: http
initialDelaySeconds: 2
periodSeconds: 5
timeoutSeconds: 2
resources:
limits:
memory: 64Mi
requests:
cpu: 10m
memory: 16Mi
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop:
- ALL
privileged: false
readOnlyRootFilesystem: true
volumeMounts:
- mountPath: /tmp
name: tmp
imagePullSecrets:
- name: regcred-docker-gitea
priorityClassName: tier-3
securityContext:
fsGroup: 101
runAsGroup: 101
runAsNonRoot: true
runAsUser: 101
seccompProfile:
type: RuntimeDefault
volumes:
- emptyDir: {}
name: tmp