A minimal starting point for running Apus on Kubernetes, not a production-ready deployment. It covers the core resources and is meant to be adapted to your environment.

Why not Helm

Apus is a single container with some config files. The interesting customization is in agent content (Markdown files, skills, config overrides), not infrastructure templating. Kustomize handles overlays without the overhead of a Helm chart. Layer a Helm chart on top of these manifests if your deployment grows more complex.

What you need

Quick start

# Export the key for the provider you configured. Use ANTHROPIC_API_KEY,
# GEMINI_API_KEY, OPENAI_API_KEY, or OPENROUTER_API_KEY.
export ANTHROPIC_API_KEY="..."
./scripts/k8s/deploy.sh

kubectl port-forward svc/apus 18789:18789 -n apus
open http://127.0.0.1:18789

deploy.sh creates token auth by default. Retrieve the generated gateway token for the Control UI:

kubectl get secret apus-secrets -n apus -o jsonpath='{.data.APUS_GATEWAY_TOKEN}' | base64 -d

For local debugging, ./scripts/k8s/deploy.sh --show-token prints the token after deploy.

Local testing with Kind

If you do not have a cluster, create one locally with Kind:

./scripts/k8s/create-kind.sh           # auto-detects docker or podman
./scripts/k8s/create-kind.sh --delete  # tear down

Then deploy as usual with ./scripts/k8s/deploy.sh.

Step by step

1) Deploy

Option A: API key in environment (one step)

# Export the key for the provider you configured. Use ANTHROPIC_API_KEY,
# GEMINI_API_KEY, OPENAI_API_KEY, or OPENROUTER_API_KEY.
export ANTHROPIC_API_KEY="..."
./scripts/k8s/deploy.sh

The script creates a Kubernetes Secret with the API key and an auto-generated gateway token, then deploys. If the Secret already exists, it preserves the current gateway token and any provider keys not being changed.

Option B: create the secret separately

export <PROVIDER>_API_KEY="..."
./scripts/k8s/deploy.sh --create-secret
./scripts/k8s/deploy.sh

Add --show-token to either command to print the token to stdout for local testing.

2) Access the gateway

kubectl port-forward svc/apus 18789:18789 -n apus
open http://127.0.0.1:18789

What gets deployed

Namespace: apus (configurable via APUS_NAMESPACE)
├── Deployment/apus        # Single pod, init container + gateway
├── Service/apus           # ClusterIP on port 18789
├── PersistentVolumeClaim      # 10Gi for agent state and config
├── ConfigMap/apus-config  # apus.json + AGENTS.md
└── Secret/apus-secrets    # Gateway token + API keys

The Deployment checks /readyz for startup and traffic readiness with a five-minute startup budget, and /healthz for liveness. Every check asserts the JSON check contract rather than the status code alone, because the Control UI answers unknown paths with a catch-all 200; a status-only check would pass forever against an image whose check route does not exist yet.

/startupz is the better traffic-admission check because it ignores channel health, so one failing channel account cannot evict an otherwise healthy Gateway from Service endpoints. It requires an image built from 2026.8.1 or newer, the release that introduced it, which is newer than the tag pinned above. After pinning such an image, switch the startup and readiness checks to /startupz and keep /readyz for monitoring that should include channel-account health.

Customization

Agent instructions

Edit the AGENTS.md in scripts/k8s/manifests/configmap.yaml and redeploy:

./scripts/k8s/deploy.sh

Gateway config

Edit apus.json in scripts/k8s/manifests/configmap.yaml. See Gateway configuration for the full reference.

The init container seeds apus.json and workspace AGENTS.md only when each file is missing from the PVC. The persisted copy is the source of truth after first boot: changes made through Apus (onboard, channels add, doctor --fix, Control UI) survive pod restarts, and updating the ConfigMap does not overwrite an existing PVC copy. To intentionally reseed a file from an updated ConfigMap, delete the persisted copy and restart:

kubectl exec -n apus deploy/apus -- rm /home/node/.apus/apus.json
kubectl rollout restart -n apus deploy/apus

Deployments created from the previous template applied ConfigMap edits on every pod start (and discarded any config changes made through Apus). If you relied on that flow, use the reseed commands above after ConfigMap edits.

Add providers

Re-run with additional keys exported:

export ANTHROPIC_API_KEY="..."
export OPENAI_API_KEY="..."
./scripts/k8s/deploy.sh --create-secret
./scripts/k8s/deploy.sh

Existing provider keys stay in the Secret unless you overwrite them.

Or patch the Secret directly:

kubectl patch secret apus-secrets -n apus \
  -p '{"stringData":{"<PROVIDER>_API_KEY":"..."}}'
kubectl rollout restart deployment/apus -n apus

Custom namespace

APUS_NAMESPACE=my-namespace ./scripts/k8s/deploy.sh

Custom image

Edit the image field in scripts/k8s/manifests/deployment.yaml:

# Bump this immutable versioned tag when upgrading Apus.
image: ghcr.io/apus/apus:2026.7.1-2-slim

Images from 2026.8.1 onward need a writable cache location; the shipped manifest provides it through XDG_CACHE_HOME. When upgrading an older deployment, apply that environment entry along with the image change.

Expose beyond port-forward

The default manifests bind the gateway to loopback inside the pod. That works with kubectl port-forward, but not with a Kubernetes Service or Ingress path that needs to reach the pod IP directly.

To expose the gateway through an Ingress or load balancer:

Re-deploy

./scripts/k8s/deploy.sh

This applies all manifests and restarts the pod to pick up any config or secret changes.

Teardown

./scripts/k8s/deploy.sh --delete

For the default apus namespace, this deletes the namespace and everything in it, including the PVC.

For a custom namespace, --delete removes only Apus resources and preserves the namespace and unrelated workloads:

APUS_NAMESPACE=my-namespace ./scripts/k8s/deploy.sh --delete

Use --delete-resources to request this scoped teardown explicitly in any namespace. Both scoped modes delete the Apus Deployment, Service, PVC, ConfigMap, and generated Secret. Deleting the PVC removes Apus's claim and access to its persisted data; whether the backing volume and data are deleted depends on the PersistentVolume or StorageClass reclaim policy (Delete or Retain).

To delete a custom namespace and every workload in it, explicitly opt in:

APUS_NAMESPACE=my-namespace ./scripts/k8s/deploy.sh --delete-namespace

This also deletes unrelated workloads and the PVC.

Architecture notes

File structure

scripts/k8s/
├── deploy.sh                   # Creates namespace + secret, deploys via kustomize
├── create-kind.sh              # Local Kind cluster (auto-detects docker/podman)
└── manifests/
    ├── kustomization.yaml      # Kustomize base
    ├── configmap.yaml          # apus.json + AGENTS.md
    ├── deployment.yaml         # Pod spec with security hardening
    ├── pvc.yaml                # 10Gi persistent storage
    └── service.yaml            # ClusterIP on 18789

Related