Skip to main content

Kubernetes Deployment

Deploy Aurora on any Kubernetes cluster using Helm.

Prerequisites​

Cluster requirements​

  • 4+ CPU cores and 12+ GB RAM allocatable across your nodes
  • A working default StorageClass (GKE and AKS have this out of the box; EKS needs the EBS CSI driver)
  • Outbound internet from nodes (to pull container images from public registries)
  • kubectl connected to the cluster

Don't have a cluster yet?

Third-party images

Aurora deploys several third-party images from public registries. Your nodes must be able to pull: postgres:15-alpine, redis:7-alpine, hashicorp/vault:1.15, searxng/searxng:*, memgraph/memgraph-mage:3.8.1. With services.minio.enabled: true you also need pgsty/silo and amazon/aws-cli (bucket-creation hook). For air-gapped clusters, mirror these to a private registry and review enabled services in your values.yaml.

Required tools​

ToolInstall
kubectlkubernetes.io/docs/tasks/tools
helmhelm.sh/docs/intro/install
yqgithub.com/mikefarah/yq#install
opensslUsually pre-installed. macOS: brew install openssl

S3-compatible storage​

Aurora stores files in S3-compatible object storage. Have your bucket details ready.

ProviderEndpoint URLNotes
AWS S3https://s3.amazonaws.comEKS guide covers bucket creation
GCS (S3 interop)https://storage.googleapis.comCreate HMAC keys
Cloudflare R2https://<ACCOUNT_ID>.r2.cloudflarestorage.comRegion: auto
MinIOhttp://minio:9000Self-hosted; see the storage guide — upstream images were pulled from public registries in Sept 2026

LLM provider​

Aurora's AI agents need an LLM. Have your provider credentials ready before deploying.

Quickstart: Use OpenRouter -- one API key, no model prefix config needed.

For full setup details (all providers, model configuration, Vertex AI/Bedrock auth), see the LLM Providers guide.

The key values you'll set during deployment:

config:
LLM_PROVIDER_MODE: "openrouter" # or "vertex", "anthropic", "openai", "bedrock"
# For non-OpenRouter providers, also set:
# MAIN_MODEL: "vertex/gemini-3.6-flash"
# RCA_MODEL: "vertex/gemini-3.6-flash"
secrets:
llm:
OPENROUTER_API_KEY: "" # or OPENAI_API_KEY, ANTHROPIC_API_KEY, etc.

For Vertex AI on Kubernetes, store credentials as a secret and reference with existingSecret:

kubectl create secret generic aurora-llm-vertex -n aurora-oss \
--from-literal=VERTEX_AI_PROJECT="your-gcp-project-id" \
--from-literal=VERTEX_AI_LOCATION="us-central1" \
--from-literal=VERTEX_AI_SERVICE_ACCOUNT_JSON="$(cat service-account.json)"
secrets:
llm:
existingSecret: "aurora-llm-vertex"

Choose Your Deployment Method​

MethodBest forWhat it does
Interactive Deploy ScriptFirst-time setup, getting running quicklyScript handles secrets, ingress, Vault automatically
Manual Helm DeploymentGitOps, custom configs, version-controlled valuesYou edit values and run Helm yourself

Both produce the same result. The script requires cloning the repo. Manual can use either a local clone or a published chart from a Helm registry.


Interactive Deploy Script​

Best for: first-time setup, getting Aurora running quickly.

1. Clone the repo​

git clone https://github.com/arvo-ai/aurora.git
cd aurora

2. Preflight check​

./deploy/preflight.sh

Fix any FAIL items before continuing.

3. Run the deploy script​

# Standard (public LB, nip.io URLs for quick testing):
./deploy/k8s-deploy.sh --skip-build

# Private/VPN (internal LB, your own hostname):
./deploy/k8s-deploy.sh --private --skip-build

# Build your own images instead of using prebuilt GHCR ones:
./deploy/k8s-deploy.sh --private

The script prompts for: container registry, storage bucket, LLM provider/key, and (for --private) a hostname. It then:

  1. Installs nginx ingress controller if missing
  2. Generates secrets and values.generated.yaml
  3. Deploys with Helm
  4. Initializes and configures Vault
  5. Prints access URLs

After deployment, open the frontend URL. The first user to register becomes the org admin.

You're done. Skip to Post-Deploy: DNS if you need to configure a real domain or HTTPS.


Manual Helm Deployment​

Best for: GitOps workflows, custom configurations, teams that manage values files in version control.

1. Get the chart​

Option A -- Clone the repo (gives you deploy scripts + local chart):

git clone https://github.com/arvo-ai/aurora.git
cd aurora
cp deploy/helm/aurora/values.yaml deploy/helm/aurora/values.generated.yaml

If you cloned the repo, run the preflight check to verify cluster readiness:

./deploy/preflight.sh

Option B -- Published chart (no clone needed, for GitOps tooling):

# Traditional Helm repo
helm repo add aurora https://raw.githubusercontent.com/Arvo-AI/aurora/gh-pages
helm repo update
helm show values aurora/aurora-oss > values.generated.yaml

# Or OCI registry (ArgoCD, Flux)
helm show values oci://ghcr.io/arvo-ai/charts/aurora-oss > values.generated.yaml

2. Edit values.generated.yaml​

Set at minimum:

image:
registry: "ghcr.io/arvo-ai" # prebuilt public images (no auth needed)
tag: "latest" # or "edge" for latest main, or "sha-<7char>"

config:
NEXT_PUBLIC_BACKEND_URL: "https://api.yourdomain.com"
NEXT_PUBLIC_WEBSOCKET_URL: "wss://ws.yourdomain.com"
FRONTEND_URL: "https://yourdomain.com"
STORAGE_BUCKET: "my-bucket"
STORAGE_ENDPOINT_URL: "https://s3.amazonaws.com"
STORAGE_REGION: "us-east-1"
LLM_PROVIDER_MODE: "openrouter" # see "LLM provider" above
# For non-OpenRouter providers, uncomment and set:
# MAIN_MODEL: "vertex/gemini-3.6-flash"
# RCA_MODEL: "vertex/gemini-3.6-flash"

secrets:
db:
POSTGRES_PASSWORD: "" # openssl rand -base64 32
backend:
STORAGE_ACCESS_KEY: "" # S3/GCS HMAC key (optional with IRSA/pod identity)
STORAGE_SECRET_KEY: ""
app:
FLASK_SECRET_KEY: "" # openssl rand -base64 32
AUTH_SECRET: "" # openssl rand -base64 32
SEARXNG_SECRET: "" # openssl rand -base64 32
INTERNAL_API_SECRET: "" # openssl rand -base64 32
llm:
OPENROUTER_API_KEY: "" # or OPENAI_API_KEY, ANTHROPIC_API_KEY, etc.
# Or use pre-existing K8s secrets instead -- see "Using Pre-Existing Kubernetes Secrets" below

ingress:
hosts:
frontend: "yourdomain.com"
api: "api.yourdomain.com"
ws: "ws.yourdomain.com"
# For private/VPN deployments (internal LB, not internet-facing):
# internal: true
# annotations:
# cloud.google.com/load-balancer-type: "Internal" # GKE
# service.beta.kubernetes.io/aws-load-balancer-internal: "true" # EKS
# service.beta.kubernetes.io/azure-load-balancer-internal: "true" # AKS

For LLM model configuration details (required for non-OpenRouter providers), see the LLM Providers guide. To use externally-managed secrets instead of inline values, see Using Pre-Existing Kubernetes Secrets.

No domain yet?

nip.io is a free wildcard DNS service -- any hostname like app.10.0.0.1.nip.io resolves to that embedded IP automatically. Use it for quick testing without DNS config. Replace yourdomain.com with aurora-oss.<INGRESS_IP>.nip.io (you'll get the IP after installing the ingress controller).

Private/VPN deployments

If using internal: true, your hostnames must resolve on the VPN or internal network. Options: split-horizon DNS, Tailscale MagicDNS, or /etc/hosts entries.

Guardrails require an LLM

AI safety guardrails are enabled by default (GUARDRAILS_ENABLED: "true"). They fail closed on any LLM error, blocking all shell commands. Ensure your LLM key is valid, or set GUARDRAILS_ENABLED: "false".

3. Install ingress controller (if not present)​

kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/controller-v1.8.1/deploy/static/provider/cloud/deploy.yaml
kubectl rollout status deployment/ingress-nginx-controller -n ingress-nginx --timeout=120s

4. Create namespace​

kubectl create namespace aurora-oss

If using existingSecret for any secret group (e.g., Vertex AI), create those secrets now before deploying.

5. Deploy​

# From local clone:
helm upgrade --install aurora-oss ./deploy/helm/aurora \
--namespace aurora-oss --reset-values \
-f deploy/helm/aurora/values.generated.yaml

# From published chart:
helm upgrade --install aurora-oss aurora/aurora-oss \
--namespace aurora-oss --reset-values \
-f values.generated.yaml

# From OCI:
helm upgrade --install aurora-oss oci://ghcr.io/arvo-ai/charts/aurora-oss \
--namespace aurora-oss --reset-values \
-f values.generated.yaml

6. Initialize Vault​

Aurora uses Vault to store user credentials (cloud tokens, API keys). This step is required -- without it, users can't connect integrations.

# Initialize (save the Unseal Key and Root Token!)
kubectl -n aurora-oss exec -it statefulset/aurora-oss-vault -- \
vault operator init -key-shares=1 -key-threshold=1

# Unseal
kubectl -n aurora-oss exec -it statefulset/aurora-oss-vault -- \
vault operator unseal <UNSEAL_KEY>

# Login
kubectl -n aurora-oss exec statefulset/aurora-oss-vault -- sh -c \
'export VAULT_ADDR=http://127.0.0.1:8200 && echo "<ROOT_TOKEN>" | vault login -'

# Enable KV engine
kubectl -n aurora-oss exec statefulset/aurora-oss-vault -- sh -c \
'export VAULT_ADDR=http://127.0.0.1:8200 && vault secrets enable -path=aurora kv-v2'

# Create policy
kubectl -n aurora-oss exec statefulset/aurora-oss-vault -- sh -c \
'export VAULT_ADDR=http://127.0.0.1:8200 && vault policy write aurora-app - <<EOF
path "aurora/data/users/*" { capabilities = ["create","read","update","delete","list"] }
path "aurora/metadata/users/*" { capabilities = ["list","read","delete"] }
path "aurora/metadata/" { capabilities = ["list"] }
path "aurora/metadata/users" { capabilities = ["list"] }
EOF'

# Create app token
kubectl -n aurora-oss exec statefulset/aurora-oss-vault -- sh -c \
'export VAULT_ADDR=http://127.0.0.1:8200 && vault token create -policy=aurora-app -ttl=0'

Put the app token in values.generated.yaml under secrets.backend.VAULT_TOKEN, then redeploy:

helm upgrade aurora-oss ./deploy/helm/aurora \
--namespace aurora-oss --reset-values \
-f deploy/helm/aurora/values.generated.yaml
tip

With manual unsealing, you must run vault operator unseal after every Vault pod restart. For production, use KMS auto-unseal to eliminate this.

Alternative: AWS Secrets Manager

You can use AWS Secrets Manager instead of Vault by setting SECRETS_BACKEND=aws_secrets_manager. This requires no Vault initialization or unsealing. See the AWS Secrets Manager guide for full setup.

7. Verify​

kubectl get pods -n aurora-oss # all Running
kubectl get ingress -n aurora-oss # hosts + IP assigned
curl http://api.yourdomain.com/health/ # {"status": "ok"}

Open the frontend URL. The first user to register becomes the org admin.

You're done. Aurora is running. The sections below are optional configuration for production hardening.


Optional: DNS & TLS​

By default, Aurora works over HTTP with nip.io or /etc/hosts. Set up DNS and TLS when you're ready to give users a real URL with HTTPS.

DNS​

Point your three hostnames at the ingress load balancer IP:

# Get the ingress IP (or hostname on EKS)
kubectl get svc -n ingress-nginx ingress-nginx-controller \
-o jsonpath='{.status.loadBalancer.ingress[0].ip}'
yourdomain.com A <INGRESS_IP>
api.yourdomain.com A <INGRESS_IP>
ws.yourdomain.com A <INGRESS_IP>

TLS with cert-manager (Let's Encrypt)​

Only needed if the deployment is internet-facing and you want automatic HTTPS certificates.

# Install cert-manager
kubectl apply -f https://github.com/cert-manager/cert-manager/releases/download/v1.13.3/cert-manager.yaml
kubectl wait --for=condition=ready pod -l app.kubernetes.io/instance=cert-manager -n cert-manager --timeout=120s

# Create issuer
cat <<EOF | kubectl apply -f -
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:
ingressClassName: nginx
EOF

Then in values.generated.yaml:

ingress:
tls:
enabled: true
certManager:
enabled: true
issuer: "letsencrypt-prod"

Manual TLS certificate​

For private deployments with an internal CA or self-signed cert:

kubectl create secret tls aurora-tls \
--cert=fullchain.crt --key=privkey.key -n aurora-oss
ingress:
tls:
enabled: true
secretName: "aurora-tls"

Ingress Controller​

Aurora's chart is controller-agnostic -- it uses the standard ingressClassName field. Set ingress.className to match your controller.

ControllerclassNameInstall
NGINX Ingressnginxhelm install ingress-nginx ingress-nginx/ingress-nginx -n ingress-nginx --create-namespace
TraefiktraefikOften bundled with k3s
AWS ALBalbAWS Load Balancer Controller
HAProxyhaproxyhelm install haproxy haproxytech/kubernetes-ingress -n haproxy --create-namespace

Required controller settings​

Regardless of controller, ensure these are configured:

SettingValueWhy
Read/send timeoutat least INVESTIGATION_TIMEOUT_SECONDS (chart default 7200s)The chart sets the nginx annotations only when className is nginx and nginx defaults are enabled. Other controllers must set this themselves.
HTTP version1.1Required for WebSocket upgrade
Max body size50mFile uploads

When className is nginx and ingress.nginx.enabled is not false, these are auto-applied as annotations by the chart. With nginx defaults turned off, set the timeouts on the controller yourself.

MCP Ingress​

The MCP server runs on port 8811. By default it's reachable at https://api.<domain>/mcp. For local-only access:

kubectl port-forward svc/aurora-oss-mcp 8811:8811 -n aurora-oss
Security - unauthenticated MCP = remote code execution

Exposing the MCP server without authentication allows unauthenticated remote code execution through the MCP protocol. For a dedicated hostname, set ingress.hosts.mcp in your values but always place an auth proxy (e.g. OAuth2 Proxy, Keycloak Gatekeeper, or nginx auth_request) in front of any internet-facing MCP ingress.

Local Kubernetes​

For local dev on OrbStack, Docker Desktop, or Rancher Desktop:

./deploy/k8s-deploy.sh --local

This builds images locally (no push), enables built-in S3-compatible storage for local dev, and uses nip.io URLs. The storage image is pgsty/silo, a maintained MinIO fork — see the storage guide for why.

Upgrading​

Aurora images track the chart version, so upgrading is usually just bumping --version — no image.tag to juggle. Pick the flow that matches how you installed.

# Version upgrade (recommended). The chart pulls the matching images and
# rolls the pods automatically. Reuses your existing config/secrets.
helm repo update
helm upgrade aurora-oss aurora/aurora-oss \
--namespace aurora-oss --version <X.Y.Z> --reuse-values
# Reproducible upgrade, pinned by immutable digest. Each release attaches a
# values.images-<X.Y.Z>.yaml (download from the GitHub Release) that pins the
# exact image digests — deploys the same binaries every time.
helm upgrade aurora-oss aurora/aurora-oss \
--namespace aurora-oss --version <X.Y.Z> --reuse-values \
-f values.images-<X.Y.Z>.yaml
# Config change (edited values.generated.yaml):
helm upgrade aurora-oss ./deploy/helm/aurora \
--namespace aurora-oss --reset-values \
-f deploy/helm/aurora/values.generated.yaml

# Rollback:
helm rollback aurora-oss -n aurora-oss

Then confirm the rollout landed on the new images:

kubectl -n aurora-oss rollout status deploy -l app.kubernetes.io/part-of=aurora
kubectl -n aurora-oss get deploy \
-o jsonpath='{range .items[*]}{.metadata.name}{"\t"}{.spec.template.spec.containers[0].image}{"\n"}{end}'
note
Coming from an older image.tag: "latest" install?

latest is mutable, so helm upgrade re-renders the same string and never rolls the pods. --reuse-values also keeps the persisted image.tag: "latest", so clear it explicitly:

helm upgrade aurora-oss aurora/aurora-oss \
--namespace aurora-oss --version <X.Y.Z> --reuse-values \
--set-string image.tag=

Aurora-managed images should then read :<X.Y.Z> (or @sha256:… if digest-pinned); a custom frontendImage override keeps its own tag.

Building Custom Images​

If you need images in a private registry instead of GHCR:

# Automated (reads registry from values.generated.yaml, builds multi-arch, pushes):
make deploy-build

# Manual:
GIT_SHA=$(git rev-parse --short HEAD)
REGISTRY="your-registry.example.com"

docker buildx build ./server --target=prod --platform linux/amd64 --push \
-t $REGISTRY/aurora-server:$GIT_SHA

docker buildx build ./client --target=prod --platform linux/amd64 --push \
-t $REGISTRY/aurora-frontend:$GIT_SHA

The frontend image uses runtime environment injection (docker-entrypoint.sh generates env-config.js at startup), so a single prebuilt image works with any domain without rebuilding.

Uninstalling​

helm uninstall aurora-oss -n aurora-oss
kubectl delete namespace aurora-oss

Autoscaling​

The chart includes optional HPAs for server and Celery workers. Disabled by default.

autoscaling:
server:
enabled: true
minReplicas: 2
maxReplicas: 3
targetCPU: 70
targetMemory: 80
celeryWorker:
enabled: true
minReplicas: 2
maxReplicas: 3
targetCPU: 70

When enabled, replicaCounts.* values are ignored -- HPA manages replicas. Size the Postgres connection budget below for maxReplicas before enabling it; helm refuses to render otherwise.

Per-pod concurrency and the Postgres connection budget​

config:
GUNICORN_WORKERS: "2" # processes per API server pod
GUNICORN_THREADS: "32" # request threads per process
DB_POOL_MAX: "20" # connections per Python process (server, worker, chatbot, beat)
DB_POOL_WAIT_TIMEOUT: "5" # seconds a request waits for a pooled connection
DB_CONNECT_TIMEOUT: "10" # seconds before an unreachable Postgres fails the connect
CELERY_CONCURRENCY: "4" # parallel tasks (processes) per worker pod
SSE_MAX_STREAMS_PER_PROCESS: "8" # open Incidents tabs per server process
services:
postgres:
maxConnections: 300 # in-cluster Postgres only; the image default is 100

Every Python process opens its own pool, so the worst case the configured pods can open is:

server pods x GUNICORN_WORKERS x (DB_POOL_MAX + 15) # +15 for the RBAC engine
+ worker pods x CELERY_CONCURRENCY x DB_POOL_MAX
+ chatbot pods x DB_POOL_MAX
+ celery-beat x DB_POOL_MAX
+ mcp x 10

With the defaults above one replica of each service is 200 connections, two server plus two worker replicas is 350, and the HPA's maxReplicas 3 + 3 is 500. helm refuses to render when the worst case for your replica counts (or maxReplicas) exceeds services.postgres.maxConnections: raise it first, and size resources.postgres.limits.memory with it (about 10Mi per connection on top of shared_buffers). The check covers the in-cluster Postgres only; with an external one set max_connections there. Changing maxConnections restarts the in-cluster Postgres pod once.

Each open Incidents tab (the list and every incident page) holds one server thread for its Server-Sent Events stream. SSE_MAX_STREAMS_PER_PROCESS (default 8, never more than GUNICORN_THREADS - 2) caps them per process; a tab beyond the cap is told to reconnect in 15s, and streams rotate every ~5 minutes so it gets a slot.

API server probes​

Liveness is a TCP check on port 5080 (server.probes.liveness.type: tcp), so a pod whose threads are all busy is not restarted: readiness (/health/readiness, 10s timeout, 3 failures) takes it out of the Service until it drains, and gunicorn's --timeout replaces a worker whose main loop hangs. Set type: http to restart a pod that cannot answer GET /health/liveness for failureThreshold x periodSeconds (120s by default). /health/readiness checks Postgres and Redis with short bounded waits and reports a busy connection pool as degraded (HTTP 200) rather than taking the pod out of the Service.

Using Pre-Existing Kubernetes Secrets​

For production deployments where secrets are managed externally (Terraform, External Secrets Operator, Sealed Secrets).

Creating the secrets​

Each secret is a standard Kubernetes Opaque secret whose keys match the environment variables Aurora expects:

kubectl create namespace aurora-oss # if not already created

# Database secret
kubectl create secret generic my-db-secret -n aurora-oss \
--from-literal=POSTGRES_USER=aurora \
--from-literal=POSTGRES_PASSWORD="$(openssl rand -base64 32)"

# Backend secret
kubectl create secret generic my-backend-secret -n aurora-oss \
--from-literal=VAULT_TOKEN="" \
--from-literal=STORAGE_ACCESS_KEY="your-access-key" \
--from-literal=STORAGE_SECRET_KEY="your-secret-key"

# App secret
kubectl create secret generic my-app-secret -n aurora-oss \
--from-literal=FLASK_SECRET_KEY="$(openssl rand -base64 32)" \
--from-literal=AUTH_SECRET="$(openssl rand -base64 32)" \
--from-literal=SEARXNG_SECRET="$(openssl rand -base64 32)" \
--from-literal=INTERNAL_API_SECRET="$(openssl rand -base64 32)"

# LLM secret
kubectl create secret generic my-llm-secret -n aurora-oss \
--from-literal=OPENROUTER_API_KEY="sk-or-..."

Referencing in values.generated.yaml​

secrets:
db:
existingSecret: "my-db-secret"
backend:
existingSecret: "my-backend-secret"
app:
existingSecret: "my-app-secret"
llm:
existingSecret: "my-llm-secret"

The Helm chart mounts the secret as envFrom -- each key in the secret becomes an environment variable in the pod. This is how Aurora knows which key is VAULT_TOKEN vs STORAGE_ACCESS_KEY.

Required keys per group​

GroupRequired Keys
dbPOSTGRES_USER, POSTGRES_PASSWORD
backendVAULT_TOKEN (if using Vault). STORAGE_ACCESS_KEY/STORAGE_SECRET_KEY optional with IRSA.
appFLASK_SECRET_KEY, AUTH_SECRET, SEARXNG_SECRET, INTERNAL_API_SECRET
llmAt least one LLM API key, or Vertex/Bedrock env vars

Secrets must exist in the namespace before helm install. You can mix existingSecret for some groups and inline values for others.

Troubleshooting​

StatefulSets stuck in Pending​

kubectl get pvc -n aurora-oss
kubectl get storageclass

EKS: Almost always a missing EBS CSI driver. See EKS setup guide.

After fixing storage, delete stuck PVCs to force recreation:

kubectl delete pvc --all -n aurora-oss
kubectl delete pods --all -n aurora-oss

Frontend loads but API returns 403​

  1. Check backend: curl https://api.yourdomain.com/health/
  2. Check frontend config: curl https://yourdomain.com/env-config.js
  3. Check logs: kubectl logs -n aurora-oss deploy/aurora-oss-server --tail=20

Vault sealed after restart​

kubectl -n aurora-oss exec -it statefulset/aurora-oss-vault -- \
vault operator unseal <UNSEAL_KEY>

For production, use KMS auto-unseal.

Image pull errors​

kubectl get events -n aurora-oss --sort-by='.lastTimestamp'

GHCR prebuilt images are public (no auth needed). If using a private registry, configure image.pullSecrets in values.

EKS: nip.io URLs not working​

AWS load balancers return a hostname, not an IP. The deploy script resolves this automatically. To fix manually:

ELB_HOST=$(kubectl get svc -n ingress-nginx ingress-nginx-controller \
-o jsonpath='{.status.loadBalancer.ingress[0].hostname}')
INGRESS_IP=$(dig +short "$ELB_HOST" | head -1)
echo "Use $INGRESS_IP in your nip.io URLs"
warning

ELB IPs can change. For production, use real DNS CNAME records pointing to the ELB hostname.

Configuration Reference​

See deploy/helm/aurora/values.yaml for the complete list of options with inline documentation.