Skip to main content

Seitenraum

Customers, contracts and invoices for a small hosting business, in one binary.

Seitenraum is a first-party Go service that manages customers, contracts, domains, email accounts and invoices, with a daily billing cron and a notification worker running in the same process. One SvelteKit app for admins and customers is embedded into the server binary, so one container serves the API and the UI from the same origin; permissions decide what a user sees. The same binary is the OpenID Connect provider for the seitenraum-cms (Payload) that edits the customers' websites, with the seitenraum-astro preview server rendering drafts live. It lives in the seitenraum organisation on Gitea, and its image is pulled from the Gitea registry. Like Openbeehive it is a public service with its own domain, which is why it runs on the edge cluster rather than at home (multi-cluster topology).

Installation​

Architecture​

  • Deployments: three in the seitenraum namespace, one replica each — app-server running /app/server (HTTP API on 8080 plus the in-process worker and billing cron, also the OpenID provider under /oidc), cms (Payload on Next.js, port 3000, Recreate because preview tickets live in memory) and preview (Astro SSR, port 4321, no secrets)
  • Images: gitea.web.kueber.eu/seitenraum/seitenraum-app, seitenraum-cms and seitenraum-preview, digest-pinned on the latest tag the repos' Gitea workflows push; private, so the namespace carries the SOPS-encrypted regcred-docker-gitea pull secret
  • Database: single-instance CNPG cluster from the cnpg component's edge variant, 1Gi on local-path, with two databases: app (DATABASE_URL from the cnpg-app secret, migrates at startup) and cms (a Database resource owned by the managed role cms, password in cnpg-cms; Payload runs its migrations at startup)
  • Storage: CMS media in a namespace MinIO from the minio component's edge variant, 5Gi local-path; the minio-init job creates the media bucket and a cms user limited to it. The app has no PVC
  • Networking: ClusterIP services app-server, cms and preview on port 80; HTTPRoutes for app. and cms.seitenraum.com on the wildcard listener of the dedicated public-seitenraum Gateway on the edge Envoy Gateway, preview.seitenraum.com on its own listener with its own certificate, because the shared security-headers ClientTrafficPolicy sets X-Frame-Options: SAMEORIGIN and the CMS frames the preview — that listener gets frame-ancestors https://cms.seitenraum.com instead (certificate lifecycle)
  • Single sign-on: the CMS has no passwords; its login button starts an OpenID Connect code flow (PKCE) against the app, discovery and token exchange leave through the gateway hairpin, back-channel logout comes back in-namespace on http://cms
  • Health: app on /health, CMS on /api/access, preview via TCP

Security​

  • Runs as uid 65532, runAsNonRoot: true, readOnlyRootFilesystem: true, allowPrivilegeEscalation: false, all capabilities dropped, no service account token
  • Ingress to all three workloads only from the public gateway class's Envoy pods; the app's egress to the internet is 587 and 465 for outbound mail, the CMS and preview have none beyond the namespace — DNS and the gateway hairpin come from the app-network-policy baseline
  • The entire runtime configuration (app-config, cms-config, minio, cnpg-cms), including the SMTP password, the Payload secret and the shared OIDC client secret, is SOPS-encrypted; nothing about the business identity is in plain text in the repo

Updates​

Renovate moves the digests behind the latest tags, so every default-branch build of the app, CMS and astro repos becomes a pull request here. Manifests reach the cluster as the cosign-verified OCI artifact oci-edge-app-seitenraum (GitOps flow).

Data Management​

  • Database: CNPG, one instance, 1Gi local-path, two databases; Media: MinIO, 5Gi local-path
  • Backups: none. The edge cluster has no k8up, and local-path is a directory on the single node's disk; the invoices and customer records in Postgres and the website media are lost with the node. See storage tiers for the edge posture
  • Resources: app 50m / 64Mi (256Mi limit), CMS 100m / 512Mi (1.5Gi limit), preview 50m / 128Mi (512Mi limit), MinIO 50m / 256Mi (1Gi limit)

User Management​

Accounts are in-app: the image ships an operator CLI (/app/admin) that creates admins and customer users and resets passwords; customers sign in to the same portal with the users created there. CMS users are created on first sign-in from the app's claims (admin or editor, scoped to the tenants of their customer), so nothing is administered in Payload itself. WebAuthn relying-party settings (WEBAUTHN_RP_*) are part of the configuration, so passkeys can be enrolled on the served hostname; the relying-party ID is sticky and must not change once passkeys exist.

Configuration Management​

  • Everything is environment, loaded with envFrom from the SOPS-encrypted app-config Secret: listen address, cookie domain and security flags, session TTL, the supplier identity that is snapshotted into invoices (name, address, VAT ID or Kleinunternehmer flag, IBAN and tax number for finalizing), the WebAuthn relying party, the public base URL, the OIDC client of the CMS (OIDC_CMS_*, CMS_SSO_URL) and the SMTP relay
  • The CMS reads the public URLs and the OIDC issuer from the deployment patch and the secrets (DATABASE_URI, PAYLOAD_SECRET, OIDC_CLIENT_SECRET, S3_*) from cms-config; the preview only needs the internal and public CMS URLs
  • The server refuses to start when a required variable is missing, so a bad edit shows up as a pod that never becomes ready rather than as a half-configured service

Usage​

app.seitenraum.com is where admins maintain customers, contracts, domains and mailboxes and create invoices, and where customers see their own contracts and invoices. "Edit website" in the menu sends a user to cms.seitenraum.com, which signs them in through the app; there they edit their pages and open the live preview. The billing cron creates open invoice positions once a day and the notification worker sends the resulting mail through the configured relay.

Cluster-specific deviations from the above live next to the manifests — see k8s/apps/seitenraum/edge; there is no per-cluster README yet.

Cluster Deployment​

Kubernetes Metadata
  • Image: gitea.web.kueber.eu/seitenraum/seitenraum-app:latest@sha256:59bb9170af353c2b2d07f343dba29a8bf927bdeb399b862e7df9e7b0beecdc4d
  • Image: gitea.web.kueber.eu/seitenraum/seitenraum-cms:latest@sha256:b6d3346c1b2f0d15c13dfaae96f12272e699005bb83b9e62685c34b493eac8fb
  • Image: gitea.web.kueber.eu/seitenraum/seitenraum-preview:latest@sha256:59fb32f5808e239d7046e4efed71aff686b795b5b24290f7cfe90a54c6ff9b06
  • Image: pgsty/silo:RELEASE.2026-10-15T00-00-00Z@sha256:aaac6bce13eb99316b3c88bd20143fc3c38ab607585256a411a095c44ee57f62
Rendered manifests (kustomize build)
apiVersion: apps/v1
kind: Deployment
metadata:
annotations:
kustomize.toolkit.fluxcd.io/force: enabled
name: app-server
namespace: seitenraum
spec:
replicas: 1
revisionHistoryLimit: 2
selector:
matchLabels:
app.kubernetes.io/name: seitenraum
template:
metadata:
labels:
app.kubernetes.io/name: seitenraum
spec:
automountServiceAccountToken: false
containers:
- command:
- /app/server
env:
- name: DATABASE_URL
valueFrom:
secretKeyRef:
key: uri
name: cnpg-app
envFrom:
- secretRef:
name: app-config
image: gitea.web.kueber.eu/seitenraum/seitenraum-app:latest@sha256:59bb9170af353c2b2d07f343dba29a8bf927bdeb399b862e7df9e7b0beecdc4d
livenessProbe:
httpGet:
path: /health
port: http
initialDelaySeconds: 30
periodSeconds: 30
name: app-server
ports:
- containerPort: 8080
name: http
readinessProbe:
httpGet:
path: /health
port: http
initialDelaySeconds: 3
periodSeconds: 5
resources:
limits:
memory: 256Mi
requests:
cpu: 50m
memory: 64Mi
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop:
- ALL
readOnlyRootFilesystem: true
runAsGroup: 65532
runAsNonRoot: true
runAsUser: 65532
imagePullSecrets:
- name: regcred-docker-gitea
---
apiVersion: apps/v1
kind: Deployment
metadata:
annotations:
kustomize.toolkit.fluxcd.io/force: enabled
name: cms
namespace: seitenraum
spec:
replicas: 1
revisionHistoryLimit: 2
selector:
matchLabels:
app.kubernetes.io/name: seitenraum-cms
strategy:
type: Recreate
template:
metadata:
labels:
app.kubernetes.io/name: seitenraum-cms
spec:
automountServiceAccountToken: false
containers:
- env:
- name: PREVIEW_URL
value: https://preview.seitenraum.com
- name: EXTRA_ORIGINS
value: https://cms.seitenraum.com,https://preview.seitenraum.com
- name: OIDC_ISSUER
value: https://app.seitenraum.com/oidc
- name: OIDC_INTERNAL_URL
value: http://app-server
- name: OIDC_CLIENT_ID
value: seitenraum-cms
- name: OIDC_REDIRECT_URI
value: https://cms.seitenraum.com/api/oidc/callback
- name: S3_ENDPOINT
value: http://minio:9000
- name: S3_BUCKET
value: media
- name: S3_REGION
value: us-east-1
envFrom:
- secretRef:
name: cms-config
image: gitea.web.kueber.eu/seitenraum/seitenraum-cms:latest@sha256:b6d3346c1b2f0d15c13dfaae96f12272e699005bb83b9e62685c34b493eac8fb
livenessProbe:
httpGet:
path: /api/access
port: http
initialDelaySeconds: 60
periodSeconds: 30
name: cms
ports:
- containerPort: 3000
name: http
readinessProbe:
httpGet:
path: /api/access
port: http
initialDelaySeconds: 10
periodSeconds: 10
resources:
limits:
memory: 1536Mi
requests:
cpu: 100m
memory: 512Mi
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop:
- ALL
readOnlyRootFilesystem: true
volumeMounts:
- mountPath: /tmp
name: tmp
- mountPath: /app/.next/cache
name: next-cache
imagePullSecrets:
- name: regcred-docker-gitea
securityContext:
runAsGroup: 1001
runAsNonRoot: true
runAsUser: 1001
seccompProfile:
type: RuntimeDefault
volumes:
- emptyDir: {}
name: tmp
- emptyDir: {}
name: next-cache
---
apiVersion: apps/v1
kind: Deployment
metadata:
annotations:
kustomize.toolkit.fluxcd.io/force: enabled
name: preview
namespace: seitenraum
spec:
replicas: 1
revisionHistoryLimit: 2
selector:
matchLabels:
app.kubernetes.io/name: seitenraum-preview
template:
metadata:
labels:
app.kubernetes.io/name: seitenraum-preview
spec:
automountServiceAccountToken: false
containers:
- env:
- name: PAYLOAD_URL
value: http://cms
- name: PAYLOAD_PUBLIC_URL
value: https://cms.seitenraum.com
image: gitea.web.kueber.eu/seitenraum/seitenraum-preview:latest@sha256:59fb32f5808e239d7046e4efed71aff686b795b5b24290f7cfe90a54c6ff9b06
livenessProbe:
initialDelaySeconds: 30
periodSeconds: 30
tcpSocket:
port: http
name: preview
ports:
- containerPort: 4321
name: http
readinessProbe:
initialDelaySeconds: 5
periodSeconds: 10
tcpSocket:
port: http
resources:
limits:
memory: 512Mi
requests:
cpu: 50m
memory: 128Mi
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop:
- ALL
readOnlyRootFilesystem: true
volumeMounts:
- mountPath: /tmp
name: tmp
imagePullSecrets:
- name: regcred-docker-gitea
securityContext:
runAsGroup: 1000
runAsNonRoot: true
runAsUser: 1000
seccompProfile:
type: RuntimeDefault
volumes:
- emptyDir: {}
name: tmp
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
annotations:
kustomize.toolkit.fluxcd.io/force: enabled
name: minio
namespace: seitenraum
spec:
replicas: 1
selector:
matchLabels:
app.kubernetes.io/name: minio
serviceName: minio
template:
metadata:
labels:
app.kubernetes.io/name: minio
spec:
containers:
- args:
- server
- /data
- --console-address
- :9001
envFrom:
- secretRef:
name: minio
image: pgsty/silo:RELEASE.2026-10-15T00-00-00Z@sha256:aaac6bce13eb99316b3c88bd20143fc3c38ab607585256a411a095c44ee57f62
livenessProbe:
httpGet:
path: /minio/health/live
port: s3
initialDelaySeconds: 10
periodSeconds: 10
name: minio
ports:
- containerPort: 9000
name: s3
- containerPort: 9001
name: console
readinessProbe:
httpGet:
path: /minio/health/ready
port: s3
initialDelaySeconds: 3
periodSeconds: 5
resources:
limits:
memory: 1Gi
requests:
cpu: 50m
memory: 256Mi
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop:
- ALL
privileged: false
volumeMounts:
- mountPath: /data
name: data
securityContext:
fsGroup: 1000
fsGroupChangePolicy: OnRootMismatch
runAsGroup: 1000
runAsNonRoot: true
runAsUser: 1000
seccompProfile:
type: RuntimeDefault
volumeClaimTemplates:
- metadata:
name: data
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 5Gi
storageClassName: local-path