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
seitenraumnamespace, one replica each —app-serverrunning/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,Recreatebecause preview tickets live in memory) andpreview(Astro SSR, port 4321, no secrets) - Images:
gitea.web.kueber.eu/seitenraum/seitenraum-app,seitenraum-cmsandseitenraum-preview, digest-pinned on thelatesttag the repos' Gitea workflows push; private, so the namespace carries the SOPS-encryptedregcred-docker-giteapull secret - Database: single-instance CNPG cluster from the cnpg component's edge variant, 1Gi on local-path, with two databases:
app(DATABASE_URLfrom thecnpg-appsecret, migrates at startup) andcms(aDatabaseresource owned by the managed rolecms, password incnpg-cms; Payload runs its migrations at startup) - Storage: CMS media in a namespace MinIO from the minio component's edge variant, 5Gi
local-path; theminio-initjob creates themediabucket and acmsuser limited to it. The app has no PVC - Networking: ClusterIP services
app-server,cmsandpreviewon port 80; HTTPRoutes forapp.andcms.seitenraum.comon the wildcard listener of the dedicatedpublic-seitenraumGateway on the edge Envoy Gateway,preview.seitenraum.comon its own listener with its own certificate, because the shared security-headersClientTrafficPolicysetsX-Frame-Options: SAMEORIGINand the CMS frames the preview — that listener getsframe-ancestors https://cms.seitenraum.cominstead (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, 5Gilocal-path - Backups: none. The edge cluster has no k8up, and
local-pathis 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
envFromfrom the SOPS-encryptedapp-configSecret: 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_*) fromcms-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
- 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