Skip to main content

Voyage

Plan trips in one place, with the photos and attachments served straight from S3.

Voyage is a first-party trip-planning app: a Go backend with an embedded SvelteKit frontend, shipped as a single image from the private Gitea registry. It keeps its data in Postgres and its objects — photos and attachments — in an app-local MinIO that browsers fetch from directly through presigned URLs. Login works with a local email and password or through Keycloak, mail goes out over the shared SMTP relay, and both the app and its object store sit on the public gateway.

Installation​

Architecture​

  • Deployment: Single voyage deployment in the voyage namespace, one replica, Recreate strategy, container port 8080 with /readyz and /healthz probes
  • Image: gitea.web.kueber.eu/johnny/voyage, the latest tag pinned by digest and pulled with the regcred-docker-gitea pull secret (the namespace carries the secrets.docker-gitea label so the credential is synced in); the voyage repo builds and pushes it, there is no upstream chart
  • Database: CNPG PostgreSQL cluster from the cnpg component; VOYAGE_DATABASE_URL is the uri key of the generated cnpg-app Secret
  • Object storage: a MinIO StatefulSet from the shared minio component (the pgsty/silo image) with a 1Gi longhorn-object-encrypted volume; Voyage runs the s3 storage driver against bucket voyage with the MinIO root credentials
  • Networking: two HTTPRoutes on the public gateway's https listener — voyage.web.kueber.eu to the app on 8080 and voyage-s3.web.kueber.eu to MinIO on 9000. The second exists because Voyage serves object reads as presigned GET URLs that embed the S3 endpoint, so the endpoint must be browser-reachable; the same host is added to the app's CSP img-src and connect-src
  • Mail: the email component syncs allinkl-smtp-credentials, mapped variable by variable into VOYAGE_MAIL_SMTP_* with USE_TLS=true
  • Resources: app requests 25m CPU and 64Mi with a 128Mi limit; MinIO 256Mi with a 1Gi limit; CNPG 128Mi with a 256Mi limit; all three tier-3

Security​

  • Runs as uid 65532, runAsNonRoot: true, read-only root filesystem with an emptyDir at /tmp, all capabilities dropped, RuntimeDefault seccomp, no service account token; MinIO runs as uid 1000 with the same drops
  • Ingress to the app and to MinIO only from the public gateway's Envoy pods; the app's egress is SMTP on 587 and 465 to the internet and nothing else — Postgres and MinIO are intra-namespace and the app-network-policy baseline covers DNS and the gateway
  • VOYAGE_HTTP_TRUSTED_PROXIES is the pod CIDR of the gateway, so per-IP rate limiting keys on the address Envoy forwards; VOYAGE_HTTP_ALLOWED_ORIGINS is the app's own origin
  • JWT secret, proof-of-work secret and the admin e-mail list live in the SOPS-encrypted voyage-secrets Secret, the Keycloak client secret in workspace-outputs; the CNPG and MinIO volumes are Longhorn-encrypted with keys kept next to the respective component (cnpg/, minio/)
  • Kyverno's gitea-johnny cosign policy lists this image among the first-party builds that must be signed with the homelab key; it runs in Audit until the build workflows sign

Updates​

The deployment tracks the latest tag by digest, so a new build becomes a digest change in the manifest; MinIO and the CNPG image are digest-pinned through Renovate like every other component.

Data Management​

  • Database: CNPG cluster, pg_dump via the backups-cnpg PreBackupPod
  • Objects: the MinIO data volume is annotated k8up.io/backup: "true" because it holds primary data, unlike the ephemeral MinIO other apps use for caches
  • Backups: k8up Schedules to Hetzner and Backblaze via restic, bucket suffix -voyage-70608ddf

User Management​

In-app: local registration with email and password stays enabled, and OIDC is wired to the Keycloak client voyage in the kueber.eu realm, created by the app's workspace (spec.oidc, not gated; issuer https://keycloak.web.kueber.eu/realms/kueber.eu, callback /oidc/callback). Admins are the addresses in VOYAGE_APP_ADMIN_EMAILS.

Configuration Management​

Everything is VOYAGE_* environment: public URL, app name and source URL, trusted proxies, OIDC provider, mail and storage settings in the talos patches, secrets from voyage-secrets, database URI and SMTP credentials from the generated Secrets. Two settings encode quirks of the code: the S3 endpoint is given without a scheme because minio-go expects host[:port], and VOYAGE_STORAGE_S3_USE_PATH_STYLE=false is what selects SSL for the HTTPS endpoint.

Usage​

Open voyage.web.kueber.eu, sign in with Keycloak or a local account, and plan a trip; photos and attachments upload to MinIO and load from voyage-s3.web.kueber.eu. Whatever the app mails goes out through the shared relay under the Voyage sender name.

Cluster-specific deviations from the above live in the per-cluster README — see k8s/apps/voyage/talos/README.md.

Cluster Deployment​

Voyage — Talos cluster

Deviations from defaults​

  • First-party image from the private Gitea registry — gitea.web.kueber.eu/johnny/voyage:latest, pulled via regcred-docker-gitea (namespace label secrets.docker-gitea). There is no upstream chart or image; the voyage repo builds and pushes the image itself.
  • App-local MinIO with a public hostname — voyage runs the S3 storage driver against the shared minio component. Unlike cairn's ephemeral MinIO, this one is patched onto a persistent, k8up-backed PVC (minio-data) because it holds primary data, and it gets its own HTTPRoute (voyage-s3.web.kueber.eu): voyage serves object reads as presigned GET URLs that embed the S3 endpoint, so browsers must reach it directly. Two code quirks pin the config: minio-go wants the endpoint without a scheme, and voyage's main.go passes !use_path_style as the useSSL flag — hence VOYAGE_STORAGE_S3_USE_PATH_STYLE=false for the HTTPS endpoint (path-style lookup still applies automatically to non-AWS hosts).
  • SMTP via the shared all-inkl relay — voyage's VOYAGE_MAIL_SMTP_* variables are mapped one-by-one from allinkl-smtp-credentials (the email component secret) because voyage does not speak the homelab's SMTP_* naming convention. The mailer only does STARTTLS (net/smtp.SendMail), so the relay port must be a STARTTLS port, not 465.
  • OIDC against Keycloak — provider slug keycloak, client voyage in the kueber.eu realm, redirect URI https://voyage.web.kueber.eu/oidc/callback. Local email+password registration stays enabled. The client is created by the TofuWorkspace (workspace/patch-oidc.yaml, not gated) and its secret arrives in workspace-outputs.
Kubernetes Metadata
  • Image: gitea.web.kueber.eu/johnny/voyage:latest@sha256:e9709986c2ac10c9df8473ef2dd48feb7038d585f4b3f5b0707c47ca1a503372
  • 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
labels:
app.kubernetes.io/name: voyage
name: voyage
namespace: voyage
spec:
replicas: 1
revisionHistoryLimit: 2
selector:
matchLabels:
app.kubernetes.io/name: voyage
strategy:
rollingUpdate: null
type: Recreate
template:
metadata:
labels:
app.kubernetes.io/name: voyage
spec:
automountServiceAccountToken: false
containers:
- env:
- name: VOYAGE_ENV
value: production
- name: VOYAGE_APP_PUBLIC_URL
value: https://voyage.web.kueber.eu
- name: VOYAGE_APP_NAME
value: Voyage
- name: VOYAGE_APP_SOURCE_URL
value: https://gitea.web.kueber.eu/johnny/voyage
- name: VOYAGE_HTTP_ALLOWED_ORIGINS
value: https://voyage.web.kueber.eu
- name: VOYAGE_HTTP_TRUSTED_PROXIES
value: 10.100.0.0/16
- name: VOYAGE_OIDC_PROVIDERS
value: keycloak
- name: VOYAGE_OIDC_KEYCLOAK_DISPLAY_NAME
value: Keycloak
- name: VOYAGE_OIDC_KEYCLOAK_ISSUER
value: https://keycloak.web.kueber.eu/realms/kueber.eu
- name: VOYAGE_OIDC_KEYCLOAK_CLIENT_ID
value: voyage
- name: VOYAGE_OIDC_KEYCLOAK_CLIENT_SECRET
valueFrom:
secretKeyRef:
key: client-secret
name: workspace-outputs
- name: VOYAGE_MAIL_DRIVER
value: smtp
- name: VOYAGE_MAIL_SMTP_USE_TLS
value: 'true'
- name: VOYAGE_MAIL_FROM_NAME
value: Voyage
- name: VOYAGE_MAIL_SMTP_HOST
valueFrom:
secretKeyRef:
key: host
name: allinkl-smtp-credentials
- name: VOYAGE_MAIL_SMTP_PORT
valueFrom:
secretKeyRef:
key: port
name: allinkl-smtp-credentials
- name: VOYAGE_MAIL_SMTP_USER
valueFrom:
secretKeyRef:
key: username
name: allinkl-smtp-credentials
- name: VOYAGE_MAIL_SMTP_PASS
valueFrom:
secretKeyRef:
key: password
name: allinkl-smtp-credentials
- name: VOYAGE_MAIL_FROM_ADDR
valueFrom:
secretKeyRef:
key: from
name: allinkl-smtp-credentials
- name: VOYAGE_STORAGE_DRIVER
value: s3
- name: VOYAGE_STORAGE_S3_ENDPOINT
value: voyage-s3.web.kueber.eu
- name: VOYAGE_STORAGE_S3_REGION
value: us-east-1
- name: VOYAGE_STORAGE_S3_BUCKET
value: voyage
- name: VOYAGE_STORAGE_S3_USE_PATH_STYLE
value: 'false'
- name: VOYAGE_STORAGE_S3_ACCESS_KEY
valueFrom:
secretKeyRef:
key: MINIO_ROOT_USER
name: minio
- name: VOYAGE_STORAGE_S3_SECRET_KEY
valueFrom:
secretKeyRef:
key: MINIO_ROOT_PASSWORD
name: minio
- name: VOYAGE_SECURITY_EXTRA_CSP_IMG_SRC
value: https://voyage-s3.web.kueber.eu
- name: VOYAGE_SECURITY_EXTRA_CSP_CONNECT_SRC
value: https://voyage-s3.web.kueber.eu
- name: VOYAGE_DATABASE_URL
valueFrom:
secretKeyRef:
key: uri
name: cnpg-app
envFrom:
- secretRef:
name: voyage-secrets
image: gitea.web.kueber.eu/johnny/voyage:latest@sha256:e9709986c2ac10c9df8473ef2dd48feb7038d585f4b3f5b0707c47ca1a503372
imagePullPolicy: Always
livenessProbe:
failureThreshold: 5
httpGet:
path: /healthz
port: web
initialDelaySeconds: 10
periodSeconds: 10
successThreshold: 1
timeoutSeconds: 3
name: voyage
ports:
- containerPort: 8080
name: web
protocol: TCP
readinessProbe:
failureThreshold: 3
httpGet:
path: /readyz
port: web
initialDelaySeconds: 5
periodSeconds: 5
successThreshold: 1
timeoutSeconds: 3
resources:
limits:
memory: 128Mi
requests:
cpu: 25m
memory: 64Mi
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: 65532
fsGroupChangePolicy: OnRootMismatch
runAsGroup: 65532
runAsNonRoot: true
runAsUser: 65532
seccompProfile:
type: RuntimeDefault
volumes:
- emptyDir: {}
name: tmp
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
annotations:
kustomize.toolkit.fluxcd.io/force: enabled
name: minio
namespace: voyage
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
priorityClassName: tier-3
securityContext:
fsGroup: 1000
fsGroupChangePolicy: OnRootMismatch
runAsGroup: 1000
runAsNonRoot: true
runAsUser: 1000
seccompProfile:
type: RuntimeDefault
volumeClaimTemplates:
- metadata:
annotations:
k8up.io/backup: 'true'
name: data
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 1Gi
storageClassName: longhorn-object-encrypted