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.7.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.7.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:
        listen_address: "0.0.0.0:8888"
        oasf_api_validation:
          disable: true
        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
        p,*,/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.7.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.

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 searchdirctl sync create --stdin
prune Delete matching records from this node's store dirctl searchdirctl delete --stdin
publish Announce matching local records to the routing network dirctl searchdirctl routing publish --stdin
unpublish Withdraw matching local records from the routing network dirctl searchdirctl 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.