Skip to content

Scouty the Scout Bee Bindy Scout

Two Deployment Modes

Same-cluster mode (default): Scout and the Bindy operator run in the same cluster. No extra configuration needed.

Remote cluster mode: Scout runs on a workload cluster and writes to a dedicated Bindy cluster elsewhere. Set BINDY_SCOUT_REMOTE_SECRET to a Secret containing a kubeconfig for the Bindy cluster. Scout will create ARecords and validate zones there while watching local Ingresses and Services.

The Scout Bee

In a honeybee colony, scout bees are the advance team. While the main workers tend the hive, a small number of scouts venture out into the surrounding terrain, find promising flower patches, return to the hive, and communicate their discoveries through the waggle dance — a precise, information-rich signal that tells their sisters exactly where the nectar is.

Bindy Scout plays the same role in your Kubernetes infrastructure.

The main bindy run process is the hive — it manages BIND9 DNS infrastructure and reconciles DNS zones and records from their canonical CRD definitions. But it doesn't know about every application that flies around your cluster. That's where Scout comes in.

Scout is a lightweight, event-driven controller that ventures into your workload namespaces, watches Ingress and LoadBalancer Service resources, and — when it finds one annotated with the right signal — carries that DNS information back to the bindy cluster and registers an ARecord on the application's behalf.

The application team annotates their Ingress or Service. Scout finds it, validates it, derives the correct record name from the DNS zone, and server-side applies the ARecord CR in the bindy namespace. From there, the normal ARecord reconciler picks it up and programs BIND9. The application never needs to know how DNS works — it just raises a flag and Scout handles the rest.


What Scout Solves

In a multi-team platform, the DNS operator and the application teams live in different worlds:

  • The platform team owns the bindy-system namespace, the DNSZone CRs, and the BIND9 instances.
  • Application teams own their own namespaces, Deployment objects, and Ingress resources.

Bridging this gap traditionally requires either:

  • Manual DNS record management (error-prone, doesn't scale), or
  • Granting application teams write access to the bindy-system namespace (violates least-privilege).

Scout eliminates both compromises. Application teams annotate their own Ingresses — resources they already own — and Scout does the translation work. No cross-namespace write access required on the application side.


How It Works

sequenceDiagram participant App as Application Team participant Source as Ingress or Service participant Scout as Bindy Scout participant ARecord as ARecord CR participant Bindy as Bindy Operator participant BIND9 as BIND9 DNS App->>Source: Annotate with bindy.firestoned.io/scout-enabled: "true" Source->>Scout: Watch event (kube-rs Controller) Scout->>Scout: Validate annotations + derive record name + resolve IP Scout->>ARecord: Server-side apply (bindy-system namespace) ARecord->>Bindy: Watch event triggers ARecord reconciler Bindy->>BIND9: Program A record via RNDC
  1. Scout watches all Ingress and LoadBalancer Service resources cluster-wide (excluding its own namespace and any configured exclusions).
  2. When a watch event fires, Scout checks for the bindy.firestoned.io/scout-enabled: "true" annotation.
  3. If enabled, Scout reads the zone annotation, derives the DNS record name, and resolves the IP — from the LoadBalancer status or an explicit annotation override.
  4. Ingress: record name derived by stripping the zone suffix from each rule host (e.g. app.example.com in zone example.comapp). One ARecord per rule host.
  5. Service: record name is the Service name. Exactly one ARecord per Service.
  6. Scout server-side applies an ARecord CR in the configured target namespace (default: bindy-system), stamped with labels identifying the source cluster, namespace, and resource.
  7. The main bindy operator's ARecord reconciler picks up the new CR and programs BIND9.

Quick Start

1. Annotate your Ingress or Service

Minimal — when Scout is configured with --default-zone and --default-ips:

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: my-app
  namespace: my-app-ns
  annotations:
    bindy.firestoned.io/scout-enabled: "true"
spec:
  rules:
    - host: my-app.example.com
      ...

With overrides — per-Ingress zone and IP take precedence over Scout defaults:

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: my-app
  namespace: my-app-ns
  annotations:
    bindy.firestoned.io/scout-enabled: "true"
    # Override the default zone for this Ingress (optional when --default-zone is set)
    bindy.firestoned.io/zone: "example.com"
    # Optional: explicit IP override (overrides --default-ips and LB status)
    # bindy.firestoned.io/ip: "10.0.1.42"
    # Optional: TTL override in seconds (defaults to zone TTL)
    # bindy.firestoned.io/ttl: "300"
spec:
  rules:
    - host: my-app.example.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: my-app
                port:
                  number: 80

Scout will create an ARecord named scout-<cluster>-my-app-ns-my-app-0 in the bindy-system namespace, with:

  • spec.name: my-app (derived by stripping the zone suffix from the host)
  • spec.address: IP from the Ingress LoadBalancer status
  • Labels: bindy.firestoned.io/source-cluster, source-namespace, source-name, zone

For a LoadBalancer Service:

apiVersion: v1
kind: Service
metadata:
  name: my-grpc-api
  namespace: my-app-ns
  annotations:
    bindy.firestoned.io/scout-enabled: "true"
    bindy.firestoned.io/zone: "example.com"
spec:
  type: LoadBalancer
  selector:
    app: my-grpc-api
  ports:
    - port: 9090
      targetPort: 9090

Scout will create an ARecord named scout-<cluster>-my-app-ns-my-grpc-api in the bindy-system namespace, resolving to my-grpc-api.example.com.

2. Deploy Scout

apiVersion: apps/v1
kind: Deployment
metadata:
  name: bindy-scout
  namespace: bindy-system
spec:
  replicas: 1
  selector:
    matchLabels:
      app: bindy-scout
  template:
    metadata:
      labels:
        app: bindy-scout
    spec:
      serviceAccountName: bindy-scout
      containers:
        - name: scout
          image: ghcr.io/firestoned/bindy:latest
          args: ["scout", "--cluster-name", "prod"]
          env:
            - name: BINDY_SCOUT_NAMESPACE
              value: "bindy-system"
            - name: POD_NAMESPACE
              valueFrom:
                fieldRef:
                  fieldPath: metadata.namespace
            - name: RUST_LOG
              value: "info"
            - name: RUST_LOG_FORMAT
              value: "json"

Annotations Reference

These annotations apply to both Ingress and LoadBalancer Service resources.

Annotation Required Description
bindy.firestoned.io/scout-enabled Yes (preferred) Set to "true" to opt this Ingress or Service into Scout management. The record kind always defaults to ARecord.
bindy.firestoned.io/recordKind (legacy, Ingress only) Accepted for backward compatibility. "ARecord" opts in. Prefer scout-enabled: "true" for new deployments.
bindy.firestoned.io/zone Yes (unless --default-zone is set) The DNS zone for this resource (e.g. example.com). Overrides the operator's --default-zone. For Ingress: hosts outside the zone are skipped with a warning.
bindy.firestoned.io/ip No Explicit IP address(es) for the A record. Accepts a single IP ("10.0.0.1") or a comma-separated list ("10.0.0.1,10.0.0.2,10.0.0.3") — every entry becomes one address on the resulting ARecord, in the order given. Whitespace around each entry is trimmed and empty entries are skipped. When set, overrides both --default-ips and the LoadBalancer status IP.
bindy.firestoned.io/ttl No TTL override in seconds. When absent, the created ARecord inherits the TTL from the DNSZone spec.
bindy.firestoned.io/record-name No Override the spec.name of the created ARecord. When set, replaces the name normally derived by stripping the zone suffix from the host. Use "@" for the zone apex. On multi-host resources (Ingress, HTTPRoute, TLSRoute) the override is applied to every record produced — intended for single-host use cases.

Zone authorization (cross-namespace)

Scout only publishes into a zone that authorizes the source object's namespace. This prevents a tenant in one namespace from creating records in another tenant's zone (a cross-tenant DNS-hijack / confused-deputy risk, since Scout writes with a cluster-privileged client).

A source object (Ingress/Service/HTTPRoute/TLSRoute/TCPRoute) may publish into a zone when either:

  • the matching DNSZone lives in the same namespace as the source object, or
  • the DNSZone carries bindy.firestoned.io/allow-zone-namespaces listing the source namespace (comma-separated), or the * wildcard.
apiVersion: bindy.firestoned.io/v1beta1
kind: DNSZone
metadata:
  name: example-com
  namespace: bindy-system
  annotations:
    bindy.firestoned.io/allow-zone-namespaces: "tenant-a,tenant-b"   # or "*"
spec:
  zoneName: example.com

If a source object names a zone that does not authorize its namespace, Scout skips it and logs ... namespace not authorized for zone .... This mirrors the same annotation gate the DNSZone reconciler enforces for cross-namespace instance targeting. See the Migration Guide for upgrade steps.


Watched Resources

Scout supports three categories of Kubernetes resources as DNS record sources.


1. Ingresses (Legacy)

Kubernetes Ingress is deprecated

The networking.k8s.io/v1 Ingress API is considered legacy in modern Kubernetes clusters. New deployments should prefer Gateway API (HTTPRoute / TLSRoute / TCPRoute) where available. Scout continues to support Ingress for backward compatibility.

Scout processes each rule in the Ingress spec independently. For a multi-host Ingress:

spec:
  rules:
    - host: api.example.com      # → ARecord "scout-prod-ns-name-0"  (name: "api")
    - host: www.example.com      # → ARecord "scout-prod-ns-name-1"  (name: "www")
    - host: other.io             # → skipped (not in zone "example.com")

The record name is derived by stripping the zone suffix from the host:

Host Zone Derived record name
api.example.com example.com api
deep.sub.example.com example.com deep.sub
example.com example.com @ (apex record)
other.io example.com (skipped — not in zone)

Opt in with the bindy.firestoned.io/scout-enabled: "true" annotation. One ARecord CR is created per rule host, named scout-{cluster}-{namespace}-{ingress}-{idx}. The IP is resolved from the Ingress LoadBalancer status or the bindy.firestoned.io/ip annotation override.


2. Services (LoadBalancer)

Scout watches LoadBalancer Services and automatically creates ARecord CRs — no Ingress required. This covers gRPC, TCP, and other non-HTTP workloads exposed directly via a cloud load balancer.

Opt in with the same annotation:

apiVersion: v1
kind: Service
metadata:
  name: my-grpc-api
  namespace: my-app
  annotations:
    bindy.firestoned.io/scout-enabled: "true"
    bindy.firestoned.io/zone: "example.com"          # required if no --default-zone
    # bindy.firestoned.io/ip: "1.2.3.4"              # optional IP override
    # bindy.firestoned.io/ttl: "300"                  # optional TTL override
spec:
  type: LoadBalancer
  selector:
    app: my-grpc-api
  ports:
    - port: 443
      targetPort: 8443

Scout creates an ARecord named scout-{cluster}-{namespace}-{service} with:

  • DNS record name: {service-name} (relative to the zone) → resolves to my-grpc-api.example.com
  • IP: from bindy.firestoned.io/ip annotation → --default-ipsstatus.loadBalancer.ingress[].ip

ClusterIP and NodePort services are silently skipped — they have no routable external IP.

If no external IP has been assigned yet, Scout logs a warning and re-queues after 30s:

WARN scout: service my-app/my-grpc-api has no external IP yet; requeueing in 30s

Comparison with Ingress:

Ingress Service
ARecord per rule Yes (one per rules[].host) No — exactly one record
CR name suffix ...-{idx} (rule index) No index
IP source LB status or annotation LB status or annotation
Source label source-name source-name

Query all Service-sourced ARecords:

kubectl get arecords -n bindy-system -l bindy.firestoned.io/source-name=<service-name>

3. Gateway API (HTTPRoute, TLSRoute, and TCPRoute)

Scout watches HTTPRoute, TLSRoute, and TCPRoute resources from the Gateway API. The same opt-in annotation (bindy.firestoned.io/scout-enabled: "true") and zone/IP/TTL annotations apply. For HTTPRoute and TLSRoute, Scout creates one ARecord per hostname in spec.hostnames[]. TCPRoute has no spec.hostnames[] field — Scout creates a single ARecord using the name supplied by the bindy.firestoned.io/record-name annotation.

IP resolution follows the gateway chain

Gateway API routes have no LoadBalancer status of their own. When no bindy.firestoned.io/ip annotation is set, Scout follows the route's parentRefs to the serving Gateway, and — for a Gateway whose gatewayClassName is in the operator-configured --gateway-service map — reads the external IP from the Gateway's status.addresses, or, when those are empty, from the mapped LoadBalancer Service. This lets operators avoid making every route repeat an IP that is already discoverable from the gateway (e.g. Traefik's proxy Service). The full precedence is: bindy.firestoned.io/ip annotation → discovered gateway IP → --default-ips → requeue.

See Gateway API Routes for the full reference, examples, record naming, labels, and RBAC requirements.


Labels on Created ARecords

Every ARecord created by Scout carries these labels:

Label Value Source
bindy.firestoned.io/managed-by scout All
bindy.firestoned.io/source-cluster <BINDY_SCOUT_CLUSTER_NAME> All
bindy.firestoned.io/source-namespace Resource namespace All
bindy.firestoned.io/source-name Resource name All
bindy.firestoned.io/zone Zone name All

The zone label is particularly important: it lets you configure a DNSZone to automatically pull in all ARecords created by Scout for that zone:

apiVersion: bindy.firestoned.io/v1beta1
kind: DNSZone
metadata:
  name: example-com
spec:
  zoneName: example.com
  recordsFrom:
    - labelSelector:
        matchLabels:
          bindy.firestoned.io/managed-by: scout
          bindy.firestoned.io/zone: example.com

Configuration Reference

CLI Flags

Flag Description
--cluster-name <NAME> Required (unless env var set). Logical name of this cluster stamped on all created ARecord labels. Used to distinguish records created by different clusters writing to the same bindy namespace.
--namespace <NS> Namespace where ARecords are created. Defaults to bindy-system.
--default-zone <ZONE> Default DNS zone applied to all Ingresses and Services when no bindy.firestoned.io/zone annotation is present (e.g. example.com). When combined with --default-ips, resources only need bindy.firestoned.io/scout-enabled: "true".
--default-ips <IP[,IP]> Comma-separated default IP address(es) used when no per-resource bindy.firestoned.io/ip annotation or LoadBalancer status IP is available. Useful for shared-ingress topologies (e.g. Traefik).
--gateway-service <class=target> Repeatable. Maps a gatewayClass to the LoadBalancer Service whose external IP backs it, used to resolve an IP for HTTPRoute/TLSRoute/TCPRoute via their parentRefs. target is either namespace/name (e.g. traefik=traefik/traefik) or namespace/<label-selector> (e.g. traefik=traefik/app.kubernetes.io/name=traefik). Multi-label selectors (with commas) must use this flag rather than the env var. The configured classes double as the allow-list of gateways Scout will follow.
--namespace-selector <SELECTOR> A Kubernetes label selector (e.g. bindy.firestoned.io/scout-enabled=true) restricting which namespaces Scout will act in — same syntax as kubectl get ns -l <selector>. A namespace must match this selector and the individual Ingress/Service/route object must still carry its own opt-in annotation; both gates apply. Strongly recommended for every production deployment — see Namespace Whitelisting below.

CLI flags take precedence over the corresponding environment variables.

Environment Variables

Variable Default Description
BINDY_SCOUT_CLUSTER_NAME Required when --cluster-name is not set.
BINDY_SCOUT_NAMESPACE bindy-system Namespace where ARecords are created.
POD_NAMESPACE default Scout's own namespace. Always excluded from Ingress watching to prevent Scout from watching resources in its own namespace. Injected automatically by the Kubernetes downward API.
BINDY_SCOUT_EXCLUDE_NAMESPACES Comma-separated list of additional namespaces to skip. Useful to exclude system namespaces (kube-system, kube-public, etc.) that will never have Scout-annotated Ingresses.
BINDY_SCOUT_DEFAULT_ZONE Default DNS zone for all Ingresses and Services when no bindy.firestoned.io/zone annotation is present. Overridden by --default-zone. When set alongside BINDY_SCOUT_DEFAULT_IPS, resources only need bindy.firestoned.io/scout-enabled: "true".
BINDY_SCOUT_DEFAULT_IPS Comma-separated default IP address(es) used when no per-resource annotation override or LoadBalancer status IP is available. Useful for shared-ingress topologies (e.g. Traefik) where all resources resolve to the same VIP(s). Overridden by --default-ips.
BINDY_SCOUT_GATEWAY_SERVICES Comma-separated gatewayClass=target map for gateway-chain IP resolution on HTTPRoute/TLSRoute/TCPRoute, e.g. traefik=traefik/traefik. target is namespace/name or namespace/<label-selector>. Because commas separate entries here, multi-label selectors must use the repeatable --gateway-service flag. Overridden by --gateway-service.
BINDY_SCOUT_NAMESPACE_SELECTOR — (every namespace eligible) Label selector restricting which namespaces Scout will act in. Overridden by --namespace-selector. Unset means every namespace in the cluster is eligible — running without this set is not recommended for production. See Namespace Whitelisting.
BINDY_SCOUT_REMOTE_SECRET (Phase 2) Name of a Secret in the local cluster containing a kubeconfig key. When set, Scout targets the remote Bindy cluster for ARecord creation and zone validation. When unset, same-cluster mode is used.
BINDY_SCOUT_REMOTE_SECRET_NAMESPACE Scout's own namespace (Phase 2) Namespace of the BINDY_SCOUT_REMOTE_SECRET. Defaults to Scout's own namespace (POD_NAMESPACE).
RUST_LOG info Log level: trace, debug, info, warn, error.
RUST_LOG_FORMAT text Log format: text (compact, human-readable) or json (structured, for log aggregators).

Full Deployment Example with All Variables

apiVersion: apps/v1
kind: Deployment
metadata:
  name: bindy-scout
  namespace: bindy-system
spec:
  replicas: 1
  selector:
    matchLabels:
      app.kubernetes.io/name: bindy
      app.kubernetes.io/component: scout
  template:
    metadata:
      labels:
        app.kubernetes.io/name: bindy
        app.kubernetes.io/component: scout
    spec:
      serviceAccountName: bindy-scout
      containers:
        - name: scout
          image: ghcr.io/firestoned/bindy:latest
          args:
            - scout
            # Alternatively: --cluster-name prod
          env:
            - name: BINDY_SCOUT_CLUSTER_NAME
              value: "prod"
            - name: BINDY_SCOUT_NAMESPACE
              value: "bindy-system"
            - name: POD_NAMESPACE
              valueFrom:
                fieldRef:
                  fieldPath: metadata.namespace
            - name: BINDY_SCOUT_EXCLUDE_NAMESPACES
              value: "kube-system,kube-public,kube-node-lease"
            - name: RUST_LOG
              value: "info"
            - name: RUST_LOG_FORMAT
              value: "json"
          resources:
            requests:
              cpu: "50m"
              memory: "64Mi"
            limits:
              cpu: "200m"
              memory: "128Mi"

RBAC Requirements

Scout requires cluster-wide watch access to Ingress and Service resources, read access to DNSZone and Namespace resources, and write access to ARecord resources in the target namespace. It requires no Secret access at all unless you use Phase 2 (multi-cluster) mode — see below.

Namespace access is low-sensitivity

The namespaces: get, list rule is namespace metadata only (names and labels, no Secret data) and is required for namespace whitelisting.

---
apiVersion: v1
kind: ServiceAccount
metadata:
  name: bindy-scout
  namespace: bindy-system
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: bindy-scout
rules:
  # Watch and mutate Ingresses across all namespaces.
  # patch+update required because kube-rs finalizer patches the main resource.
  - apiGroups: ["networking.k8s.io"]
    resources: ["ingresses"]
    verbs: ["get", "list", "watch", "patch", "update"]
  - apiGroups: ["networking.k8s.io"]
    resources: ["ingresses/finalizers"]
    verbs: ["update"]
  # Watch LoadBalancer Services for external IP → ARecord automation.
  # patch+update required to add/remove the Scout finalizer on Service metadata.
  - apiGroups: [""]
    resources: ["services"]
    verbs: ["get", "list", "watch", "patch", "update"]
  - apiGroups: [""]
    resources: ["services/finalizers"]
    verbs: ["update"]
  # Watch HTTPRoutes/TLSRoutes/TCPRoutes from the Gateway API and manage finalizers.
  # patch+update required to add/remove the Scout finalizer on route metadata.
  - apiGroups: ["gateway.networking.k8s.io"]
    resources: ["httproutes", "tlsroutes", "tcproutes"]
    verbs: ["get", "list", "watch", "patch", "update"]
  # route/finalizers subresource for forward-compatibility.
  - apiGroups: ["gateway.networking.k8s.io"]
    resources: ["httproutes/finalizers", "tlsroutes/finalizers", "tcproutes/finalizers"]
    verbs: ["update"]
  # Gateways are read-only — Scout reads status.addresses to discover external IPs.
  - apiGroups: ["gateway.networking.k8s.io"]
    resources: ["gateways"]
    verbs: ["get", "list", "watch"]
  # Read DNSZones for zone validation
  - apiGroups: ["bindy.firestoned.io"]
    resources: ["dnszones"]
    verbs: ["get", "list", "watch"]
  # Read Namespace labels for --namespace-selector / BINDY_SCOUT_NAMESPACE_SELECTOR.
  # Low-sensitivity (name/labels only, no Secret data).
  - apiGroups: [""]
    resources: ["namespaces"]
    verbs: ["get", "list"]
  # NOTE: deliberately no Secret rule here. See "RBAC for Phase 2 (Multi-Cluster) Mode"
  # below for the namespaced, resourceNames-restricted Role used instead.
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
  name: bindy-scout
subjects:
  - kind: ServiceAccount
    name: bindy-scout
    namespace: bindy-system
roleRef:
  kind: ClusterRole
  name: bindy-scout
  apiGroup: rbac.authorization.k8s.io
---
# Separate Role for writing ARecords in the target namespace
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: bindy-scout-writer
  namespace: bindy-system
rules:
  - apiGroups: ["bindy.firestoned.io"]
    resources: ["arecords"]
    verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: bindy-scout-writer
  namespace: bindy-system
subjects:
  - kind: ServiceAccount
    name: bindy-scout
    namespace: bindy-system
roleRef:
  kind: Role
  name: bindy-scout-writer
  apiGroup: rbac.authorization.k8s.io

RBAC for Phase 2 (Multi-Cluster) Mode

If — and only if — you run Scout with BINDY_SCOUT_REMOTE_SECRET set, Scout additionally needs to read that one kubeconfig Secret. This is granted by a separate, namespaced, resourceNames-restricted Role/RoleBinding — not a cluster-wide grant on the main bindy-scout ClusterRole. Apply this only if you use Phase 2 mode (deploy/scout/secrets-reader-rbac.yaml, or automatically via bindy bootstrap scout --remote-secret <name>):

apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: bindy-scout-secrets-reader
  namespace: bindy-system
rules:
  # resourceNames restricts this to exactly the one kubeconfig Secret — not every
  # Secret in this namespace, and not any Secret in any other namespace.
  - apiGroups: [""]
    resources: ["secrets"]
    resourceNames: ["bindy-scout-remote-kubeconfig"]
    verbs: ["get"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: bindy-scout-secrets-reader
  namespace: bindy-system
subjects:
  - kind: ServiceAccount
    name: bindy-scout
    namespace: bindy-system
roleRef:
  kind: Role
  name: bindy-scout-secrets-reader
  apiGroup: rbac.authorization.k8s.io

bindy bootstrap scout --remote-secret <name> provisions this automatically, with resourceNames set to the exact Secret name you passed. Same-cluster-only deployments (the default — no --remote-secret) get no Secret access whatsoever.


Gateway API Routes (HTTPRoute, TLSRoute, and TCPRoute)

In addition to watching Ingress resources, Scout also supports Gateway API routes: HTTPRoute, TLSRoute, and TCPRoute from the Gateway API. These resources provide a more modern, flexible alternative to Ingress with better separation of concerns.

Scout treats HTTPRoute, TLSRoute, and TCPRoute similarly to Ingress:

  • Watches all HTTPRoute/TLSRoute/TCPRoute resources cluster-wide (excluding its own namespace)
  • Requires the same bindy.firestoned.io/scout-enabled: "true" opt-in annotation
  • Uses the same annotation scheme for zone, IP, and TTL configuration
  • HTTPRoute / TLSRoute: creates one ARecord per hostname in spec.hostnames[] with an index suffix
  • TCPRoute: has no spec.hostnames[] field — creates one ARecord using the bindy.firestoned.io/record-name annotation (required)

Why Use Gateway API Routes?

  • HTTPRoute: Provides advanced HTTP routing (path-based, method-based, header matching) without the Ingress resource limitations
  • TLSRoute: For TLS-only traffic (non-HTTP protocols over TLS, gRPC, custom protocols), Scout ensures DNS records are created for all declared hostnames
  • TCPRoute: For TCP-based services such as databases, proxies, or other L4 traffic. TCPRoute has no spec.hostnames[] field, so the record name must be supplied via bindy.firestoned.io/record-name. Scout creates exactly one ARecord.

Quick Example: HTTPRoute

apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  name: api-gateway
  namespace: my-app
  annotations:
    bindy.firestoned.io/scout-enabled: "true"
    bindy.firestoned.io/zone: "api.example.com"
    bindy.firestoned.io/ip: "192.168.1.100"
spec:
  hostnames:
    - api.example.com
    - secure-api.example.com
  parentRefs:
    - name: my-gateway
      namespace: my-app
  rules:
    - backendRefs:
        - name: api-service
          port: 8080

Scout will create two ARecords: - scout-{cluster}-my-app-api-gateway-0 for api.example.com - scout-{cluster}-my-app-api-gateway-1 for secure-api.example.com

Quick Example: TLSRoute

apiVersion: gateway.networking.k8s.io/v1
kind: TLSRoute
metadata:
  name: grpc-gateway
  namespace: my-app
  annotations:
    bindy.firestoned.io/scout-enabled: "true"
    bindy.firestoned.io/zone: "secure.example.com"
    bindy.firestoned.io/ip: "192.168.1.101"
spec:
  hostnames:
    - secure.example.com
    - grpc.example.com
  parentRefs:
    - name: my-gateway
      namespace: my-app
  rules:
    - backendRefs:
        - name: grpc-service
          port: 5051

Scout will create two ARecords: - scout-{cluster}-my-app-grpc-gateway-0 for secure.example.com - scout-{cluster}-my-app-grpc-gateway-1 for grpc.example.com

Quick Example: TCPRoute

apiVersion: gateway.networking.k8s.io/v1alpha2
kind: TCPRoute
metadata:
  name: db-gateway
  namespace: my-app
  annotations:
    bindy.firestoned.io/scout-enabled: "true"
    bindy.firestoned.io/zone: "example.com"
    bindy.firestoned.io/ip: "192.168.1.102"
    bindy.firestoned.io/record-name: "db"
spec:
  parentRefs:
    - name: my-gateway
      namespace: my-app
  rules:
    - backendRefs:
        - name: postgres
          port: 5432

Scout will create one ARecord for db.example.com.

Record Naming for Gateway Routes

Gateway API routes use spec.hostnames[] instead of spec.rules[].host. Scout creates one ARecord per hostname with an index suffix (0, 1, 2, ...):

Hostname Record CR Name
api.example.com (index 0) scout-prod-my-app-api-gateway-0
secure-api.example.com (index 1) scout-prod-my-app-api-gateway-1
grpc.example.com (index 0) scout-prod-my-app-grpc-gateway-0

The record name within the zone is derived identically to Ingress rules:

Hostname Zone Derived record name
api.example.com api.example.com @ (apex record)
secure-api.example.com api.example.com secure-api
deep.api.example.com api.example.com deep.api

Labels on Gateway Route ARecords

ARecords created from Gateway API routes carry similar labels to Ingress-derived records, with source-specific labels:

Label Value Purpose
bindy.firestoned.io/managed-by scout Identifies Scout as the manager
bindy.firestoned.io/source-cluster <BINDY_SCOUT_CLUSTER_NAME> Cluster where the route lives
bindy.firestoned.io/source-namespace Route namespace Source namespace for traceability
bindy.firestoned.io/source-name Route name Source route name
bindy.firestoned.io/zone Zone name Allows DNSZone.spec.recordsFrom label selectors to discover the record

This allows you to configure a DNSZone to pull in all ARecords created by Scout from both Ingress and Gateway API routes:

apiVersion: bindy.firestoned.io/v1beta1
kind: DNSZone
metadata:
  name: api-example-com
spec:
  zoneName: api.example.com
  recordsFrom:
    - labelSelector:
        matchLabels:
          bindy.firestoned.io/managed-by: scout
          bindy.firestoned.io/zone: api.example.com

RBAC for Gateway API Routes

Scout requires read access (get, list, watch) to HTTPRoute, TLSRoute, TCPRoute, and Gateway resources. Gateway read access lets Scout follow a route's parentRefs to the serving gateway and discover its external IP. Add this rule to the Scout ClusterRole:

---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: bindy-scout
rules:
  # ... existing Ingress and Service rules ...

  # Watch HTTPRoutes/TLSRoutes/TCPRoutes and read Gateways (Gateway API)
  - apiGroups: ["gateway.networking.k8s.io"]
    resources: ["httproutes", "tlsroutes", "tcproutes", "gateways"]
    verbs: ["get", "list", "watch"]

When gateway-chain IP resolution reads the LoadBalancer Service behind a Gateway, it uses the existing services get/list permissions (a namespace/<label-selector> target lists Services in the namespace).

Note: Gateway API resources are read-only for Scout. Scout does NOT add finalizers to routes/gateways or mutate them — it only reads spec/metadata/status and creates corresponding ARecords in the target namespace.


Namespace Exclusions

Scout always excludes its own namespace (POD_NAMESPACE) from Ingress watching. This prevents:

  • Scout from watching its own Ingresses (if any) and creating circular references.
  • Unintended ARecords from platform Ingresses in the bindy namespace.

To exclude additional namespaces:

env:
  - name: BINDY_SCOUT_EXCLUDE_NAMESPACES
    value: "kube-system,kube-public,kube-node-lease,monitoring"

Namespace Whitelisting (--namespace-selector)

By default — with no --namespace-selector / BINDY_SCOUT_NAMESPACE_SELECTOR set — Scout is eligible to act in every namespace in the cluster, gated only by each source object's own opt-in annotation (bindy.firestoned.io/scout-enabled: "true", etc.) and the namespace exclusion list above. In other words: any tenant in any namespace can cause Scout to create an ARecord, simply by annotating their own Ingress/Service/route object.

Running with no namespace selector configured is not recommended. Set one:

env:
  - name: BINDY_SCOUT_NAMESPACE_SELECTOR
    value: "bindy.firestoned.io/scout-enabled=true"

or via the CLI flag: --namespace-selector "bindy.firestoned.io/scout-enabled=true".

Once set, Scout checks this selector against the namespace's own labels (not the resource's annotations) before acting on anything inside it — a genuine two-factor opt-in: the namespace must carry the label and the individual resource must carry its annotation. Label the namespaces you want Scout active in:

kubectl label namespace team-web bindy.firestoned.io/scout-enabled=true

Any selector expression accepted by kubectl get ns -l <selector> works — this is not limited to the single-label example above (e.g. environment in (staging,prod) is equally valid).

What changes when a namespace is unlabeled

If a namespace stops matching the selector (label removed, or the selector is newly introduced on an existing deployment), Scout treats every source object in that namespace exactly as if its opt-in annotation had been removed: any ARecords it created are deleted and its finalizer is released, unblocking normal object deletion. This means introducing --namespace-selector on an existing Scout deployment is a behavior change — any namespace not labeled at the time will have its Scout-managed ARecords cleaned up. Label every namespace that should keep working before rolling out the selector, not after.

Deletion and remote-cluster failures

When an opted-in Ingress/Service/route is deleted, Scout first deletes the ARecords it created (on the remote Bindy cluster in Phase 2 mode) and then releases its finalizer. If that remote cleanup fails — a broken or expired Phase 2 kubeconfig, or a remote-cluster outage — Scout retries for a grace period (5 minutes) and, past it, releases the finalizer anyway rather than hold the source object in Terminating. This prevents a single unreachable remote from blocking deletion of tenant objects cluster-wide. When the grace period is exceeded, Scout logs an error noting that the remote ARecords may be orphaned and must be reconciled separately (they are otherwise reaped by Scout's label-based stale-record cleanup once the remote is reachable again).

What this does and does not reduce

Setting a namespace selector meaningfully shrinks Scout's day-to-day operating footprint and requires a platform-level action (labeling a namespace) before any tenant's opt-in annotation takes effect — a real, valuable second gate. It does not by itself shrink the ClusterRole's RBAC ceiling: bindy-scout still holds cluster-wide patch/update on Ingress/Service/route types, so a directly compromised ServiceAccount token could still, in principle, act outside the labeled namespaces for those types. (As of 2026-07-19, Scout's Secret access is no longer part of this ceiling at all — the former cluster-wide secrets: get grant was replaced with a namespaced, resourceNames-restricted Role scoped to the single Phase 2 kubeconfig Secret; see RBAC for Phase 2 Mode above.) See docs/src/security/threat-model.md for the full analysis.


Multi-Cluster Mode

In many platform engineering setups, the DNS operator lives on a dedicated infrastructure cluster — isolated from workload traffic, hardened, and controlled by the platform team. Application workloads run on separate clusters that have no direct RBAC access to the DNS cluster. Scout bridges this gap without requiring network tunnels or federated identity.

The Queen Bee and Her Scouts

Think of the architecture using the same honeybee metaphor:

  • The Queen Bee is the central Bindy operator running on the dedicated Queen Bee cluster. She manages all DNS infrastructure — DNSZone CRs, ARecord CRs, and the BIND9 instances that serve DNS.
  • Scout Bees run on each workload cluster. They watch local Ingress resources and fly their discoveries back to the Queen, depositing ARecord CRs directly into her hive.

Application teams annotate their Ingresses as normal. They never need to know a separate DNS cluster exists.

graph LR subgraph queen["🐝 Queen Bee — DNS Cluster"] QB[Bindy Operator] ARecord["ARecord CRs\n(bindy-system)"] BIND9[BIND9 DNS Server] QB -->|reconciles| ARecord ARecord -->|programs via RNDC| BIND9 end subgraph child1["🌸 Child Cluster A"] Scout1["Scout Bee"] Ingress1["Ingress resources"] Secret1["remote-kubeconfig\nSecret"] Ingress1 -->|watch event| Scout1 Scout1 --- Secret1 end subgraph child2["🌸 Child Cluster B"] Scout2["Scout Bee"] Ingress2["Ingress resources"] Secret2["remote-kubeconfig\nSecret"] Ingress2 -->|watch event| Scout2 Scout2 --- Secret2 end subgraph child3["🌸 Child Cluster C"] Scout3["Scout Bee"] Ingress3["Ingress resources"] Secret3["remote-kubeconfig\nSecret"] Ingress3 -->|watch event| Scout3 Scout3 --- Secret3 end Scout1 -->|"server-side apply ARecord\n(via queen kubeconfig)"| ARecord Scout2 -->|"server-side apply ARecord\n(via queen kubeconfig)"| ARecord Scout3 -->|"server-side apply ARecord\n(via queen kubeconfig)"| ARecord

Each Scout authenticates to the Queen Bee cluster using a dedicated service account kubeconfig stored as a Secret in its own cluster. A Scout on Cluster A cannot impersonate or interfere with the Scout on Cluster B — each has its own independently revocable credentials.


How the Multi-Cluster Connection Works

sequenceDiagram participant Ops as Platform Engineer participant Queen as Queen Bee Cluster participant Child as Child Cluster Ops->>Queen: bindy bootstrap mc --service-account bindy-scout-remote Queen->>Queen: Create SA + Role (ARecord CRUD, DNSZone read) Queen->>Queen: Create SA token Secret, wait for token Queen->>Queen: Build kubeconfig for SA Queen-->>Ops: Outputs remote-kubeconfig Secret YAML (stdout) Ops->>Child: kubectl apply -f - (pipe or file) Child->>Child: Store Secret as bindy.firestoned.io/remote-kubeconfig Note over Child: Scout Deployment uses BINDY_SCOUT_REMOTE_SECRET Child->>Child: Ingress annotated with scout-enabled: "true" Child->>Child: Scout watches Ingress event Child->>Queen: Scout applies ARecord via kubeconfig from Secret Queen->>Queen: ARecord reconciler programs BIND9

Setup Guide

1. Bootstrap credentials on the Queen Bee cluster

Run bindy bootstrap mc against the Queen Bee cluster (the cluster running the Bindy operator). It creates a dedicated service account, the minimal RBAC Role and RoleBinding, waits for the SA token, and outputs a bindy.firestoned.io/remote-kubeconfig Secret as YAML to stdout:

# Run against the Queen Bee cluster — outputs Secret YAML to stdout
bindy bootstrap mc \
  --service-account bindy-scout-remote \
  --namespace bindy-system

Expected output (stderr — progress only):

✓ Applied ServiceAccount bindy-scout-remote
✓ Applied Role bindy-scout-remote
✓ Applied RoleBinding bindy-scout-remote
✓ Applied SA token Secret bindy-scout-remote-token
⏳ Waiting for SA token to be populated...
✓ SA token ready

✓ Apply the above Secret to each child cluster:
    bindy bootstrap mc | kubectl --context=<child-cluster> apply -f -
Then set BINDY_SCOUT_REMOTE_SECRET=bindy-scout-remote-kubeconfig on the scout Deployment.

The YAML written to stdout is a Secret of type bindy.firestoned.io/remote-kubeconfig containing a kubeconfig key with the Queen Bee cluster credentials.

Use the default service account name

The default --service-account is bindy-scout-remote, which matches the RBAC manifests in deploy/scout/remote-cluster-rbac.yaml. Use a unique SA name per child cluster if you want independent credential revocation.

2. Apply the kubeconfig Secret to each child cluster

Pipe directly, or save to a file first:

# Point KUBECONFIG at the Queen Bee cluster first
export KUBECONFIG=~/.kube/queen-bee.yaml

bindy bootstrap mc | kubectl --context=child-cluster-a apply -f -
export KUBECONFIG=~/.kube/queen-bee.yaml
bindy bootstrap mc > /tmp/scout-kubeconfig.yaml

kubectl --context=child-cluster-a apply -f /tmp/scout-kubeconfig.yaml
kubectl --context=child-cluster-b apply -f /tmp/scout-kubeconfig.yaml
# Note: each cluster uses the same credentials (same SA on Queen Bee cluster)
# Use a unique --service-account per child cluster for independent revocation
export KUBECONFIG=~/.kube/queen-bee.yaml

# Cluster A gets its own SA on the Queen Bee cluster
bindy bootstrap mc --service-account bindy-scout-remote-cluster-a \
  | kubectl --context=child-cluster-a apply -f -

# Cluster B gets its own SA
bindy bootstrap mc --service-account bindy-scout-remote-cluster-b \
  | kubectl --context=child-cluster-b apply -f -

3. Configure Scout on the child cluster

Set two environment variables on the Scout Deployment to activate remote mode:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: bindy-scout
  namespace: bindy-system
spec:
  template:
    spec:
      containers:
        - name: scout
          image: ghcr.io/firestoned/bindy:latest
          args: ["scout", "--cluster-name", "cluster-a"]
          env:
            - name: BINDY_SCOUT_CLUSTER_NAME
              value: "cluster-a"
            - name: BINDY_SCOUT_NAMESPACE
              value: "bindy-system"
            - name: POD_NAMESPACE
              valueFrom:
                fieldRef:
                  fieldPath: metadata.namespace
            # --- Multi-cluster mode ---
            - name: BINDY_SCOUT_REMOTE_SECRET
              value: "bindy-scout-remote-kubeconfig"
            # Optional: if the Secret is in a different namespace than Scout
            # - name: BINDY_SCOUT_REMOTE_SECRET_NAMESPACE
            #   value: "bindy-system"
            - name: RUST_LOG
              value: "info"

When BINDY_SCOUT_REMOTE_SECRET is set, Scout loads the kubeconfig from that Secret and uses it for all ARecord creation and DNSZone validation — the Queen Bee cluster, not the local cluster.

Also grant RBAC to read this Secret

Setting the env var alone is not enough — Scout's ClusterRole grants no Secret access at all (see RBAC for Phase 2 Mode). Apply the namespaced secrets-reader Role too, with resourceNames matching the Secret name above:

kubectl --context=child-cluster-a apply -f deploy/scout/secrets-reader-rbac.yaml
or, if you manage Scout via bindy bootstrap scout, re-run it with --remote-secret bindy-scout-remote-kubeconfig to provision this automatically. Without it, Scout will fail to read the Secret with an RBAC-forbidden error.

4. Verify the connection

# Check Scout is running on the child cluster
kubectl --context=child-cluster-a get pods -n bindy-system -l app.kubernetes.io/component=scout

# Watch Scout logs — should see "remote mode" startup message
kubectl --context=child-cluster-a logs -n bindy-system -l app.kubernetes.io/component=scout -f

# Annotate an Ingress on the child cluster
kubectl --context=child-cluster-a annotate ingress my-app \
  bindy.firestoned.io/scout-enabled=true \
  bindy.firestoned.io/zone=example.com \
  -n my-app-ns

# Verify the ARecord was created on the Queen Bee cluster
kubectl --context=queen-bee get arecords -n bindy-system -l bindy.firestoned.io/source-cluster=cluster-a

RBAC: What bootstrap mc Creates

bindy bootstrap mc creates three resources on the Queen Bee cluster, all named after the service account:

Resource Kind Permissions
bindy-scout-remote ServiceAccount
bindy-scout-remote Role arecords: get, list, watch, create, update, patch, delete
bindy-scout-remote Role dnszones: get, list, watch (for zone validation)
bindy-scout-remote RoleBinding Binds the Role to the SA

The Role is namespace-scoped (defaults to bindy-system). Scout cannot read or modify any other namespace on the Queen Bee cluster. It cannot read Secrets, access Pods, or perform any cluster-wide operations.

A separate ServiceAccount-type token Secret (bindy-scout-remote-token) is created with the kubernetes.io/service-account.name annotation so the Kubernetes token controller populates a long-lived token. The token is embedded in the output kubeconfig.

One SA per child cluster (recommended)

Using a unique service account per child cluster (--service-account bindy-scout-remote-cluster-a) allows you to revoke access for one cluster by simply deleting its SA on the Queen Bee cluster, without affecting other clusters.


Multi-Cluster Configuration Reference

Variable Default Description
BINDY_SCOUT_REMOTE_SECRET Name of a Secret in the child cluster containing a kubeconfig key. When set, Scout uses that kubeconfig to create ARecord CRs and validate DNSZone resources on the Queen Bee cluster instead of the local cluster.
BINDY_SCOUT_REMOTE_SECRET_NAMESPACE Scout's own namespace (POD_NAMESPACE) Namespace where the remote kubeconfig Secret lives. Only set this if the Secret is in a different namespace than Scout itself.

Changing the Cluster Name

If you restart Scout with a different --cluster-name (or BINDY_SCOUT_CLUSTER_NAME), Scout automatically cleans up any ARecord CRs that were created by the old cluster name.

How It Works

ARecord CRs created by Scout carry a bindy.firestoned.io/source-cluster label with the cluster name that created them. The CR name also embeds the cluster name (e.g., scout-<cluster>-<namespace>-<ingress>-<idx>).

When Scout reconciles an Ingress under a new cluster name: 1. It creates new ARecord CRs under the new cluster name. 2. It selects all ARecord CRs for the same Ingress whose source-cluster label does not match the current cluster name. 3. It deletes those stale records automatically.

This happens on every reconcile, so all stale records are cleaned up on the next controller loop after the restart — no manual intervention required.

Example

# Scout was previously running with --cluster-name prod-a
# Rename the cluster:
kubectl set env deployment/bindy-scout BINDY_SCOUT_CLUSTER_NAME=prod-b -n bindy-system

# After the rollout, Scout logs will show:
# Deleted stale ARecord after cluster-name change
#   arecord=scout-prod-a-default-my-app-0
#   old_cluster=prod-a  new_cluster=prod-b

One cluster name per scout instance

All scouts writing to the same Queen Bee namespace must use unique cluster names. Duplicate cluster names cause one scout to overwrite the other's records.


Roadmap

Feature Status Roadmap
Same-cluster mode ✅ Complete bindy-scout-ingress-controller.md
Finalizer on Ingress ✅ Complete bindy-scout-ingress-controller.md
Remote cluster mode ✅ Complete bindy-scout-ingress-controller.md
LoadBalancer Service → ARecord ✅ Complete scout-service-watching.md
SRV records from Service ports and Ingress 🔲 Planned scout-srv-records.md
Namespace inclusion/exclusion via label selectors 🔲 Planned scout-namespace-selectors.md