Kubernetes deployment
This guide shows how to run Argus on a Kubernetes cluster. It is meant as a solid starting point you adapt to your cluster — not a turnkey production chart.
If you just want the simplest possible host, a VPS with the binary under systemd is easier. Reach for Kubernetes when you already run a cluster and want Argus to live alongside your other bots and ops tooling.
Architecture on Kubernetes
Section titled “Architecture on Kubernetes”| Concern | Choice |
|---|---|
| Workload | StatefulSet, replicas: 1 (never scaled) |
| State | one ReadWriteOnce PVC mounted at ARGUS_HOME |
Config (argus.yaml, and SOUL.md — your organization’s identity) |
ConfigMap, seeded onto the PVC by an init container |
| Secrets (API keys, GitHub App) | Secret → env vars; the App PEM mounted as a file |
User table (users.yaml) |
lives on the PVC, managed via kubectl exec |
| Networking | one HTTP front door (daemon.http_addr), exposed by path at the Ingress |
What lives where, and why
Section titled “What lives where, and why”Argus splits its files by a single question — is it mutated at runtime?
- Declarative config —
argus.yaml,SOUL.md. Read once at boot, never written at runtime.SOUL.mdcarries your organization’s identity into every model call. Keep them in Git, ship them via aConfigMap, and let the init container copy them onto the PVC (overwrite on every boot). ChangingSOUL.mdrequires a pod restart to reload. - Secrets. Provider keys and GitHub App credentials go in a
Secretand are injected as environment variables.argus.yamlreferences them with theenv(VAR_NAME)syntax, which resolves from the process environment — so you do not need a.envfile in the pod. The GitHub App private key is the exception:private_key_pathreads a file, so mount it from aSecret. - Runtime state —
users.yaml,MEMORY.md(the summary Argus curates across sessions),context/,audit.log.jsonl,reports/,cache/. Owned by the PVC. The init container never overwrites these.
Prerequisites
Section titled “Prerequisites”-
A Kubernetes cluster (a single-node cluster is fine).
-
An Ingress controller (e.g. ingress-nginx) and TLS — the GitHub webhook must be reachable on a public HTTPS URL. cert-manager is the easy path to certificates.
-
The official Argus image. You do not build it yourself — pull it from
ghcr.io/argusappsec/argus:Terminal window docker pull ghcr.io/argusappsec/argus:latestIt is batteries-included:
argusplus every binary its Tools shell out to —git(required, for cloning),semgrep,gitleaksandosv-scanner— so the GitHub pull-request review channel works out of the box with no derived image or sidecar. The image runs as nonroot (uid65532) and exposes a single HTTP front door on:8080— the daemon serves every configured HTTP channel (the GitHub webhook at/webhooks/github, MCP at/mcp) plus its/healthzprobe on that one port. It is published multi-arch forlinux/amd64andlinux/arm64with provenance and SBOM attestations.Which tag to run:
Tag Source Use it when latestthe newest stable v*releaseyou want the recommended stable image (prereleases never move it) vX.Y.Z,vX.Y,vXa specific v*git tagyou want to pin to an exact release for reproducible deploys edgeevery push to mainyou want the bleeding edge — continuous dogfooding of the latest daemon before it is tagged Pin to a semver tag in production; reach for
edgeonly if you deliberately want to trackmain.
Step 1 — Author the seed config locally
Section titled “Step 1 — Author the seed config locally”argus init is interactive, so run it on your laptop (the local loop is
supported) to produce argus.yaml and SOUL.md:
argus init # pick provider, enter the API key, run the SOUL interviewKeep the generated argus.yaml and SOUL.md, but make argus.yaml point at
env vars rather than inline secrets:
# argus.yaml (the version you ship to the cluster)default_model: gemini-2.5-proproviders: gemini: type: gemini api_key: env(GEMINI_API_KEY)daemon: socket: ~/.argus/argusd.sock http_addr: :8080 # the single HTTP front door max_concurrent_sessions: 4codehosts: github: # outbound App identity: clone + call the API type: github app_id: env(GITHUB_APP_ID) private_key_path: /etc/argus/github_app.pemchannels: github: # inbound webhook binding type: github webhook_secret: env(GITHUB_SECRET_WH) auto_enroll: true mcp: type: mcpStep 2 — ConfigMap (seed) and Secrets
Section titled “Step 2 — ConfigMap (seed) and Secrets”# Declarative config seed (versioned in Git, GitOps-friendly)kubectl create configmap argus-seed \ --from-file=argus.yaml=./argus.yaml \ --from-file=SOUL.md=./SOUL.md \ --dry-run=client -o yaml > argus-seed.configmap.yaml
# Secrets injected as env varskubectl create secret generic argus-secrets \ --from-literal=GEMINI_API_KEY=... \ --from-literal=GITHUB_APP_ID=... \ --from-literal=GITHUB_SECRET_WH=...
# GitHub App private key, mounted as a filekubectl create secret generic argus-github-pem \ --from-file=github_app.pem=./github_app.pemStep 3 — StatefulSet, Service, Ingress
Section titled “Step 3 — StatefulSet, Service, Ingress”apiVersion: apps/v1kind: StatefulSetmetadata: name: argusspec: serviceName: argus replicas: 1 # never scale: two writers corrupt the state selector: matchLabels: { app: argus } template: metadata: labels: { app: argus } spec: securityContext: fsGroup: 65532 # let the nonroot image write the PVC initContainers: - name: seed image: busybox:1.37 # just needs sh + cp + test command: ["/bin/sh", "-c"] args: - | mkdir -p "$ARGUS_HOME" # config is declarative → always re-applied from the ConfigMap cp /seed/argus.yaml "$ARGUS_HOME/argus.yaml" cp /seed/SOUL.md "$ARGUS_HOME/SOUL.md" # users.yaml is runtime state → never seeded here env: - { name: ARGUS_HOME, value: /data/.argus } volumeMounts: - { name: data, mountPath: /data/.argus } - { name: seed, mountPath: /seed, readOnly: true } containers: - name: argus image: ghcr.io/argusappsec/argus:latest args: ["daemon"] env: - { name: ARGUS_HOME, value: /data/.argus } envFrom: - secretRef: { name: argus-secrets } ports: - { name: http, containerPort: 8080 } # the single front door livenessProbe: httpGet: { path: /healthz, port: http } initialDelaySeconds: 10 readinessProbe: httpGet: { path: /healthz, port: http } volumeMounts: - { name: data, mountPath: /data/.argus } - { name: pem, mountPath: /etc/argus, readOnly: true } volumes: - name: seed configMap: { name: argus-seed } - name: pem secret: secretName: argus-github-pem items: - { key: github_app.pem, path: github_app.pem } volumeClaimTemplates: - metadata: { name: data } spec: accessModes: ["ReadWriteOnce"] storageClassName: retain-sc # a StorageClass with reclaimPolicy: Retain resources: requests: { storage: 10Gi }---apiVersion: v1kind: Servicemetadata: name: argusspec: clusterIP: None selector: { app: argus } ports: - { name: http, port: 8080, targetPort: http }---apiVersion: networking.k8s.io/v1kind: Ingressmetadata: name: argus annotations: cert-manager.io/cluster-issuer: letsencryptspec: tls: - hosts: [argus.example.com] secretName: argus-tls rules: - host: argus.example.com http: paths: - path: /webhooks/github # GitHub App webhook → must be public pathType: Prefix backend: { service: { name: argus, port: { name: http } } } - path: /mcp # drop this path to keep MCP internal pathType: Prefix backend: { service: { name: argus, port: { name: http } } }Both channels sit behind the one front-door port; exposure is a routing
decision at the Ingress, not a matter of separate listeners. Publish
/webhooks/github (the GitHub App must reach it) and keep MCP internal by
omitting the /mcp path — reach MCP via the in-cluster Service or a
port-forward. Leave /healthz unrouted; it is for in-cluster probes.
Point the GitHub App’s webhook URL at https://argus.example.com/webhooks/github.
Step 4 — Bootstrap users
Section titled “Step 4 — Bootstrap users”users.yaml starts empty. Possession of the local socket is admin — whoever can
reach the daemon’s Unix socket already controls the host it runs on, so Argus
treats that access as proof of ownership rather than asking for a credential it
could not protect anyway. The first operator therefore administers the daemon
over kubectl exec:
kubectl exec -it argus-0 -- argus user mcp-token add davideStep 5 — Backups
Section titled “Step 5 — Backups”A PVC is not a backup — a disk or zone failure loses it. Protect the runtime state off-cluster:
- Use
reclaimPolicy: Retainon the StorageClass so deleting the PVC does not destroy the underlying disk. - Schedule off-cluster backups with Velero or CSI
VolumeSnapshots. Prioritizeaudit.log.jsonl(append-only, compliance). - Do not mount the
ReadWriteOncePVC from a second backup pod on another node — it conflicts with the daemon. Use storage-layer snapshots or an in-pod sidecar.
Because argus.yaml and SOUL.md come from Git and secrets from your secret
store, a PVC loss only costs the accumulated state (users.yaml, memory,
context, audit log, reports) — which is exactly what the backups cover.
Updating configuration
Section titled “Updating configuration”argus.yaml/SOUL.md: edit in Git, re-apply the ConfigMap, and restart the pod (kubectl rollout restart statefulset/argus). With Flux, a ConfigMap-hash annotation or Reloader rolls the pod automatically.- Secrets: update the
Secretand restart the pod. - Users:
kubectl execas in Step 4 — no restart needed.