Device Enrollment Guide¶
This guide walks through bringing a new controller device (an x86_64 industrial PC or equivalent) into the control system. Enrollment has two distinct halves, and they are owned by different layers (ADR 0004):
- Cluster join — making the device a Kubernetes node. This is the deployment layer's job, done with whatever mechanism your cluster distribution provides (Talos machine config, k3s token, cloud nodegroup). The product takes no part in it.
- DCS registration — binding that node to a Controller resource in a site, labeling it for unit scheduling, and tracking its health. This is the product's job and is what the rest of this guide covers.
Next step after enrollment
Once the device is joined and reports Joined, the next step is to
attach I/O. See I/O
for the end-to-end workflow from IOModule authoring to a phase
running against real equipment.
sequenceDiagram
participant Deploy as Deployment layer (talosctl, …)
participant K8s as Kubernetes
participant Op as Physical operator
participant UI as Gateway UI / dcs CLI
Deploy->>K8s: 1. Join node (machine config / join token)
Note over Deploy,K8s: node carries dcs.io/site=<site> label
K8s-->>Op: 2. Node appears
Op->>Op: 3. Auto-create Controller CR (node discovery)
UI-->>Op: (or: Controller CR pre-created by name)
Op->>K8s: 4. Reconcile spec.nodeLabels onto node
Op->>UI: 5. Controller: Pending -> Joining -> Joined
Using the reference plant? The
plant-01plant uses simulation controllers (spec.simulation: true) that skip enrollment entirely — the physical operator creates a virtual Kubernetes node and maintains its heartbeat lease, no hardware involved. This guide is only relevant if you are enrolling a real edge device against your own control plane. See Deploy Your Own for the self-host track.
Prerequisites¶
| Component | Notes |
|---|---|
| Cluster-admin access via your distro's tooling | e.g. talosctl + machine config for Talos, a join token for k3s |
| DCS operators + gateway installed | Via the Helm chart |
| Enterprise + Site created | So that the site-<name> namespace exists |
| Device meets node requirements | x86_64, able to pull the unit-runtime image, host ports outside 32768–60999 free (see Architecture § Port Allocation Policy) |
Step 1: Join the node (deployment layer)¶
Join the device to the cluster with your distribution's own mechanism. The product does not broker join credentials. Node join is a change-managed infrastructure activity, performed with the same tooling that built the cluster.
For Talos (the reference deployments), generate a worker machine config and apply it, including the DCS labels at join time:
# patch.yaml — Talos worker machine config patch
machine:
nodeLabels:
dcs.io/site: newark-plant # triggers auto-discovery (Step 2, Option A)
dcs.io/device: edge-mixing-1 # optional: unit scheduling label
talosctl machineconfig patch worker.yaml --patch @patch.yaml -o node.yaml
talosctl apply-config --insecure -n <device-ip> --file node.yaml
The golden-path join runbook for the reference deployment lives in the deploy-reference repository, because the runbook is a deployment concern. For any other distribution, any join mechanism is fine. The product only sees the resulting Node object.
Step 2: Register the Controller¶
A Controller resource in the site namespace represents the device. There are two equivalent ways to create it. Pick one:
Option A: label-driven auto-discovery (recommended). If the node
joined carrying the dcs.io/site=<site> label (as in Step 1), the
physical operator discovers it and creates the Controller automatically:
- The Controller is named after the node and created in
site-<site>. - Any
dcs.io/*labels already on the node are captured intospec.nodeLabels. - The Controller is annotated
dcs.io/discovery: auto. Removing the site label from the node cleans it up again. - Control-plane nodes are never auto-discovered.
Option B: pre-create the Controller by name. Create the Controller before (or after) the join. The reconciler binds it to the cluster node of the same name as soon as that node appears. Use this when the device's identity should exist in the DCS (and in GitOps) ahead of the physical install:
- Open the gateway UI in your browser.
- Select a Site from the sidebar.
- Click the Controllers tab.
- Click + Add Controller.
- Enter:
- Name — must equal the Kubernetes node name the device will
join with (e.g.,
edge-mixing-1). - Node labels — optional, comma-separated
key=valuepairs (e.g.,dcs.io/device=edge-mixing-1).
- Name — must equal the Kubernetes node name the device will
join with (e.g.,
- Click Create.
dcs create controller --name edge-mixing-1 \
--labels dcs.io/device=edge-mixing-1 \
-s newark-plant
POST /api/v1/sites/newark-plant/controllers
Content-Type: application/json
{
"name": "edge-mixing-1",
"nodeLabels": {
"dcs.io/device": "edge-mixing-1"
}
}
Step 3: Node labels for unit scheduling¶
Controller.spec.nodeLabels is the product-side source of truth for the
device's DCS labels. The physical operator converges these labels onto
the bound node. You do not need to run kubectl label, and the labels
do not have to be present at join time:
- Only labels under the
dcs.io/prefix are reconciled. The operator never touches other node labels. - Labels the operator applied earlier but that were removed from
spec.nodeLabelsare removed from the node again (tracked via thephysical.dcs.io/applied-node-labelsannotation). - Editing node labels in the UI or via
PATCH /api/v1/sites/{site}/controllers/{name}takes effect on the live node.
The key convention is dcs.io/device=<unit-name>: a Unit whose
spec.nodeSelector names that label gets its runtime pod scheduled onto
this device. See Architecture for the scheduling
model.
Step 4: Watch the Controller join¶

In the gateway UI, the Controller transitions through phases automatically:
| Phase | What's happening |
|---|---|
| Pending | Controller created; transitions to Joining immediately |
| Joining | Waiting for a cluster node with the Controller's name to appear |
| Joined | Node found; labels reconciled; address discovered |
The Address column populates with the device's IP (discovered from the node's InternalIP) once the Controller reaches Joined.
Phase is an adoption lifecycle, so it answers whether the device has been
taken into the control system. It is not a health readout, and it stays at
Joined if the device later stops answering, because an unreachable device is
still an adopted one. The Health column beside it is the readout for right
now: Healthy when the bound node is Ready, Degraded when the node is Ready
but the I/O probe is not running, and Offline when the node has stopped
reporting Ready. Hovering the health dot gives the reason behind it.
You can also watch from the CLI:
dcs get controllers -s <sitename>
# NAME ADDRESS PHASE HEALTH NODE
# edge-mixing-1 10.0.0.100 Joined healthy edge-mixing-1
Removing a Controller¶
Whichever path you use, the reconciler:
- Cordons the node (marks unschedulable)
- Drains the node, evicting every pod that is neither owned by a DaemonSet nor a kubelet mirror pod, and waits for those pods to leave
- Deletes the node from the control system, once the drain has finished
The drain decides whether step 3 happens at all. An eviction that a
PodDisruptionBudget refuses, or a pod still inside its termination grace
period, leaves workloads on the node, and the removal refuses to delete
a node out from under them. The Controller stays in Removing and
carries a NodeDrained condition naming the pods that are holding it back,
and the reconciler keeps retrying until they move. After five minutes the
condition reason becomes DrainTimedOut and the retry slows down, but the
removal is still refused until the node is empty.
/system → pick the site → Controllers tab →
click Remove next to the controller row → confirm the
dialog.
dcs delete controller edge-mixing-1 -s newark-plant
DELETE /api/v1/sites/newark-plant/controllers/edge-mixing-1
Note: removing the Controller does not unconfigure the machine itself. Decommission the node with your deployment layer's tooling (e.g.
talosctl reseton Talos), mirroring how it was joined.
Troubleshooting¶
Use the Diagnostics page to check service health and per-site runtime status when debugging enrollment issues:

| Symptom | Fix |
|---|---|
| Node joined but no Controller appeared | Auto-discovery needs the dcs.io/site=<site> node label and an existing Site CR of that name. Check both, or pre-create the Controller (Option B). |
| Controller stuck in Joining | No cluster node named exactly like the Controller exists yet. Check the join on the deployment side (kubectl get nodes); verify the node name matches the Controller name. |
| Controller goes to Failed | Node disappeared or never appeared. Check the Diagnostics page and device connectivity. |
| Controller stuck in Removing | The node has not drained. Read the NodeDrained condition (kubectl describe controller <name> -n site-<sitename>): it names the pods still on the node and the ones a PodDisruptionBudget refused to evict. Move or scale those workloads, or relax the budget, and the removal finishes on its own. |
| Unit runtime not scheduling onto the device | Verify dcs.io/device=<unit-name> is in the Controller's spec.nodeLabels and shows on the node (kubectl get node <name> --show-labels), and that the Unit's nodeSelector matches. |
| Labels edited in the UI but not on the node | Only dcs.io/-prefixed labels are reconciled onto the node. Anything else in spec.nodeLabels is intentionally left alone. |
History: token-based enrollment (removed)¶
Versions before June 2026 shipped an in-product enrollment flow: the
gateway minted single-use tokens, a dcs-enroll agent on the device
redeemed them via POST /api/v1/enroll, and the device installed a k3s
agent from the returned join credentials. That flow only worked on k3s
clusters and was removed in favor of the deployment-layer contract
above. See ADR 0004
for the rationale.