Add dashwise and tududi
Validate and Deploy to K3s / validate (push) Successful in 19s
Validate and Deploy to K3s / deploy (push) Successful in 23s

Two self-hosted apps behind traefik, both keeping their state on the
GlusterFS volume that is mounted on every node:

- dashwise (home.wittyoneoff.com) is an all-in-one image running its web
  server, a bundled PocketBase and valkey side by side. Only port 3000 is
  published: the frontend resolves its backend as window.location.origin
  and every PocketBase call is made server-side, so 8090 stays inside the
  pod. It is on the service so the PocketBase admin UI can be reached with
  kubectl port-forward.

- tududi (tududi.wittyoneoff.com) stores a SQLite database and user
  uploads. Both are VOLUMEs in the image, so both are backed by the claim
  -- as one volume mounted twice with subPath, because naming the same
  claim as two volume entries wedges kubelet, which is what happened with
  limesurvey.

Both images are pinned to tags that publish an arm64 manifest. dashwise
:latest and :stable are amd64 only and would not start on these nodes.

Namespaces and secrets are applied out of band, as with the other apps.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
jasonwitty
2026-08-12 12:46:58 -07:00
parent 9fd2a54e2e
commit 7b084dbddd
15 changed files with 368 additions and 0 deletions
+94
View File
@@ -0,0 +1,94 @@
# Dashwise all-in-one: the container runs the web/API server (3000), a bundled
# PocketBase (8090) and valkey, all on localhost inside the pod.
#
# Only 3000 is exposed. The browser never talks to PocketBase directly -- the
# frontend resolves its backend as window.location.origin and every
# PocketBase call is made server-side -- so 8090 stays internal.
#
# NOTE: :latest and :stable are built for amd64 ONLY and will not run on this
# arm64 cluster. Use a tag that publishes an arm64 manifest.
apiVersion: apps/v1
kind: Deployment
metadata:
name: dashwise
namespace: dashwise
labels:
app.kubernetes.io/name: dashwise
spec:
replicas: 1
revisionHistoryLimit: 3
strategy:
# the PV is ReadWriteOnce and PocketBase is a single-writer SQLite store,
# so the old pod has to go before the new one starts
type: Recreate
selector:
matchLabels:
app.kubernetes.io/name: dashwise
template:
metadata:
labels:
app.kubernetes.io/name: dashwise
spec:
containers:
- name: dashwise
image: andreasmolnardev/dashwise:v1.0-rc1
imagePullPolicy: IfNotPresent
env:
- name: ENVIRONMENT
value: production
- name: INSTANCE_NAME
value: Witty One Off
- name: START_POCKETBASE
value: "true"
- name: PB_BINARY_PATH
value: /usr/local/bin/pocketbase
# in-pod addresses: the server talks to PocketBase over localhost
- name: PB_URL
value: http://127.0.0.1:8090
- name: NEXT_PUBLIC_PB_URL
value: http://127.0.0.1:8090
- name: APP_BASE_URL
value: https://home.wittyoneoff.com
- name: NEXT_PUBLIC_APP_URL
value: https://home.wittyoneoff.com
- name: PB_ADMIN_EMAIL
valueFrom:
secretKeyRef:
name: dashwise
key: pb-admin-email
- name: PB_ADMIN_PASSWORD
valueFrom:
secretKeyRef:
name: dashwise
key: pb-admin-password
- name: TZ
value: America/Los_Angeles
ports:
- name: http
containerPort: 3000
protocol: TCP
- name: pocketbase
containerPort: 8090
protocol: TCP
startupProbe:
tcpSocket:
port: http
failureThreshold: 60
periodSeconds: 5
readinessProbe:
tcpSocket:
port: http
periodSeconds: 10
failureThreshold: 3
livenessProbe:
tcpSocket:
port: http
periodSeconds: 20
failureThreshold: 3
volumeMounts:
- name: data
mountPath: /app/pocketbase/pb_data
volumes:
- name: data
persistentVolumeClaim:
claimName: dashwise
+23
View File
@@ -0,0 +1,23 @@
# Both hostnames point at the same backend: the public one used by the EC2
# nginx-proxy-manager, and the origin-* alias traefik matches on when NPM
# forwards to origin.wittyoneoff.com:8080.
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: dashwise
namespace: dashwise
spec:
ingressClassName: traefik
rules:
- host: home.wittyoneoff.com
http: &backend
paths:
- path: /
pathType: Prefix
backend:
service:
name: dashwise
port:
number: 3000
- host: origin-home.wittyoneoff.com
http: *backend
+10
View File
@@ -0,0 +1,10 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
# secret.example.yaml is intentionally NOT listed -- copy it to secret.yaml
# (gitignored), set real values, and apply it once per cluster.
resources:
- namespace.yaml
- storage.yaml
- deployment.yaml
- service.yaml
- ingress.yaml
+4
View File
@@ -0,0 +1,4 @@
apiVersion: v1
kind: Namespace
metadata:
name: dashwise
+13
View File
@@ -0,0 +1,13 @@
# Copy to secret.yaml (gitignored), set real values, and apply once:
# kubectl apply -f secret.yaml
# These are the PocketBase superuser credentials dashwise creates on first
# start; they are also what you use to reach the PocketBase admin UI.
apiVersion: v1
kind: Secret
metadata:
name: dashwise
namespace: dashwise
type: Opaque
stringData:
pb-admin-email: you@example.com
pb-admin-password: CHANGE-ME
+18
View File
@@ -0,0 +1,18 @@
apiVersion: v1
kind: Service
metadata:
name: dashwise
namespace: dashwise
spec:
type: ClusterIP
selector:
app.kubernetes.io/name: dashwise
ports:
- name: http
port: 3000
targetPort: http
# PocketBase is deliberately NOT published through the ingress; this port
# exists so its admin UI can be reached with `kubectl port-forward`.
- name: pocketbase
port: 8090
targetPort: pocketbase
+29
View File
@@ -0,0 +1,29 @@
# PocketBase data lives on GlusterFS (/mnt/gvolume0 is mounted on every node),
# so the pod can be scheduled on any of them.
apiVersion: v1
kind: PersistentVolume
metadata:
name: dashwise-pv
spec:
capacity:
storage: 5Gi
accessModes:
- ReadWriteOnce
persistentVolumeReclaimPolicy: Retain
volumeMode: Filesystem
hostPath:
path: /mnt/gvolume0/dashwise/pb_data
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: dashwise
namespace: dashwise
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 5Gi
storageClassName: ""
volumeName: dashwise-pv
+80
View File
@@ -0,0 +1,80 @@
# tududi keeps everything in a SQLite file under /app/db and user uploads
# under /app/uploads. Both are declared VOLUMEs in the image, so both have to
# be backed by the PVC or they are lost on restart.
apiVersion: apps/v1
kind: Deployment
metadata:
name: tududi
namespace: tududi
labels:
app.kubernetes.io/name: tududi
spec:
replicas: 1
revisionHistoryLimit: 3
strategy:
# single-writer SQLite on a ReadWriteOnce volume
type: Recreate
selector:
matchLabels:
app.kubernetes.io/name: tududi
template:
metadata:
labels:
app.kubernetes.io/name: tududi
spec:
containers:
- name: tududi
image: chrisvel/tududi:1.3.1
imagePullPolicy: IfNotPresent
env:
# tududi sits behind traefik and the EC2 proxy, so it has to trust
# X-Forwarded-* to build correct URLs and mark cookies secure
- name: TUDUDI_TRUST_PROXY
value: "true"
- name: TUDUDI_USER_EMAIL
valueFrom:
secretKeyRef:
name: tududi
key: user-email
- name: TUDUDI_USER_PASSWORD
valueFrom:
secretKeyRef:
name: tududi
key: user-password
- name: TUDUDI_SESSION_SECRET
valueFrom:
secretKeyRef:
name: tududi
key: session-secret
- name: TZ
value: America/Los_Angeles
ports:
- name: http
containerPort: 3002
protocol: TCP
startupProbe:
tcpSocket:
port: http
failureThreshold: 60
periodSeconds: 5
readinessProbe:
tcpSocket:
port: http
periodSeconds: 10
failureThreshold: 3
livenessProbe:
tcpSocket:
port: http
periodSeconds: 20
failureThreshold: 3
volumeMounts:
- name: data
mountPath: /app/db
subPath: db
- name: data
mountPath: /app/uploads
subPath: uploads
volumes:
- name: data
persistentVolumeClaim:
claimName: tududi
+22
View File
@@ -0,0 +1,22 @@
# Public hostname plus the origin-* alias traefik matches on when the EC2
# nginx-proxy-manager forwards to origin.wittyoneoff.com:8080.
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: tududi
namespace: tududi
spec:
ingressClassName: traefik
rules:
- host: tududi.wittyoneoff.com
http: &backend
paths:
- path: /
pathType: Prefix
backend:
service:
name: tududi
port:
number: 3002
- host: origin-tududi.wittyoneoff.com
http: *backend
+10
View File
@@ -0,0 +1,10 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
# secret.example.yaml is intentionally NOT listed -- copy it to secret.yaml
# (gitignored), set real values, and apply it once per cluster.
resources:
- namespace.yaml
- storage.yaml
- deployment.yaml
- service.yaml
- ingress.yaml
+4
View File
@@ -0,0 +1,4 @@
apiVersion: v1
kind: Namespace
metadata:
name: tududi
+15
View File
@@ -0,0 +1,15 @@
# Copy to secret.yaml (gitignored), set real values, and apply once:
# kubectl apply -f secret.yaml
# user-email / user-password are the login tududi creates on first start.
# session-secret signs session cookies -- generate with:
# openssl rand -hex 64
apiVersion: v1
kind: Secret
metadata:
name: tududi
namespace: tududi
type: Opaque
stringData:
user-email: you@example.com
user-password: CHANGE-ME
session-secret: CHANGE-ME
+13
View File
@@ -0,0 +1,13 @@
apiVersion: v1
kind: Service
metadata:
name: tududi
namespace: tududi
spec:
type: ClusterIP
selector:
app.kubernetes.io/name: tududi
ports:
- name: http
port: 3002
targetPort: http
+31
View File
@@ -0,0 +1,31 @@
# One volume for both of tududi's data paths, mounted twice with subPath.
# Declaring the same PVC as two separate volume entries in one pod wedges
# kubelet's volume setup (pod stuck in Init with no events) -- that is what
# happened with limesurvey; do not split this into db-pv and uploads-pv.
apiVersion: v1
kind: PersistentVolume
metadata:
name: tududi-pv
spec:
capacity:
storage: 5Gi
accessModes:
- ReadWriteOnce
persistentVolumeReclaimPolicy: Retain
volumeMode: Filesystem
hostPath:
path: /mnt/gvolume0/tududi
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: tududi
namespace: tududi
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 5Gi
storageClassName: ""
volumeName: tududi-pv