Skip to content

Split Control/Data Plane deployment

Last updated:

Share PostgreSQL, keys, and workspaces, and route requests by Host role.

Split deployment runs management and scheduling in Control Plane and task execution in Data Plane. Use it when execution environments need separate maintenance or additional nodes. For a trial on one host, Standalone is simpler to configure.

This page assumes familiarity with containers, databases, and reverse proxies. Prepare shared PostgreSQL, Data Protection keys for decrypting credentials, and workspaces accessible to every execution node. The entry proxy must support WebSocket for conversation events.

Roles

HostResponsibility
Control PlaneSetup, Web, management APIs, Job scheduling
Data PlaneSignalR Execution, A2A, durable execution workers
StandaloneBoth roles combined for a single-server setup

Split deployment uses Distributed execution. In this mode, turns that run an External Agent (Claude Code, Codex, or Pi) directly in Chat fail with “Distributed execution currently supports System Agents only.” To use those external agents directly, choose Standalone with InProcess execution.

Split deployments require PostgreSQL for the database and locks, plus Distributed execution on both roles. SQLite or in-memory locks cannot replace cross-node coordination.

Database__Provider: postgres
Database__ConnectionString: "${AGW_DATABASE_CONNECTION_STRING}"
Execution__Provider: Distributed
DistributedLock__Provider: postgres
DistributedLock__ConnectionString: ""

This is an environment configuration fragment for both roles. An empty lock connection string reuses the database connection. Supply the real database connection string through Secrets.

Startup and routing

  1. Configure the database, both Hosts, shared keys, and directories using the cluster Compose reference.
  2. Start Control Plane first, initialize it, and confirm readiness: GET /api/health/ready returns 503 until the Host is initialized and can reach its database, then 200; GET /api/health/live only shows that the process is running. Neither requires sign-in.
  3. Start Data Plane, then add replicas as needed.
  4. Route /api/hubs/exec, /a2a/*, and /.well-known/agents.json to Data Plane; route other application paths to Control Plane.

Preserve Host, authentication headers/Cookies, and WebSocket Upgrade. Exclude execution Hub query strings from proxy access logs. Control Plane does not serve A2A.

Reading the examples

For Docker Compose, start with the cluster Compose file linked below and follow the startup order and routes above. For Kubernetes, the next section uses kind, which runs a local Kubernetes cluster in containers. Pods run applications, Services provide access addresses, and PVs/PVCs declare and request storage.

Both approaches need a shared client entry point. The Nginx section shows which requests go to each role. Verify the services and database first, then routing, to distinguish service failures from proxy failures.

Kubernetes YAML examples

The repository’s deploy/k8s directory provides a local, single-node kind example. It uses separate Control Plane and Data Plane Deployments, an external PostgreSQL database, and NodePorts that can connect to the Nginx configuration below. These files do not create PostgreSQL or an Ingress Controller.

FilePurpose
kind-agw-cluster.yamlCreate the local kind cluster with port and directory mappings
agw-data-pv-pvc.yamlProvide a data volume shared by Pods on the same node
agw-control-plane-deployment.yamlOne Control Plane replica and a NodePort Service
agw-data-plane-deployment.yamlTwo Data Plane replicas and a NodePort Service

Cluster entry points and shared directory

kind-agw-cluster.yaml maps both NodePorts to the host loopback address and mounts /opt/agw into the kind node:

kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
nodes:
  - role: control-plane
    extraPortMappings:
      - containerPort: 30816
        hostPort: 30816
        listenAddress: "127.0.0.1"
        protocol: TCP
      - containerPort: 30820
        hostPort: 30820
        listenAddress: "127.0.0.1"
        protocol: TCP
    extraMounts:
      # Required by agw-data-pv: expose the host directory inside the kind node.
      - hostPath: /opt/agw
        containerPath: /opt/agw

Prepare /opt/agw/agw-data on the container runtime host before creating the cluster. With a Docker/Podman VM, configure file sharing so the directory is available inside the VM. The control-plane node role here belongs to Kubernetes; it is distinct from AGW’s Control Plane service.

The storage path is:

Host /opt/agw/agw-data
  → kind node /opt/agw/agw-data
  → PV agw-data-pv → PVC agw-data
  → /data in Control/Data Plane Pods

The corresponding PV/PVC follows. Retain preserves reclaimed volume data but does not replace backups. The PVC requests 1Gi, while the PV declares 5Gi capacity.

apiVersion: v1
kind: PersistentVolume
metadata:
  name: agw-data-pv
  labels:
    app: agw
spec:
  capacity:
    storage: 5Gi
  accessModes:
    - ReadWriteOnce
  persistentVolumeReclaimPolicy: Retain
  storageClassName: manual
  hostPath:
    # Node path backed by kind-agw-cluster.yaml extraMounts; single-node use only.
    path: /opt/agw/agw-data
    type: DirectoryOrCreate
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: agw-data
spec:
  accessModes:
    - ReadWriteOnce
  storageClassName: manual
  resources:
    requests:
      storage: 1Gi
  volumeName: agw-data-pv

ReadWriteOnce permits multiple Pods on the same node to mount the volume, covering both roles and Data Plane replicas in this example. hostPath is not shared storage across nodes. For multiple nodes, use shared storage supported by your cluster and keep keys, credentials, and Project workspace paths consistent across execution nodes. Add mounts for workspaces located outside /data.

Data Plane Deployment and Service

This is the repository’s complete Data Plane example. It runs two replicas on container port 8080, reads the agw-database Secret, and exposes Service port 30820. The Control Plane file uses the same volume and database settings, with one replica and NodePort 30816. It also reads password from agw-admin as Setup__AdminPassword.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: agw-data-plane
  labels:
    app: agw-data-plane
spec:
  replicas: 2
  selector:
    matchLabels:
      app: agw-data-plane
  template:
    metadata:
      labels:
        app: agw-data-plane
    spec:
      containers:
        - name: agw-data-plane
          image: localhost/agw-data-plane:local
          imagePullPolicy: Never
          env:
            - name: AgwLogDir
              value: /data/logs
            - name: AgwDataDir
              value: /data
            - name: ASPNETCORE_ENVIRONMENT
              value: Production
            - name: ASPNETCORE_URLS
              value: http://0.0.0.0:8080
            - name: DistributedLock__Provider
              value: postgres
            - name: Execution__Provider
              value: Distributed
            - name: Database__Provider
              value: postgres
            - name: Database__ConnectionString
              valueFrom:
                secretKeyRef:
                  name: agw-database
                  key: connection-string
          ports:
            - name: http
              containerPort: 8080
              protocol: TCP
          securityContext:
            # The local hostPath is owned by the host user with mode 0700; preserve the
            # current local Podman setup's root access so the server can write /data.
            runAsUser: 0
            runAsGroup: 0
            runAsNonRoot: false
          volumeMounts:
            - name: agw-data
              mountPath: /data
      volumes:
        - name: agw-data
          persistentVolumeClaim:
            claimName: agw-data
---
apiVersion: v1
kind: Service
metadata:
  name: agw-data-plane
  labels:
    app: agw-data-plane
spec:
  type: NodePort
  sessionAffinity: ClientIP
  sessionAffinityConfig:
    clientIP:
      timeoutSeconds: 10800
  selector:
    app: agw-data-plane
  ports:
    - name: http
      protocol: TCP
      port: 30820
      targetPort: http
      nodePort: 30820

Before using it, check:

  • Images: localhost/agw-…:local with imagePullPolicy: Never requires images to be built and loaded into kind first. For registry images, use reachable image addresses and versions, with an appropriate pull policy and credentials.
  • Database: both roles must use the same PostgreSQL database reachable from the Pods. localhost in a connection string refers to the Pod itself, usually not the host database.
  • Permissions: the local example runs as root to accommodate its directory permissions. Set an appropriate UID/GID for your storage in other environments instead of copying this local setting unchanged.
  • Connection affinity: the Service uses ClientIP affinity to help SignalR requests reach the same Pod. With Nginx outside the cluster, multiple clients may appear as one proxy IP, so this does not guarantee even load distribution.

Deployment order

Prepare the local images, data directory, and files containing the two Secret values, then run from the repository root. Secret files should contain only the relevant values; keep real credentials out of Git. These commands use the default namespace of the current kubectl context. If you choose another namespace, keep Deployments, Services, PVCs, and Secrets together.

# Run from the repository root, after preparing /opt/agw/agw-data.
kind create cluster --name agw --config deploy/k8s/kind-agw-cluster.yaml
kubectl apply -f deploy/k8s/agw-data-pv-pvc.yaml

# These images must already exist in the local container runtime.
kind load docker-image --name agw \
  localhost/agw-control-plane:local localhost/agw-data-plane:local

# Replace these paths with files containing the actual secret values.
kubectl create secret generic agw-database \
  --from-file=connection-string=/secure/agw-database-connection-string
kubectl create secret generic agw-admin \
  --from-file=password=/secure/agw-admin-password

kubectl apply -f deploy/k8s/agw-control-plane-deployment.yaml
kubectl rollout status deployment/agw-control-plane
kubectl logs deployment/agw-control-plane --tail=100

# Continue only after Control Plane initialization has completed.
kubectl apply -f deploy/k8s/agw-data-plane-deployment.yaml
kubectl rollout status deployment/agw-data-plane
kubectl get pods,services,pvc

rollout status confirms the Deployment rollout, not application initialization. In this example, Control Plane’s Setup__AdminPassword triggers first-run initialization. Check logs and the sign-in page before starting Data Plane. Existing database authentication settings are not overwritten by this initial password.

With the kind mappings above, the upstreams in the Nginx example below can use 127.0.0.1:30816 and 127.0.0.1:30820. Nginx inside the cluster can instead use agw-control-plane:30816 and agw-data-plane:30820 in the same namespace. After checking that the PVC is Bound and Pods are running, verify login, execution connections, and which nodes receive requests.

Do not run kubectl apply -f deploy/k8s/: the kind Cluster file is input to kind, not a Kubernetes API resource. Changing kind port or mount mappings requires recreating the cluster; back up data first. See the local kind deployment guide for the complete source instructions.

Nginx configuration example

In this example, Nginx provides one entry point, Control Plane listens on 30816, and Data Plane on 30820, matching the kind example above; the repository’s deploy/nginx.split.conf.example and cluster Compose example use 30817 for Data Plane. Replace these example ports with the actual Host listeners. For separate hosts or containers, replace 127.0.0.1 with addresses reachable from Nginx.

Web hosted by Control Plane

Save this as a site configuration included from the http {} block in nginx.conf. map, log_format, and upstream must not be nested inside server {}. Log paths are relative to the Nginx prefix; create the directories or use writable absolute paths.

# Included inside nginx.conf's http {} block.
map $http_upgrade $agw_connection_upgrade {
    default upgrade;
    ''      close;
}

# Do not log query strings, which may contain an execution token.
log_format agw_route '$remote_addr [$time_local] '
                     '"$request_method $uri $server_protocol" $status '
                     'upstream=$upstream_addr upstream_status=$upstream_status';

upstream agw_control_plane {
    server 127.0.0.1:30816;
}

upstream agw_data_plane {
    ip_hash;
    server 127.0.0.1:30820;
    # server 127.0.0.1:30821;
}

server {
    listen 80;
    server_name agw.example.com;

    client_max_body_size 100m;
    access_log logs/agw_access.log agw_route;
    error_log  logs/agw_error.log warn;

    proxy_http_version 1.1;
    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-Host $host;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection $agw_connection_upgrade;
    proxy_read_timeout 3600s;
    proxy_send_timeout 3600s;

    # Includes /api/hubs/exec/negotiate and the WebSocket endpoint.
    location ^~ /api/hubs/exec {
        proxy_buffering off;
        proxy_pass http://agw_data_plane;
    }

    location ^~ /a2a/ {
        proxy_buffering off;
        proxy_pass http://agw_data_plane;
    }

    location = /.well-known/agents.json {
        proxy_pass http://agw_data_plane;
    }

    # Setup, management APIs, OpenAPI, and the hosted Web client.
    location / {
        proxy_pass http://agw_control_plane;
    }
}

The example uses HTTP for local verification. For external access, configure listen 443 ssl;, ssl_certificate, and ssl_certificate_key in this server, using your domain and valid certificate, and redirect HTTP to HTTPS.

RequestDestinationPurpose
/api/hubs/exec and child pathsData PlaneSignalR negotiation and execution connections
/a2a/*Data PlaneA2A requests and streaming responses
/.well-known/agents.jsonData PlaneAgent discovery
Other pathsControl PlaneSetup, management APIs, OpenAPI, Web pages, and static assets

The proxy_pass directives have no URI suffix, preserving the original path and query parameters. Authentication headers and Cookies are forwarded by default. Upgrade headers and HTTP/1.1 support WebSockets. Disabling response buffering on execution and A2A routes lets streaming output reach clients promptly. The 3600s values are proxy read/write timeouts, not a guarantee of uninterrupted tasks of any duration.

With multiple Data Plane instances, ip_hash aims to keep requests from one IP on the same instance so SignalR negotiation and subsequent connections reach the same node. It does not replace shared database, execution-state, and recovery configuration. If another proxy sits in front of Nginx, configure trusted proxies and client-IP handling for your network rather than trusting arbitrary X-Forwarded-For headers.

The access log uses $uri without query parameters. Its upstream field helps identify the receiving node. client_max_body_size controls only Nginx’s request limit; it does not increase AGW’s attachment limits.

A separate Web service

If Web runs separately on 3001, retain the Data Plane routes and common proxy settings above. Add the agw_web upstream and the management routes below, replacing the original location /. Port 3001 is the repository’s Web development port; use your Web service’s actual port in deployment.

# Add inside http {}, alongside the other upstream blocks.
upstream agw_web {
    server 127.0.0.1:3001;
}

# Add these locations inside the existing server {}.
location /api/ {
    proxy_pass http://agw_control_plane;
}
location /openapi/ {
    proxy_pass http://agw_control_plane;
}
location = /setup {
    proxy_pass http://agw_control_plane;
}
location /setup/ {
    proxy_pass http://agw_control_plane;
}

# Replace the existing location /; do not add a second one.
location / {
    proxy_pass http://agw_web;
}

Both /setup and /setup/ reach Control Plane, as do ordinary /api/ requests. The longer /api/hubs/exec match still reaches Data Plane. Configure the separate Web service’s own backend address to point to Control Plane. Clients should use the shared Nginx entry point.

Check and load the configuration

After saving your deployment configuration, validate it before reloading:

nginx -t
nginx -s reload

Run the reload in your own deployment environment. Verify login and page assets. In browser network tools, check for a successful WebSocket upgrade (101) on the execution connection and confirm its upstream is Data Plane in the access log. Management APIs should reach Control Plane. For login failures, check Cookies, forwarded scheme, and application proxy-trust settings. For execution connection failures, check Upgrade headers, routing, and the Data Plane port.

Verify

Test login, a Chat run, and a Job in order, inspecting the actual execution node. All Hosts read initialization and authentication from the same database. Recovery also requires consistent directories, keys, and runtime credentials; running containers alone do not prove readiness.

Implementation and references