Deployment — Kubernetes
Prerequisites
| Tool | Version | Notes |
|---|---|---|
| K3s | v1.29+ | Installed by bootstrap script |
| Helm | 3.14+ | curl https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash |
| kubectl | matching K3s | Bundled with K3s |
| HashiCorp Vault | 1.16+ | Your own instance or HCP Vault |
A domain name with DNS A record pointing to your server IP is required for TLS.
Step 1 — Bootstrap the cluster
Run once on a fresh Debian/Ubuntu/RHEL server:
Shellbash infra/k8s/bootstrap/install.sh
The script installs 10 components in order:
| Step | Component | Purpose |
|---|---|---|
| 1 | K3s | Kubernetes distribution (bundled Traefik disabled) |
| 2 | Traefik v3 | Ingress controller + automatic TLS routing |
| 3 | cert-manager | Let's Encrypt certificate provisioning |
| 4 | CloudNativePG | Managed PostgreSQL operator with HA + PITR backup |
| 5 | External Secrets Operator | Vault → Kubernetes Secret sync |
| 6 | KEDA | Event-driven autoscaling (NATS JetStream scaler) |
| 7 | kube-prometheus-stack | Prometheus + Grafana + Alertmanager |
| 8 | Loki + Promtail | Log aggregation (third observability pillar) |
| 9 | ArgoCD | GitOps continuous delivery |
| 10 | ArgoCD Image Updater | Auto-tag commits on new semver releases |
Step 2 — Configure DNS
DNS recordsapi.yourdomain.com A <server-ip> app.yourdomain.com A <server-ip> yourdomain.com A <server-ip>
Step 3 — TLS cluster issuer
Shell# Edit email in the file first, then: kubectl apply -f infra/k8s/bootstrap/cluster-issuer.yaml
cluster-issuer.yamlapiVersion: cert-manager.io/v1 kind: ClusterIssuer metadata: name: letsencrypt-prod spec: acme: server: https://acme-v02.api.letsencrypt.org/directory email: ops@yourdomain.com privateKeySecretRef: name: letsencrypt-prod-account-key solvers: - http01: ingress: class: traefik
Step 4 — Vault secret store
Shell# Edit Vault address and token, then: kubectl apply -f infra/k8s/bootstrap/vault-secret-store.yaml
Step 5 — Write secrets to Vault
Shellvault kv put secret/visionpush/production \ DATABASE_URL="postgresql://visionpush:PASS@visionpush-postgres-rw:5432/visionpush" \ JWT_SECRET="$(openssl rand -base64 32)" \ CREDENTIAL_ENCRYPTION_KEY="$(openssl rand -base64 32)" \ AWS_ACCESS_KEY_ID="..." \ AWS_SECRET_ACCESS_KEY="..." \ S3_BUCKET="visionpush-media" \ DB_PASSWORD="$(openssl rand -base64 24)"
Step 6 — Deploy via ArgoCD
Shell# Edit ingress hosts in values.yaml first, then: kubectl apply -f infra/k8s/apps/visionpush.yaml
ArgoCD's Application resource syncs the Helm chart on every git push (prune + self-heal enabled) and updates image tags via ArgoCD Image Updater on new semver releases.
Resources deployed
| Resource | Description |
|---|---|
| Namespace visionpush | Isolated namespace for all workloads |
| Deployment/visionpush-api | API server, 2 replicas by default |
| Deployment/visionpush-worker | Push worker, 2 replicas by default |
| Deployment/visionpush-admin | Admin panel (nginx), 1 replica |
| Deployment/visionpush-webseite | Landing page (nginx) |
| Service (×4) | ClusterIP for each deployment |
| Ingress | Traefik ingress with TLS for all three hosts |
| Cluster (CloudNativePG) | Managed PostgreSQL cluster with WAL-G backup |
| ExternalSecret | Pulls Vault secrets into a Kubernetes Secret |
| ServiceAccount (×3) | api, worker, admin — no token automount |
| NetworkPolicy (×5) | Zero-trust pod-to-pod communication |
| HorizontalPodAutoscaler | CPU-based HPA for API (and worker if KEDA disabled) |
| ScaledObject (KEDA) | NATS consumer-lag scaler for worker |
| PodDisruptionBudget (×2) | minAvailable: 1 for api and worker |
| Job/visionpush-migrations | Pre-install/pre-upgrade migration hook |
| ServiceMonitor + PrometheusRule | 14 alert rules (if serviceMonitor.enabled) |
| AlertmanagerConfig | Email + Slack routing (if alerting.enabled) |
| ConfigMap (Grafana dashboard) | Auto-provisioned via sidecar label |
| PersistentVolumeClaim | 20 Gi analytics Parquet volume |
GitOps flow
Flowgit push origin main → GitHub Actions CI (test + build + push images to GHCR) → CD workflow commits updated image.tag to values.yaml [skip ci] → ArgoCD detects git change → syncs Helm chart → rolling update git tag v1.2.3 → ArgoCD Image Updater detects new semver tag in GHCR → commits image.tag=v1.2.3 to values.yaml → ArgoCD syncs
Scaling
API — CPU-based HPA
values.yamlapi: hpa: minReplicas: 2 maxReplicas: 10 targetCPUUtilizationPercentage: 70
Worker — KEDA NATS lag-based
values.yamlkeda: enabled: true worker: minReplicas: 2 maxReplicas: 30 lagThreshold: "100" # 1 replica per 100 pending messages
One worker replica handles ~100 pending NATS messages before a new one is triggered. During a large campaign burst the fleet scales within seconds and scales back after the queue drains.
Production values override
values.prod.yamlingress: apiHost: api.yourdomain.com adminHost: app.yourdomain.com webseiteHost: yourdomain.com postgres: instances: 2 backup: s3Path: "s3://your-bucket/visionpush/" serviceMonitor: enabled: true keda: enabled: true alerting: enabled: true email: to: ops@yourdomain.com from: alerts@yourdomain.com smarthost: smtp.resend.com:587 slack: enabled: true channel: "#visionpush-alerts"
Upgrades and rollbacks
Shell — upgrade via git taggit tag v1.2.3 && git push origin v1.2.3 # ArgoCD Image Updater commits the new tag automatically
Shell — manual rollbackhelm history visionpush -n visionpush helm rollback visionpush -n visionpush # previous release helm rollback visionpush 3 -n visionpush # specific revision
Rollback re-runs the migration Job with the previous image.
sqlx migrate is idempotent — already-applied migrations are skipped.Uninstalling
Shellkubectl delete -f infra/k8s/apps/visionpush.yaml # remove ArgoCD app helm uninstall visionpush -n visionpush # remove chart kubectl delete namespace visionpush # remove namespace + PVCs
Deleting the namespace removes all PVCs including analytics Parquet data. Back up before uninstalling.