Files
homelab-manifests/workout-system/sparkyfitness.yaml
T
admin f09e76a4b3 workout-system: fix SparkyFitness OIDC issuer slug (sparkyfitness -> sparky-fitness)
The Authentik application was created with slug 'sparky-fitness' (hyphen), so the
OIDC discovery document lives at /application/o/sparky-fitness/.well-known/...
The previous value (no hyphen) 404'd. Align the issuer URL with the actual slug.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 15:37:05 +02:00

499 lines
16 KiB
YAML

# 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: 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"
- name: SPARKY_FITNESS_DISABLE_SIGNUP
value: "true"
- 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"
# ---- Fail-safe: keep email/password login working during bring-up
# so a misconfigured OIDC can't lock you out. REMOVE this (and
# optionally set SPARKY_FITNESS_DISABLE_EMAIL_LOGIN=true) once the
# Authentik OIDC login is confirmed working end-to-end.
- name: SPARKY_FITNESS_FORCE_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