Deployment
info
This page covers deploying Flow Control to Kubernetes with Flux GitOps.
Flow Control series
- Flow Control
- Architecture
- Cursor sync daemon
- Music sync daemon
- Health sync daemon (disabled)
- HealthSync iOS app
- Deployment - You are here
Prerequisites
- Kubernetes cluster with Flux installed
- PostgreSQL (dedicated instance or shared)
- OIDC provider (Zitadel, Keycloak, etc.) for authentication
- GitLab with API token for pipeline tracking
- Container registry for images
Kubernetes structure
k8s/prod/
├── 20-secret-db.enc.yaml # PostgreSQL credentials (SOPS)
├── 35-pvc-db.yaml # Database PVC
├── 40-db-deployment.yaml # PostgreSQL deployment
├── deployment.yaml # App deployment
├── service.yaml # ClusterIP service
├── ingress.yaml # Ingress
├── cronjob.yaml # GitLab/commits import
├── cronjob-k8s.yaml # K8s deployments import
├── kustomization.yaml # Kustomize base
└── secrets/
├── stats-secrets.enc.yaml # GitLab token
├── stats-auth-secret.enc.yaml # OIDC credentials
├── stats-daemon-secret.enc.yaml # Daemon API key
└── stats-prod-registry.enc.yaml # Image pull secret
Database setup
1. Create namespace
# namespace.yaml
apiVersion: v1
kind: Namespace
metadata:
name: stats
2. Database credentials (SOPS encrypted)
# 20-secret-db.enc.yaml
apiVersion: v1
kind: Secret
metadata:
name: stats-db-secret
namespace: stats
type: Opaque
stringData:
POSTGRES_USER: stats
POSTGRES_PASSWORD: your-secure-password
POSTGRES_DB: stats
DATABASE_URL: postgresql://stats:your-secure-password@stats-db:5432/stats
Encrypt with SOPS:
sops --encrypt --in-place 20-secret-db.enc.yaml
3. Database PVC
# 35-pvc-db.yaml
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: stats-db-pvc
namespace: stats
spec:
accessModes:
- ReadWriteOnce
storageClassName: nfs-client
resources:
requests:
storage: 5Gi
4. Database deployment
# 40-db-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: stats-db
namespace: stats
spec:
replicas: 1
selector:
matchLabels:
app: stats-db
template:
metadata:
labels:
app: stats-db
spec:
containers:
- name: postgres
image: postgres:16-alpine
ports:
- containerPort: 5432
envFrom:
- secretRef:
name: stats-db-secret
volumeMounts:
- name: data
mountPath: /var/lib/postgresql/data
livenessProbe:
exec:
command: ["pg_isready", "-U", "stats", "-d", "stats"]
initialDelaySeconds: 30
periodSeconds: 10
timeoutSeconds: 5
readinessProbe:
exec:
command: ["pg_isready", "-U", "stats", "-d", "stats"]
initialDelaySeconds: 5
periodSeconds: 5
timeoutSeconds: 5
volumes:
- name: data
persistentVolumeClaim:
claimName: stats-db-pvc
---
apiVersion: v1
kind: Service
metadata:
name: stats-db
namespace: stats
spec:
selector:
app: stats-db
ports:
- port: 5432
Application deployment
Deployment manifest
# deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: stats-dashboard
namespace: stats
spec:
replicas: 1
selector:
matchLabels:
app: stats-dashboard
template:
metadata:
labels:
app: stats-dashboard
spec:
# hostAliases for OIDC provider DNS resolution (if needed)
hostAliases:
- ip: "10.50.1.5" # Your OIDC provider IP
hostnames:
- "auth.example.com"
containers:
- name: app
image: registry.example.com/tools/stats-dashboard:prod-latest # {"$imagepolicy": "flux-system:stats-prod-policy"}
ports:
- containerPort: 3000
envFrom:
- secretRef:
name: stats-db-secret
- secretRef:
name: stats-secrets
- secretRef:
name: stats-auth-secret
- secretRef:
name: stats-daemon-secret
env:
- name: GITLAB_API_URL
value: "http://gitlab-webservice-default.gitlab.svc.cluster.local:8080/api/v4"
- name: AUTH_URL
value: "https://your-stats.example.com"
- name: AUTH_TRUST_HOST
value: "true"
- name: AUTH_ISSUER
value: "https://auth.example.com"
readinessProbe:
httpGet:
path: /api/health
port: 3000
initialDelaySeconds: 10
periodSeconds: 5
livenessProbe:
httpGet:
path: /api/health
port: 3000
initialDelaySeconds: 30
periodSeconds: 10
imagePullSecrets:
- name: stats-prod-registry
Service and Ingress
# service.yaml
apiVersion: v1
kind: Service
metadata:
name: stats-dashboard
namespace: stats
spec:
selector:
app: stats-dashboard
ports:
- port: 3000
---
# ingress.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: stats-dashboard
namespace: stats
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
spec:
ingressClassName: nginx
tls:
- hosts:
- your-stats.example.com
secretName: stats-dashboard-tls
rules:
- host: your-stats.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: stats-dashboard
port:
number: 3000
CronJobs
GitLab and commits import
# cronjob.yaml
apiVersion: batch/v1
kind: CronJob
metadata:
name: stats-import-gitlab
namespace: stats
spec:
schedule: "*/15 * * * *" # Every 15 minutes
jobTemplate:
spec:
template:
spec:
containers:
- name: import
image: curlimages/curl:latest
command:
- /bin/sh
- -c
- |
curl -X POST http://stats-dashboard:3000/api/import/gitlab
curl -X POST http://stats-dashboard:3000/api/import/commits
restartPolicy: OnFailure
K8s deployments import
# cronjob-k8s.yaml
apiVersion: batch/v1
kind: CronJob
metadata:
name: stats-import-k8s
namespace: stats
spec:
schedule: "*/30 * * * *" # Every 30 minutes
jobTemplate:
spec:
template:
spec:
serviceAccountName: stats-k8s-reader
containers:
- name: import
image: bitnami/kubectl:latest
command:
- /bin/sh
- -c
- |
# Gather deployment data and POST to dashboard
kubectl get deployments --all-namespaces -o json | \
curl -X POST -H "Content-Type: application/json" \
-d @- http://stats-dashboard:3000/api/import/k8s
restartPolicy: OnFailure
Secrets
GitLab token
# secrets/stats-secrets.enc.yaml
apiVersion: v1
kind: Secret
metadata:
name: stats-secrets
namespace: stats
type: Opaque
stringData:
GITLAB_TOKEN: glpat-xxxxxxxxxxxxxxxxxxxx
OIDC credentials
# secrets/stats-auth-secret.enc.yaml
apiVersion: v1
kind: Secret
metadata:
name: stats-auth-secret
namespace: stats
type: Opaque
stringData:
AUTH_SECRET: your-32-char-auth-secret
AUTH_CLIENT_ID: your-oidc-client-id
AUTH_CLIENT_SECRET: your-oidc-client-secret
Daemon API key
# secrets/stats-daemon-secret.enc.yaml
apiVersion: v1
kind: Secret
metadata:
name: stats-daemon-secret
namespace: stats
type: Opaque
stringData:
DAEMON_API_KEY: your-secure-api-key
This key is shared by all three local daemons (cursor-sync, music-sync, health-sync) and the HealthSync iOS app.
Key rotation procedure:
- Generate new key:
openssl rand -base64 32 - Decrypt SOPS:
sops -d -i k8s/prod/secrets/stats-daemon-secret.enc.yaml - Replace the key value
- Re-encrypt:
sops -e -i k8s/prod/secrets/stats-daemon-secret.enc.yaml - Commit, push, wait for pipeline, flux reconcile
- Update all installed daemon plists in
~/Library/LaunchAgents/, reload each withlaunchctl - Update the API key in HealthSync iOS app Settings
Flux GitOps
Image automation
# flux-system/stats-prod-gitrepository.yaml
apiVersion: source.toolkit.fluxcd.io/v1
kind: GitRepository
metadata:
name: stats-prod
namespace: flux-system
spec:
interval: 1m
url: https://gitlab.example.com/tools/stats-dashboard.git
ref:
branch: main
---
# flux-system/stats-prod-imagerepository.yaml
apiVersion: image.toolkit.fluxcd.io/v1beta2
kind: ImageRepository
metadata:
name: stats-prod-repo
namespace: flux-system
spec:
interval: 1m
image: registry.example.com/tools/stats-dashboard
---
# flux-system/stats-prod-imagepolicy.yaml
apiVersion: image.toolkit.fluxcd.io/v1beta2
kind: ImagePolicy
metadata:
name: stats-prod-policy
namespace: flux-system
spec:
imageRepositoryRef:
name: stats-prod-repo
filterTags:
pattern: '^prod-(?P<ts>\d+\.\d+)$'
extract: '$ts'
policy:
numerical:
order: asc
---
# flux-system/stats-prod-automation.yaml
apiVersion: image.toolkit.fluxcd.io/v1beta2
kind: ImageUpdateAutomation
metadata:
name: stats-prod-automation
namespace: flux-system
spec:
interval: 1m
sourceRef:
kind: GitRepository
name: stats-prod
git:
checkout:
ref:
branch: main
commit:
author:
email: flux@example.com
name: FluxCD
messageTemplate: '{{range .Changed.Changes}}{{print .OldValue}} -> {{println .NewValue}}{{end}} [skip ci]'
push:
branch: main
update:
path: ./k8s/prod
strategy: Setters
Database migrations
After deployment, run Prisma migrations:
# Port-forward to database
kubectl port-forward -n stats svc/stats-db 5432:5432
# Run migrations
DATABASE_URL="postgresql://stats:password@localhost:5432/stats" npx prisma db push
Monitoring
Health endpoint
The /api/health endpoint is public (excluded from auth) for K8s probes:
curl https://your-stats.example.com/api/health
# Returns: {"status":"ok"}
Uptime monitoring
Configure external monitoring (e.g., Uptime Kuma) to check /api/health every 60 seconds.
Troubleshooting
Pod not starting
kubectl logs -n stats deployment/stats-dashboard
kubectl describe pod -n stats -l app=stats-dashboard
Database connection failed
# Check database pod
kubectl logs -n stats deployment/stats-db
# Test connectivity
kubectl exec -n stats deploy/stats-dashboard -- \
node -e "const { Pool } = require('pg'); new Pool().query('SELECT 1').then(() => console.log('OK'))"
OIDC authentication errors
Check for DNS resolution issues:
kubectl exec -n stats deploy/stats-dashboard -- \
node -e "fetch('https://auth.example.com/.well-known/openid-configuration').then(r => r.json()).then(console.log)"
If DNS fails, add hostAliases to the deployment spec.
Flux not updating images
# Check image repository
flux get image repository stats-prod-repo -n flux-system
# Check image policy
kubectl get imagepolicy stats-prod-policy -n flux-system -o yaml
# Manual reconcile
flux reconcile image repository stats-prod-repo -n flux-system
flux reconcile image update stats-prod-automation -n flux-system
This completes the Flow Control series. For questions or issues, check the troubleshooting sections in each page.