# SparkyFitness - Workout / nutrition tracker # https://github.com/CodeWithCJ/SparkyFitness # Version: v0.16.6.3 (pinned) # Domain: workout.dooplex.hu (+ workout.home) # Auth: SparkyFitness NATIVE OIDC against Authentik (no forward-auth in front). # DB: dedicated postgres:15-alpine in this namespace (NOT the shared CNPG cluster). # # ============================================================================ # Replaces wger (see workout.yaml — wger is parked, not deleted, for rollback). # # Image names: the upstream Helm chart uses codewithcj/sparkyfitness-{server,frontend}; # we use the docker-compose image names (codewithcj/sparkyfitness_server and # codewithcj/sparkyfitness) as specified for this deployment. The frontend root # image's nginx has `listen 80;` hardcoded, so the frontend listens on :80 # (NGINX_LISTEN_PORT set explicitly to match container/Service ports). # # Secrets (manual, NOT in git — created with kubectl): # sparky-oauth : client-id, client-secret (OIDC; already created) # sparky-app : db-user, db-password, app-db-user, (DB owner + app user + # app-db-password, api-encryption-key, crypto keys) # better-auth-secret # api-encryption-key / better-auth-secret must NEVER change after first boot # (changing them locks out 2FA users and makes encrypted data unrecoverable). # # Authentik provider: OAuth2/OpenID, application slug `sparky-fitness`, confidential, # RS256, scopes `openid profile email`. Issuer must match SPARKY_FITNESS_OIDC_ISSUER_URL # below. Register the exact oidc-callback redirect URI shown in SparkyFitness admin # (Settings -> Authentication) once the app is up. # ============================================================================ --- # ---------------------------------------------------------------------------- # Database: dedicated PostgreSQL 15 (alpine). POSTGRES_USER is the cluster # superuser, so SparkyFitness auto-creates the limited app role, applies RLS # table ownership, and installs uuid-ossp/pgcrypto/pg_stat_statements on its # own — no init SQL or shared_preload_libraries tinkering needed. # ---------------------------------------------------------------------------- --- apiVersion: v1 kind: Namespace metadata: name: workout-system labels: app.kubernetes.io/name: sparkyfitness --- apiVersion: apps/v1 kind: Deployment metadata: name: sparkyfitness-db namespace: workout-system labels: app.kubernetes.io/instance: sparkyfitness app.kubernetes.io/name: postgres spec: replicas: 1 strategy: type: Recreate selector: matchLabels: app.kubernetes.io/instance: sparkyfitness app.kubernetes.io/name: postgres template: metadata: labels: app.kubernetes.io/instance: sparkyfitness app.kubernetes.io/name: postgres spec: enableServiceLinks: false securityContext: fsGroup: 999 containers: - name: postgres image: postgres:15-alpine env: - name: POSTGRES_USER valueFrom: secretKeyRef: name: sparky-app key: db-user - name: POSTGRES_PASSWORD valueFrom: secretKeyRef: name: sparky-app key: db-password - name: POSTGRES_DB value: "sparkyfitness_db" # Keep PG data in a subdir so the volume mount root can hold lost+found - name: PGDATA value: "/var/lib/postgresql/data/pgdata" ports: - containerPort: 5432 name: postgres resources: requests: cpu: 100m memory: 256Mi limits: cpu: 500m memory: 512Mi volumeMounts: - name: data mountPath: /var/lib/postgresql/data subPath: data livenessProbe: exec: command: ["sh", "-c", "pg_isready -U \"$POSTGRES_USER\" -d \"$POSTGRES_DB\""] initialDelaySeconds: 30 periodSeconds: 15 timeoutSeconds: 5 failureThreshold: 5 readinessProbe: exec: command: ["sh", "-c", "pg_isready -U \"$POSTGRES_USER\" -d \"$POSTGRES_DB\""] initialDelaySeconds: 5 periodSeconds: 10 timeoutSeconds: 5 volumes: - name: data persistentVolumeClaim: claimName: sparkyfitness-postgres --- # ---------------------------------------------------------------------------- # Server: Node.js backend API (port 3010). Runs DB migrations + creates the # limited app role on first boot. /api/health is the health endpoint. # ---------------------------------------------------------------------------- apiVersion: apps/v1 kind: Deployment metadata: name: sparkyfitness-server namespace: workout-system labels: app.kubernetes.io/instance: sparkyfitness app.kubernetes.io/name: server spec: replicas: 1 strategy: type: Recreate selector: matchLabels: app.kubernetes.io/instance: sparkyfitness app.kubernetes.io/name: server template: metadata: labels: app.kubernetes.io/instance: sparkyfitness app.kubernetes.io/name: server annotations: # Tag format is vMAJOR.MINOR.PATCH.BUILD (e.g. v0.16.6.3) match-regex.version-checker.io/server: '^v\d+\.\d+\.\d+\.\d+$' spec: enableServiceLinks: false securityContext: # node user (UID 1000) needs to write to the uploads PVC fsGroup: 1000 initContainers: - name: wait-for-db image: busybox:1.36 command: - sh - -c - | echo "Waiting for PostgreSQL..." until nc -z sparkyfitness-db 5432; do echo "PostgreSQL not ready, waiting..." sleep 2 done echo "PostgreSQL is ready!" containers: - name: server image: codewithcj/sparkyfitness_server:v0.16.6.3 env: # ---- Database (owner / superuser role, used for migrations) ---- - name: SPARKY_FITNESS_DB_HOST value: "sparkyfitness-db" - name: SPARKY_FITNESS_DB_PORT value: "5432" - name: SPARKY_FITNESS_DB_NAME value: "sparkyfitness_db" - name: SPARKY_FITNESS_DB_USER valueFrom: secretKeyRef: name: sparky-app key: db-user - name: SPARKY_FITNESS_DB_PASSWORD valueFrom: secretKeyRef: name: sparky-app key: db-password # ---- Limited-privilege app role (auto-created on first boot) ---- - name: SPARKY_FITNESS_APP_DB_USER valueFrom: secretKeyRef: name: sparky-app key: app-db-user - name: SPARKY_FITNESS_APP_DB_PASSWORD valueFrom: secretKeyRef: name: sparky-app key: app-db-password # ---- Crypto secrets (SET ONCE, NEVER CHANGE) ---- - name: SPARKY_FITNESS_API_ENCRYPTION_KEY valueFrom: secretKeyRef: name: sparky-app key: api-encryption-key - name: BETTER_AUTH_SECRET valueFrom: secretKeyRef: name: sparky-app key: better-auth-secret # ---- App config ---- - name: NODE_ENV value: "production" - name: TZ value: "Europe/Budapest" - name: SPARKY_FITNESS_LOG_LEVEL value: "INFO" - name: SPARKY_FITNESS_FRONTEND_URL value: "https://workout.dooplex.hu" # MUST stay "false": this is the GLOBAL signup gate, and "true" blocks # OIDC auto-register too ("Signups are currently disabled by the # administrator"). We want OIDC self-registration to work, so it stays # false; email/password registration is instead blocked by disabling # email login entirely (SPARKY_FITNESS_DISABLE_EMAIL_LOGIN below). # Who may actually register is then governed by Authentik (only users # authorized for the SparkyFitness application can complete OIDC). - name: SPARKY_FITNESS_DISABLE_SIGNUP value: "false" - name: SPARKY_FITNESS_ADMIN_EMAIL value: "nagyfenyvesi.viktor@gmail.com" - name: ALLOW_PRIVATE_NETWORK_CORS value: "true" - name: SPARKY_FITNESS_EXTRA_TRUSTED_ORIGINS value: "https://workout.home" # OIDC login confirmed working (admin bootstrapped via Authentik 2026-05-27), # so the email-login fail-safe is removed and email/password login + # registration are disabled — Authentik (OIDC) is the only auth/signup path. # EMERGENCY RECOVERY if ever locked out of OIDC: set # SPARKY_FITNESS_FORCE_EMAIL_LOGIN=true on the deployment and restart to # re-enable email/password login (it overrides DISABLE_EMAIL_LOGIN). - name: SPARKY_FITNESS_DISABLE_EMAIL_LOGIN value: "true" # ---- OIDC (Authentik) — env-based provider upsert ---- - name: SPARKY_FITNESS_OIDC_AUTH_ENABLED value: "true" # NOTE: Authentik application slug is `sparky-fitness` (with hyphen) — # the issuer path must match it exactly, or discovery 404s. - name: SPARKY_FITNESS_OIDC_ISSUER_URL value: "https://authentik.dooplex.hu/application/o/sparky-fitness/" - name: SPARKY_FITNESS_OIDC_CLIENT_ID valueFrom: secretKeyRef: name: sparky-oauth key: client-id - name: SPARKY_FITNESS_OIDC_CLIENT_SECRET valueFrom: secretKeyRef: name: sparky-oauth key: client-secret - name: SPARKY_FITNESS_OIDC_PROVIDER_SLUG value: "authentik" - name: SPARKY_FITNESS_OIDC_PROVIDER_NAME value: "Authentik" - name: SPARKY_FITNESS_OIDC_SCOPE value: "openid profile email" - name: SPARKY_FITNESS_OIDC_AUTO_REGISTER value: "true" ports: - containerPort: 3010 name: http resources: requests: cpu: 200m memory: 256Mi limits: cpu: 1000m memory: 1Gi volumeMounts: - name: uploads mountPath: /app/SparkyFitnessServer/uploads # First boot runs DB migrations; startupProbe gives it room before # liveness/readiness kick in. startupProbe: httpGet: path: /api/health port: http periodSeconds: 10 failureThreshold: 30 livenessProbe: httpGet: path: /api/health port: http periodSeconds: 10 timeoutSeconds: 10 failureThreshold: 5 readinessProbe: httpGet: path: /api/health port: http periodSeconds: 5 timeoutSeconds: 10 volumes: - name: uploads persistentVolumeClaim: claimName: sparkyfitness-uploads --- # ---------------------------------------------------------------------------- # Frontend: nginx serving the SPA. Its internal nginx proxies /api, /uploads, # etc. to the server, so the ingress only needs to target this one Service. # Root image listens on :80 (listen 80; hardcoded) — NGINX_LISTEN_PORT pinned. # ---------------------------------------------------------------------------- apiVersion: apps/v1 kind: Deployment metadata: name: sparkyfitness-frontend namespace: workout-system labels: app.kubernetes.io/instance: sparkyfitness app.kubernetes.io/name: frontend spec: replicas: 1 strategy: type: Recreate selector: matchLabels: app.kubernetes.io/instance: sparkyfitness app.kubernetes.io/name: frontend template: metadata: labels: app.kubernetes.io/instance: sparkyfitness app.kubernetes.io/name: frontend annotations: match-regex.version-checker.io/frontend: '^v\d+\.\d+\.\d+\.\d+$' spec: enableServiceLinks: false containers: - name: frontend image: codewithcj/sparkyfitness:v0.16.6.3 env: - name: SPARKY_FITNESS_SERVER_HOST value: "sparkyfitness-server" - name: SPARKY_FITNESS_SERVER_PORT value: "3010" - name: SPARKY_FITNESS_FRONTEND_URL value: "https://workout.dooplex.hu" - name: NGINX_LISTEN_PORT value: "80" - name: TZ value: "Europe/Budapest" ports: - containerPort: 80 name: http resources: requests: cpu: 50m memory: 64Mi limits: cpu: 500m memory: 256Mi livenessProbe: httpGet: path: / port: http initialDelaySeconds: 10 periodSeconds: 10 timeoutSeconds: 10 failureThreshold: 5 readinessProbe: httpGet: path: / port: http initialDelaySeconds: 5 periodSeconds: 5 timeoutSeconds: 10 --- apiVersion: v1 kind: Service metadata: name: sparkyfitness-db namespace: workout-system labels: app.kubernetes.io/instance: sparkyfitness app.kubernetes.io/name: postgres spec: type: ClusterIP ports: - name: postgres port: 5432 targetPort: postgres selector: app.kubernetes.io/instance: sparkyfitness app.kubernetes.io/name: postgres --- apiVersion: v1 kind: Service metadata: name: sparkyfitness-server namespace: workout-system labels: app.kubernetes.io/instance: sparkyfitness app.kubernetes.io/name: server spec: type: ClusterIP ports: - name: http port: 3010 targetPort: http selector: app.kubernetes.io/instance: sparkyfitness app.kubernetes.io/name: server --- apiVersion: v1 kind: Service metadata: name: sparkyfitness-frontend namespace: workout-system labels: app.kubernetes.io/instance: sparkyfitness app.kubernetes.io/name: frontend spec: type: ClusterIP ports: - name: http port: 80 targetPort: http selector: app.kubernetes.io/instance: sparkyfitness app.kubernetes.io/name: frontend --- apiVersion: v1 kind: PersistentVolumeClaim metadata: name: sparkyfitness-postgres namespace: workout-system labels: app.kubernetes.io/instance: sparkyfitness app.kubernetes.io/name: postgres recurring-job-group.longhorn.io/needbackup: enabled recurring-job.longhorn.io/source: enabled spec: accessModes: - ReadWriteOnce storageClassName: longhorn resources: requests: storage: 5Gi --- apiVersion: v1 kind: PersistentVolumeClaim metadata: name: sparkyfitness-uploads namespace: workout-system labels: app.kubernetes.io/instance: sparkyfitness app.kubernetes.io/name: server recurring-job-group.longhorn.io/needbackup: enabled recurring-job.longhorn.io/source: enabled spec: accessModes: - ReadWriteOnce storageClassName: longhorn resources: requests: storage: 5Gi --- apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: sparkyfitness namespace: workout-system labels: app.kubernetes.io/instance: sparkyfitness app.kubernetes.io/name: frontend annotations: cert-manager.io/cluster-issuer: letsencrypt-prod external-dns.alpha.kubernetes.io/hostname: workout.dooplex.hu,workout.home nginx.ingress.kubernetes.io/ssl-redirect: "true" nginx.ingress.kubernetes.io/proxy-body-size: "50m" # Auth is handled IN-APP (native OIDC) — no forward-auth annotations here. # Hungary-only geo-block (same pattern as the rest of the DooPlex apps). nginx.ingress.kubernetes.io/configuration-snippet: | set $geo_allowed 0; if ($remote_addr ~ "^192\.168\.") { set $geo_allowed 1; } if ($remote_addr ~ "^10\.") { set $geo_allowed 1; } if ($geoip2_country_code = "HU") { set $geo_allowed 1; } if ($geo_allowed = 0) { return 403 "Access restricted to Hungary"; } spec: ingressClassName: nginx-internal rules: - host: workout.dooplex.hu http: paths: - path: / pathType: Prefix backend: service: name: sparkyfitness-frontend port: number: 80 - host: workout.home http: paths: - path: / pathType: Prefix backend: service: name: sparkyfitness-frontend port: number: 80 tls: - hosts: - workout.dooplex.hu secretName: sparkyfitness-tls