Skip to main content

Deployment

info

This page covers deploying Flow Control to Kubernetes with Flux GitOps.

Flow Control series

  1. Flow Control
  2. Architecture
  3. Cursor sync daemon
  4. Music sync daemon
  5. Health sync daemon (disabled)
  6. HealthSync iOS app
  7. 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:

  1. Generate new key: openssl rand -base64 32
  2. Decrypt SOPS: sops -d -i k8s/prod/secrets/stats-daemon-secret.enc.yaml
  3. Replace the key value
  4. Re-encrypt: sops -e -i k8s/prod/secrets/stats-daemon-secret.enc.yaml
  5. Commit, push, wait for pipeline, flux reconcile
  6. Update all installed daemon plists in ~/Library/LaunchAgents/, reload each with launchctl
  7. 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.