Skip to content

Kubernetes Deployment

Deploy Directory with SPIRE in a Kind cluster for development and testing. Uses example.org as the trust domain (local only—cannot federate with the public network).

For production AWS EKS deployment, see Production Deployment.

Prerequisites

The following prerequisites are required:

Note

Make sure that Docker has Buildx enabled.

Installation

The Agent Directory Service can be deployed using Helm or GitOps / Argo CD. Helm is the recommended method for development and testing.

Deploy Directory using Helm only—no Argo CD. Uses the same charts and configuration patterns as dir-staging.

  1. Create Kind cluster:

    kind create cluster --name dir-dev
    
  2. Add Helm repositories:

    helm repo add spiffe https://spiffe.github.io/helm-charts-hardened
    helm repo update
    
  3. Deploy SPIRE CRDs:

    helm install spire-crds spiffe/spire-crds \
      --version 0.5.0 \
      --namespace dir-dev-spire-crds \
      --create-namespace
    
  4. Deploy SPIRE server and agent:

    helm install spire spiffe/spire \
      --version 0.27.0 \
      --namespace dir-dev-spire \
      --create-namespace \
      --set global.spire.trustDomain=example.org \
      --set global.installAndUpgradeHooks.enabled=false \
      --set global.deleteHooks.enabled=false \
      --set spire-server.federation.enabled=true \
      --set spire-server.controllerManager.className=dir-spire
    

    Wait for SPIRE to be ready:

    kubectl wait --for=condition=ready pod -n dir-dev-spire -l app.kubernetes.io/name=server --timeout=240s
    kubectl wait --for=condition=ready pod -n dir-dev-spire -l app.kubernetes.io/name=agent --timeout=240s
    
  5. Deploy Directory (API server, Zot, PostgreSQL)

    The Directory chart includes the API server, Zot registry, and PostgreSQL. Pipe values from stdin using -f -:

    helm install dir oci://ghcr.io/agntcy/dir/helm-charts/dir \
      --version v1.8.0 \
      --namespace dir-dev-dir \
      --create-namespace \
      -f - <<'EOF'
    # Minimal values for Kind - based on dir-staging applications/dir/dev/values.yaml
    # Release name: dir. Service names: dir-apiserver, dir-zot (chart.oci.registryAddress uses Release.Name-zot)
    apiserver:
      image:
        repository: ghcr.io/agntcy/dir-apiserver
        tag: v1.8.0
        pullPolicy: IfNotPresent
      service:
        type: NodePort
      metrics:
        enabled: false
      routingService:
        type: NodePort
        nodePort: 30555
      spire:
        enabled: true
        className: dir-spire
        trustDomain: example.org
        useCSIDriver: true
      config:
        policy:
          operations: []
        listen_address: "0.0.0.0:8888"
        authn:
          enabled: true
          mode: "x509"
          socket_path: "unix:///run/spire/agent-sockets/api.sock"
          audiences:
            - "spiffe://example.org/spire/server"
        authz:
          enabled: true
          enforcer_policy_file_path: "/etc/agntcy/dir/authz_policies.csv"
        ratelimit:
          enabled: false
        store:
          provider: "oci"
          oci:
            registry_address: "dir-zot.dir-dev-dir.svc.cluster.local:5000"
            auth_config:
              insecure: "true"
              username: "admin"
              password: "admin"
        routing:
          listen_address: "/ip4/0.0.0.0/tcp/5555"
          datastore_dir: /etc/routing/datastore
          directory_api_address: "dir-apiserver.dir-dev-dir.svc.cluster.local:8888"
          gossipsub:
            enabled: false
        sync:
          auth_config:
            username: "user"
            password: "user"
        publication:
          scheduler_interval: "1h"
          worker_count: 0
          worker_timeout: "30m"
        database:
          type: "postgres"
          postgres:
            host: ""
            port: 5432
            database: "dir"
            ssl_mode: "disable"
      authz_policies_csv: |
        p,example.org,*
        p,*,/agntcy.dir.store.v1.StoreService/Pull
        p,*,/agntcy.dir.store.v1.StoreService/PullReferrer
        p,*,/agntcy.dir.store.v1.StoreService/Lookup
        # Registry credentials read every record straight from the registry, so only
        # a rule naming a peer node's SPIFFE ID grants them, e.g.:
        # p,spiffe://<peer-trust-domain>/ns/<namespace>/sa/<service-account>,/agntcy.dir.store.v1.SyncService/RequestRegistryCredentials
      secrets:
        ociAuth:
          username: "admin"
          password: "admin"
      reconciler:
        config:
          regsync:
            enabled: false
          indexer:
            enabled: false
      postgresql:
        enabled: true
        auth:
          username: "dir"
          password: "dir"
          database: "dir"
      strategy:
        type: Recreate
      extraVolumeMounts:
        - name: dir-routing-storage
          mountPath: /etc/routing
      extraVolumes:
        - name: dir-routing-storage
          emptyDir: {}
      zot:
        resources: {}
        mountSecret: true
        authHeader: "admin:admin"
        secretFiles:
          htpasswd: |-
            admin:$2y$05$vmiurPmJvHylk78HHFWuruFFVePlit9rZWGA/FbZfTEmNRneGJtha
            user:$2y$05$L86zqQDfH5y445dcMlwu6uHv.oXFgT6AiJCwpv3ehr7idc0rI3S2G
        mountConfig: true
        configFiles:
          config.json: |-
            {
              "distSpecVersion": "1.1.1",
              "storage": {"rootDirectory": "/var/lib/registry"},
              "http": {
                "address": "0.0.0.0",
                "port": "5000",
                "auth": {"htpasswd": {"path": "/secret/htpasswd"}},
                "accessControl": {
                  "adminPolicy": {"users": ["admin"], "actions": ["read", "create", "update", "delete"]},
                  "repositories": {"**": {"anonymousPolicy": [], "defaultPolicy": ["read"]}}
                }
              },
              "log": {"level": "info"},
              "extensions": {"search": {"enable": true}, "trust": {"enable": true, "cosign": true, "notation": false}}
            }
    EOF
    
  6. Wait for the API server to be ready:

    kubectl wait --for=condition=ready pod -n dir-dev-dir -l app.kubernetes.io/name=apiserver --timeout=240s
    
  7. Port-forward and verify:

    kubectl port-forward svc/dir-apiserver -n dir-dev-dir 8888:8888
    
  8. In another terminal, verify with token-based auth:

    # Install dirctl first if needed — see the Quickstart:
    # https://agntcy.github.io/dir/dir/dir-quickstart/#install-the-cli
    
    # Create SPIFFE SVID for local client
    SPIRE_POD=$(kubectl get pods -n dir-dev-spire -l app.kubernetes.io/name=server -o jsonpath='{.items[0].metadata.name}')
    kubectl exec -n dir-dev-spire "$SPIRE_POD" -c spire-server -- \
      /opt/spire/bin/spire-server x509 mint \
      -dns dev.api.example.org \
      -spiffeID spiffe://example.org/local-client \
      -output json > spiffe-dev.json
    
    # Configure and verify
    export DIRECTORY_CLIENT_AUTH_MODE="token"
    export DIRECTORY_CLIENT_SPIFFE_TOKEN="spiffe-dev.json"
    export DIRECTORY_CLIENT_SERVER_ADDRESS="127.0.0.1:8888"
    export DIRECTORY_CLIENT_TLS_SKIP_VERIFY="true"
    
    dirctl info bafytest123
    # Expected: Error: record not found (proves API is reachable)
    
  9. Push and pull a record:

    # Push via pipe
    cat <<'EOF' | dirctl push --stdin --output raw
    {"name":"burger_seller_agent","schema_version":"1.0.0","version":"1.0.0","description":"Helps with creating burger orders","authors":["Example Organization"],"created_at":"2025-01-01T00:00:00Z","skills":[{"name":"natural_language_processing/natural_language_understanding/contextual_comprehension","id":10101}],"locators":[{"type":"container_image","urls":["https://ghcr.io/agntcy/burger-seller-agent"]}],"modules":[{"name":"integration/mcp","data":{"name":"github-mcp-server","connections":[{"type":"stdio","command":"docker","args":["run","-i","--rm","-e","GITHUB_PERSONAL_ACCESS_TOKEN","ghcr.io/github/github-mcp-server"],"env_vars":[{"name":"GITHUB_PERSONAL_ACCESS_TOKEN","default_value":"","description":"Secret value for GITHUB_PERSONAL_ACCESS_TOKEN"}]}]}},{"name":"integration/a2a","data":{"card_data":{"protocolVersions":["0.2.6"],"name":"burger_seller_agent","description":"Helps with creating burger orders","supportedInterfaces":[{"url":"https://burger-agent-109790610330.us-central1.run.app","protocolBinding":"HTTP+JSON"}],"provider":{"url":"https://example.com","organization":"Example Organization"},"version":"1.0.0","capabilities":{"streaming":true},"defaultInputModes":["text","text/plain"],"defaultOutputModes":["text","text/plain"],"skills":[{"id":"create_burger_order","name":"Burger Order Creation Tool","description":"Helps with creating burger orders","tags":["burger order creation"],"examples":["I want to order 2 classic cheeseburgers"]}]},"card_schema_version":"v1.0.0"}}]}
    EOF
    
    # Pull by name (JSON output)
    dirctl pull burger_seller_agent -o json
    
  10. Clean up the environment:

    helm uninstall dir -n dir-dev-dir
    helm uninstall spire -n dir-dev-spire
    helm uninstall spire-crds -n dir-dev-spire-crds
    kind delete cluster --name dir-dev
    

Chart References

Component Chart Version
SPIRE CRDs spiffe/spire-crds 0.5.0
SPIRE spiffe/spire 0.27.0
Directory oci://ghcr.io/agntcy/dir/helm-charts/dir v1.8.0

For full configuration examples see the dir-staging applications.

For teams already using Argo CD, Directory can be deployed from a GitOps repository. dir-staging is an example you can fork or clone to maintain your own GitOps repo.

  1. Create a Kind cluster and install Argo CD.
  2. Apply the dir-staging project and application manifests (or your forked repo).
  3. Argo CD syncs SPIRE CRDs, SPIRE, and Directory from the repo.
kind create cluster --name dir-dev
kubectl create namespace argocd
kubectl apply -n argocd -f https://raw.githubusercontent.com/argoproj/argo-cd/stable/manifests/install.yaml
kubectl wait --namespace argocd --for=condition=available deployment --all --timeout=120s

kubectl apply -f https://raw.githubusercontent.com/agntcy/dir-staging/main/projects/dir/dev/dir-dev.yaml
kubectl apply -f https://raw.githubusercontent.com/agntcy/dir-staging/main/projectapps/dir/dev/dir-dev-projectapp.yaml

kubectl wait --for=condition=ready pod -n dir-dev-spire -l app.kubernetes.io/name=server --timeout=240s
kubectl wait --for=condition=ready pod -n dir-dev-spire -l app.kubernetes.io/name=agent --timeout=240s
kubectl wait --for=condition=ready pod -n dir-dev-dir -l app.kubernetes.io/name=apiserver --timeout=240s

Port-forward: kubectl port-forward svc/dir-dir-dev-argoapp-apiserver -n dir-dev-dir 8888:8888

See dir-staging for full configuration and customization options.

Trust domain

This Quick Start uses example.org for local testing only. To federate with the public Directory network, you need a unique trust domain. See Production Deployment and Running a Federated Directory Instance.

OASF Extractor Server

Natural-language search on the HTTP gateway (POST /v1/search and POST /v1/extract) needs an OASF taxonomy extractor to turn free text into OASF skills, domains, modules, and keywords. Locally, dirctl init provisions the extractor assets on the machine that runs them. In a cluster there is nowhere to run dirctl init, so the chart can deploy an OASF-SDK gRPC server next to the API server instead and point the gateway at it.

The subchart is opt-in and disabled by default:

apiserver:
  httpGateway:
    enabled: true
  oasf-sdk:
    enabled: true

That renders a <release>-oasf-sdk Deployment and Service, and sets extractor.remote_addr in the API server's server.config.yml to the Service's cluster DNS name — for a release named dir in namespace dir-dev-dir, dir-oasf-sdk.dir-dev-dir.svc.cluster.local:31234.

The values key is oasf-sdk, not oasfSdk

It is the upstream subchart name. The dependency is deliberately not aliased: Helm turns an alias into the subchart's .Chart.Name, and the upstream chart names its container after it, so a camelCase alias would render an invalid container name.

A few things worth knowing:

  • The server needs OASF_SDK_EXTRACTOR_OASF_URL, and the chart sets it. That variable is the extractor's on/off switch: the OASF-SDK server registers ExtractorService only when it is set. Override it (oasf-sdk.env) to point at a different schema server, but do not unset it — without it the pod still starts and passes its health check, and every call fails with unknown service agntcy.oasfsdk.extractor.v1.ExtractorService.
  • The Service is ClusterIP and stays that way. The OASF-SDK server speaks plaintext gRPC with no authentication. The gateway reaches it over cluster DNS, so a node port buys nothing and would put free-text user queries on every node IP. Requires chart v1.2.0 or newer — earlier versions defaulted to NodePort and always rendered nodePort, which Kubernetes rejects on a ClusterIP Service.
  • Startup takes about 40 seconds, and needs the OASF schema URL reachable. From image v1.3.0 the embedding model ships inside the image, so nothing is fetched from huggingface.co — the pull is ~90 MB instead. On start the server converts the model, fetches the taxonomy from OASF_SDK_EXTRACTOR_OASF_URL, and embeds the catalog before going ready. A new OASF release is picked up by restarting the pod.
  • The extractor is only consulted when httpGateway.enabled is true. With the gateway off, nothing dials it.
  • A missing extractor is not fatal. If the server cannot reach one, it logs a warning and starts anyway; only extractor-backed endpoints stay unavailable.
  • To use an extractor you deploy yourself — a shared one in another namespace, say — leave oasf-sdk.enabled at false and set the address explicitly. An explicit value always wins over the derived one:

    apiserver:
      config:
        extractor:
          remote_addr: "oasf-sdk.shared.svc.cluster.local:31234"
    

Content Policies

A content policy runs a scheduled "search, then act on the matches" pipeline against your node. Policies are declared in the dirctl chart's values — no forked chart and no hand-written manifests. Each enabled policy renders a CronJob named <release>-dirctl-policy-<key>.

policies:
  sync-netsec:
    enabled: true
    schedule: "0 3 * * *"
    action: sync
    match:
      domain: [network_security]

  prune-untrusted:
    enabled: true
    schedule: "*/30 * * * *"
    action: prune
    match:
      trusted: false
      scan-severity: MEDIUM

sync-netsec pulls every record peers advertise in the network_security domain into this node, nightly. prune-untrusted deletes local records whose signature is not trusted and that scan at MEDIUM severity or worse, every 30 minutes.

Actions

Action What it does Underlying pipeline
sync Pull matching records from network peers into this node dirctl routing search → dirctl sync create --stdin
prune Delete matching records from this node's store dirctl search → dirctl delete --stdin
publish Announce matching local records to the routing network dirctl search → dirctl routing publish --stdin
unpublish Withdraw matching local records from the routing network dirctl search → dirctl routing unpublish --stdin

Predicates

match keys are dirctl flag names with the leading -- removed, so every filter the CLI supports is available without a chart change:

Value kind Example Rendered flag
List domain: [a, b] --domain 'a' --domain 'b'
Boolean trusted: false --trusted=false
Scalar scan-severity: MEDIUM --scan-severity 'MEDIUM'

Boolean filters are tri-state. Omitting trusted does not filter on trust at all; trusted: true matches records with a trusted signature; trusted: false matches records without one. verified behaves the same way.

safe: false is not the complement of safe: true

safe: true matches records where every scanner reported is_safe=true, and safe: false matches records where at least one scanner reported unsafe. A record that was never scanned matches neither. A prune policy keyed on safe: false therefore deletes only records with a failing scan report, and silently leaves unscanned records untouched — which is likely the opposite of the intent. To act on "not known to be safe", combine safe: false with a separate policy for unscanned records, or gate on scan-severity instead.

Because keys are passed through mechanically, the exclude- filters work too — carving an exception out of a policy needs no chart change:

policies:
  prune-untrusted-except-cisco:
    enabled: true
    schedule: "@daily"
    action: prune
    match:
      trusted: false
      exclude-name: ["cisco.com/*"]

limit, format and output are set by the chart and are rejected inside match. An unknown action and a value containing a single quote also fail at helm template time rather than at run time.

sync searches the network, not your store

sync queries the routing index to find records held by other peers, so its match keys are limited to those dirctl routing search accepts: skill, domain, locator, module and min-score. The other three actions search the local store and accept the full dirctl search filter set.

Other Fields

Field Default Meaning
enabled false Render the CronJob.
schedule required Cron expression.
limit 100 Maximum records processed per run.
dryRun false Log the matches and exit without applying the action.

limit is a page size, and how a run covers matches beyond it depends on the action:

Action Coverage
prune Deleting a record stops it matching, so the set shrinks and successive runs converge on the whole backlog.
publish, unpublish The run pages through --offset until it sees a short page, so a single run covers every match.
sync Processes only the first limit matches. dirctl routing search has no --offset, and syncing does not stop a peer advertising the record, so later matches are never reached — set limit above your expected match count.

env, resources, volumes, volumeMounts, concurrencyPolicy, successfulJobsHistoryLimit and failedJobsHistoryLimit may be set per policy and behave exactly as they do for a cronjobs entry.

Preview a destructive policy before enabling it

prune deletes records and unpublish withdraws them from the network. Set dryRun: true for the first few runs and read the CronJob logs — every run logs the match count and dumps the matched records before acting:

[prune-untrusted] matched 12 record(s)
[
  "baeareib...",
  ...
]
[prune-untrusted] dry run: not applying action 'prune'

Both example policies ship disabled in values.yaml. Adding a policy of your own is a values change only.

Security Scanning

The reconciler image includes mcp-scanner and skill-scanner pre-installed. The scan task is enabled by default and requires Azure OpenAI credentials to perform LLM-backed analysis.

Inject the credentials via extraEnv in your Helm values. Use a Kubernetes Secret to avoid storing keys in plaintext:

kubectl create secret generic azure-openai \
  --namespace dir-dev-dir \
  --from-literal=api-key=<your-key>
extraEnv:
  - name: AZURE_OPENAI_API_KEY
    valueFrom:
      secretKeyRef:
        name: azure-openai
        key: api-key
  - name: AZURE_OPENAI_BASE_URL
    value: "https://your-instance.openai.azure.com/"
  - name: AZURE_OPENAI_DEPLOYMENT
    value: "gpt-4o"
  - name: AZURE_OPENAI_API_VERSION
    value: "2024-02-01"

To disable scanning, set reconciler.config.scan.enabled: false in your Helm values.

See Security Scanning for how to pull and filter scan results.