GitHub Dashboard →

Deployment — Kubernetes

Production deployment with K3s, Helm, and ArgoCD (GitOps). Covers bootstrap, secret provisioning, and day-2 operations.

Prerequisites

ToolVersionNotes
K3sv1.29+Installed by bootstrap script
Helm3.14+curl https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash
kubectlmatching K3sBundled with K3s
HashiCorp Vault1.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:

Shell
bash infra/k8s/bootstrap/install.sh

The script installs 10 components in order:

StepComponentPurpose
1K3sKubernetes distribution (bundled Traefik disabled)
2Traefik v3Ingress controller + automatic TLS routing
3cert-managerLet's Encrypt certificate provisioning
4CloudNativePGManaged PostgreSQL operator with HA + PITR backup
5External Secrets OperatorVault → Kubernetes Secret sync
6KEDAEvent-driven autoscaling (NATS JetStream scaler)
7kube-prometheus-stackPrometheus + Grafana + Alertmanager
8Loki + PromtailLog aggregation (third observability pillar)
9ArgoCDGitOps continuous delivery
10ArgoCD Image UpdaterAuto-tag commits on new semver releases

Step 2 — Configure DNS

DNS records
api.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.yaml
apiVersion: 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

Shell
vault 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

ResourceDescription
Namespace visionpushIsolated namespace for all workloads
Deployment/visionpush-apiAPI server, 2 replicas by default
Deployment/visionpush-workerPush worker, 2 replicas by default
Deployment/visionpush-adminAdmin panel (nginx), 1 replica
Deployment/visionpush-webseiteLanding page (nginx)
Service (×4)ClusterIP for each deployment
IngressTraefik ingress with TLS for all three hosts
Cluster (CloudNativePG)Managed PostgreSQL cluster with WAL-G backup
ExternalSecretPulls Vault secrets into a Kubernetes Secret
ServiceAccount (×3)api, worker, admin — no token automount
NetworkPolicy (×5)Zero-trust pod-to-pod communication
HorizontalPodAutoscalerCPU-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-migrationsPre-install/pre-upgrade migration hook
ServiceMonitor + PrometheusRule14 alert rules (if serviceMonitor.enabled)
AlertmanagerConfigEmail + Slack routing (if alerting.enabled)
ConfigMap (Grafana dashboard)Auto-provisioned via sidecar label
PersistentVolumeClaim20 Gi analytics Parquet volume

GitOps flow

Flow
git 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.yaml
api: hpa: minReplicas: 2 maxReplicas: 10 targetCPUUtilizationPercentage: 70

Worker — KEDA NATS lag-based

values.yaml
keda: 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.yaml
ingress: 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 tag
git tag v1.2.3 && git push origin v1.2.3 # ArgoCD Image Updater commits the new tag automatically
Shell — manual rollback
helm 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

Shell
kubectl 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.