Deployment
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.
Encryption key
The ENCRYPTION_KEY secret is required for encrypting AI provider API keys at rest (AES-256-GCM). Add it to the daemon secret or as a separate secret:
stringData:
ENCRYPTION_KEY: your-32-byte-hex-key
Generate with openssl rand -hex 32. Without this key, the AI provider settings page will fail to save API keys.
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
CI pipeline
The GitLab CI pipeline runs 4 stages on every push to main:
| Stage | What it does |
|---|---|
| lint | ESLint and TypeScript type checking |
| test | Unit and integration tests |
| build | Docker image build and push to registry |
| migrate | Runs prisma migrate deploy against the production database |
The migrate stage runs automatically after a successful build. Manual prisma db push is no longer needed for routine deployments.
Database migrations
Migrations are applied automatically by the CI pipeline's migrate stage using prisma migrate deploy. For manual intervention (e.g. debugging or emergency fixes):
# Port-forward to database
kubectl port-forward -n stats svc/stats-db 5432:5432
# Run pending migrations
DATABASE_URL="postgresql://stats:password@localhost:5432/stats" npx prisma migrate deploy
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.