Deploy: Kubernetes (Helm)

This guide covers deploying Llamenos to a Kubernetes cluster using the official Helm chart. The chart manages the application, MinIO storage, strfry Nostr relay, and optional signal-notifier/sip-bridge services as separate deployments. You provide a PostgreSQL database.

Prerequisites

  • A Kubernetes cluster (v1.24+) — managed (EKS, GKE, AKS) or self-hosted
  • A PostgreSQL 14+ instance (managed RDS/Cloud SQL recommended, or self-hosted)
  • Helm v3.10+
  • kubectl configured for your cluster
  • An ingress controller (NGINX Ingress, Traefik, etc.)
  • cert-manager (optional, for automatic TLS certificates)

1. Install the chart

helm install llamenos deploy/helm/llamenos/ \
  --set secrets.postgresPassword=YOUR_PG_PASSWORD \
  --set secrets.hmacSecret=YOUR_HMAC_HEX \
  --set secrets.serverNostrSecret=YOUR_NOSTR_HEX \
  --set postgres.host=YOUR_PG_HOST \
  --set minio.credentials.accessKey=your-access-key \
  --set minio.credentials.secretKey=your-secret-key \
  --set ingress.hosts[0].host=hotline.yourdomain.com \
  --set ingress.tls[0].secretName=llamenos-tls \
  --set ingress.tls[0].hosts[0]=hotline.yourdomain.com

Or create a values-production.yaml file for reproducible deploys:

# values-production.yaml
app:
  image:
    repository: ghcr.io/rhonda-rodododo/llamenos-platform
    tag: "1.0.0"
    pullPolicy: IfNotPresent
  replicas: 2
  resources:
    requests:
      cpu: "500m"
      memory: "512Mi"
    limits:
      cpu: "2"
      memory: "1Gi"
  env:
    HOTLINE_NAME: "Your Hotline"
    NODE_ENV: "production"

postgres:
  host: my-rds-instance.region.rds.amazonaws.com
  port: 5432
  database: llamenos
  user: llamenos
  poolSize: 10

secrets:
  postgresPassword: "your-strong-password"
  hmacSecret: "64-hex-chars-hmac-signing-key"
  serverNostrSecret: "64-hex-chars-nostr-identity-key"
  # Telephony (at least one required for voice):
  # twilioAccountSid: ""
  # twilioAuthToken: ""
  # twilioPhoneNumber: ""

minio:
  enabled: true
  persistence:
    size: 50Gi
    storageClass: "gp3"
  credentials:
    accessKey: "your-access-key"
    secretKey: "your-secret-key-change-me"
  resources:
    requests:
      cpu: "100m"
      memory: "256Mi"
    limits:
      cpu: "500m"
      memory: "512Mi"

strfry:
  enabled: true
  resources:
    requests:
      cpu: "50m"
      memory: "64Mi"
    limits:
      cpu: "200m"
      memory: "128Mi"

signalNotifier:
  enabled: false   # set to true to enable the signal-notifier sidecar

sipBridge:
  enabled: false   # set to true to enable the SIP bridge (Asterisk/FreeSWITCH/Kamailio)
  # pbxType: asterisk

monitoring:
  enabled: true
  serviceMonitor:
    interval: 30s
    scrapeTimeout: 10s

ingress:
  enabled: true
  className: "nginx"
  annotations:
    cert-manager.io/cluster-issuer: "letsencrypt-prod"
    nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
    nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
  hosts:
    - host: hotline.yourdomain.com
      paths:
        - path: /
          pathType: Prefix
  tls:
    - secretName: llamenos-tls
      hosts:
        - hotline.yourdomain.com

Then install:

helm install llamenos deploy/helm/llamenos/ -f values-production.yaml

2. Verify the deployment

# Check pods are running
kubectl get pods -l app.kubernetes.io/instance=llamenos

# Check the app health
kubectl port-forward svc/llamenos 3000:3000
curl http://localhost:3000/health/ready
# -> {"status":"ok"}

3. Configure DNS

Point your domain to the ingress controller’s external IP or load balancer:

kubectl get ingress llamenos

4. Initial setup

Open https://hotline.yourdomain.com in your browser and follow the setup wizard:

  1. Create your admin account — set a display name and your PIN
  2. Name your hotline — set the display name shown in the app
  3. Choose channels — enable Voice, SMS, WhatsApp, Signal, and/or Reports
  4. Configure providers — enter credentials for each enabled channel
  5. Review and finish

cert-manager integration

If you have cert-manager installed, configure the cluster issuer for automatic TLS:

# 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: admin@yourdomain.com
    privateKeySecretRef:
      name: letsencrypt-prod
    solvers:
      - http01:
          ingress:
            class: nginx

Apply it, then reference it in your ingress annotations (already included in the values-production.yaml above):

ingress:
  annotations:
    cert-manager.io/cluster-issuer: "letsencrypt-prod"

cert-manager will automatically provision and renew TLS certificates via Let’s Encrypt.

External Secrets Operator

For production, avoid putting secrets directly in Helm values. Use External Secrets Operator to sync secrets from your secret store (AWS SSM, Vault, GCP Secret Manager, etc.).

1. Create an ExternalSecret

# llamenos-externalsecret.yaml
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
  name: llamenos-secrets
  namespace: llamenos
spec:
  refreshInterval: 1h
  secretStoreRef:
    name: my-secret-store   # your ClusterSecretStore or SecretStore
    kind: ClusterSecretStore
  target:
    name: llamenos-secrets
    creationPolicy: Owner
  data:
    - secretKey: postgres-password
      remoteRef:
        key: llamenos/postgres-password
    - secretKey: hmac-secret
      remoteRef:
        key: llamenos/hmac-secret
    - secretKey: server-nostr-secret
      remoteRef:
        key: llamenos/server-nostr-secret
    - secretKey: minio-access-key
      remoteRef:
        key: llamenos/minio-access-key
    - secretKey: minio-secret-key
      remoteRef:
        key: llamenos/minio-secret-key

2. Reference in Helm values

secrets:
  existingSecret: llamenos-secrets

Alternatively, create the secret manually and reference it the same way:

kubectl create secret generic llamenos-secrets \
  --from-literal=postgres-password=your_password \
  --from-literal=hmac-secret=your_hmac_hex \
  --from-literal=server-nostr-secret=your_nostr_hex \
  --from-literal=minio-access-key=your_key \
  --from-literal=minio-secret-key=your_secret

Prometheus monitoring

ServiceMonitor

If you run the Prometheus Operator, enable the ServiceMonitor in your values:

monitoring:
  enabled: true
  serviceMonitor:
    namespace: monitoring    # namespace where Prometheus is installed
    interval: 30s
    scrapeTimeout: 10s
    labels:
      release: kube-prometheus-stack

The chart exposes /metrics on the app service and configures the ServiceMonitor to match your Prometheus selector.

Health probes

The chart configures liveness, readiness, and startup probes against /health/live and /health/ready:

livenessProbe:
  httpGet:
    path: /health/live
    port: http
  initialDelaySeconds: 15
  periodSeconds: 15
readinessProbe:
  httpGet:
    path: /health/ready
    port: http
  initialDelaySeconds: 10
  periodSeconds: 10
startupProbe:
  httpGet:
    path: /health/ready
    port: http
  failureThreshold: 30
  periodSeconds: 5

Logs

kubectl logs -l app.kubernetes.io/instance=llamenos -c app -f

Chart configuration reference

Application

ParameterDescriptionDefault
app.image.repositoryContainer imageghcr.io/rhonda-rodododo/llamenos-platform
app.image.tagImage tagChart appVersion
app.image.pullPolicyPull policyIfNotPresent
app.portApplication port3000
app.replicasPod replicas2
app.resourcesCPU/memory requests and limits{}
app.envExtra environment variables{}

PostgreSQL

ParameterDescriptionDefault
postgres.hostPostgreSQL hostname (required)""
postgres.portPostgreSQL port5432
postgres.databaseDatabase namellamenos
postgres.userDatabase userllamenos
postgres.poolSizeConnection pool size10

Secrets

ParameterDescriptionDefault
secrets.postgresPasswordPostgreSQL password (required)""
secrets.hmacSecretHMAC signing key — 64 hex chars (required)""
secrets.serverNostrSecretServer Nostr identity key — 64 hex chars (required)""
secrets.twilioAccountSidTwilio Account SID""
secrets.twilioAuthTokenTwilio Auth Token""
secrets.twilioPhoneNumberTwilio phone number (E.164)""
secrets.existingSecretUse an existing Kubernetes Secret""

Tip: For production, use secrets.existingSecret with External Secrets Operator, Sealed Secrets, or Vault.

MinIO

ParameterDescriptionDefault
minio.enabledDeploy MinIOtrue
minio.image.repositoryMinIO imageminio/minio
minio.image.tagMinIO taglatest
minio.persistence.sizeData volume size50Gi
minio.persistence.storageClassStorage class""
minio.credentials.accessKeyMinIO root user (required)""
minio.credentials.secretKeyMinIO root password (required)""
minio.resourcesCPU/memory requests and limits{}

strfry (Nostr relay)

ParameterDescriptionDefault
strfry.enabledDeploy strfrytrue
strfry.image.repositorystrfry imagedockurr/strfry
strfry.image.tagstrfry taglatest
strfry.resourcesCPU/memory requests and limits{}

strfry is a core service — real-time events (calls, notifications, hub state) require it. Keep strfry.enabled: true.

signal-notifier

ParameterDescriptionDefault
signalNotifier.enabledDeploy signal-notifier sidecarfalse
signalNotifier.image.repositorysignal-notifier imageghcr.io/rhonda-rodododo/llamenos-signal-notifier
signalNotifier.resourcesCPU/memory requests and limits{}

SIP bridge

ParameterDescriptionDefault
sipBridge.enabledDeploy sip-bridgefalse
sipBridge.pbxTypeBackend: asterisk, freeswitch, or kamailioasterisk
sipBridge.resourcesCPU/memory requests and limits{}

Monitoring

ParameterDescriptionDefault
monitoring.enabledCreate ServiceMonitorfalse
monitoring.serviceMonitor.intervalScrape interval30s
monitoring.serviceMonitor.scrapeTimeoutScrape timeout10s
monitoring.serviceMonitor.namespaceNamespace for ServiceMonitorSame as release
monitoring.serviceMonitor.labelsAdditional labels for Prometheus selector{}

Ingress

ParameterDescriptionDefault
ingress.enabledCreate Ingress resourcetrue
ingress.classNameIngress classnginx
ingress.annotationsIngress annotations{}
ingress.hostsHost rulesSee values.yaml
ingress.tlsTLS configuration[]

Service account

ParameterDescriptionDefault
serviceAccount.createCreate a ServiceAccounttrue
serviceAccount.annotationsSA annotations (e.g., IRSA for AWS){}
serviceAccount.nameOverride SA name""

Using an external S3-compatible store

If you already have MinIO, RustFS, or another S3-compatible service, disable the built-in MinIO:

minio:
  enabled: false

app:
  env:
    STORAGE_ENDPOINT: "https://your-storage.example.com"
    STORAGE_ACCESS_KEY: "your-key"
    STORAGE_SECRET_KEY: "your-secret"
    STORAGE_BUCKET: "llamenos"

Production hardening checklist

Before going live:

  • Secrets via ESO or Sealed Secrets — never commit secrets to values files
  • Resource requests and limits set on all deployments
  • PodDisruptionBudget configured (minAvailable: 1) for zero-downtime drains
  • NetworkPolicy restricting ingress to app pod from ingress controller only
  • Read-only root filesystem on app container (securityContext.readOnlyRootFilesystem: true)
  • Non-root user in container (securityContext.runAsNonRoot: true)
  • PostgreSQL TLS enabled (set postgres.sslMode: require in values)
  • MinIO TLS or mTLS between app and MinIO
  • cert-manager ClusterIssuer configured for automatic Let’s Encrypt renewal
  • Prometheus ServiceMonitor enabled and scraping
  • Liveness/readiness probes verified after deploy
  • RBAC — ServiceAccount with minimal permissions
  • Image pull policy set to IfNotPresent (not Always) for predictable deploys
  • Ingress rate limiting annotations set to mitigate abuse

Example NetworkPolicy:

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: llamenos-app
spec:
  podSelector:
    matchLabels:
      app.kubernetes.io/name: llamenos
  policyTypes:
    - Ingress
  ingress:
    - from:
        - namespaceSelector:
            matchLabels:
              kubernetes.io/metadata.name: ingress-nginx
      ports:
        - port: 3000

Scaling

The deployment uses RollingUpdate strategy for zero-downtime upgrades. Scale replicas based on your traffic:

kubectl scale deployment llamenos --replicas=3

Or set app.replicas in your values file. PostgreSQL advisory locks ensure data consistency across replicas.

Upgrading

helm upgrade llamenos deploy/helm/llamenos/ -f values-production.yaml

The RollingUpdate strategy provides zero-downtime upgrades.

Uninstalling

helm uninstall llamenos

Note: PersistentVolumeClaims are not deleted by helm uninstall. Delete them manually if you want to remove all data:

kubectl delete pvc -l app.kubernetes.io/instance=llamenos

Troubleshooting

Pod stuck in CrashLoopBackOff

kubectl logs llamenos-0 -c app --previous
kubectl describe pod llamenos-0

Common causes: missing secrets (hmacSecret, serverNostrSecret), PostgreSQL unreachable, MinIO not ready.

Database connection errors

Verify PostgreSQL is reachable from the cluster:

kubectl run pg-test --rm -it --image=postgres:17-alpine -- \
  psql postgresql://llamenos:PASSWORD@PG_HOST:5432/llamenos -c "SELECT 1"

Ingress not working

Verify the ingress controller is running and the Ingress resource has an address:

kubectl get ingress llamenos
kubectl describe ingress llamenos

Certificate not issued

Check cert-manager certificate status:

kubectl get certificate llamenos-tls
kubectl describe certificate llamenos-tls
kubectl get certificaterequest
kubectl describe certificaterequest

Common causes: DNS not yet propagated, ports 80/443 not open, ClusterIssuer misconfigured.

Next steps