> ## Documentation Index
> Fetch the complete documentation index at: https://docs.controlplane.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Deploy a Workload to Your Own Hardware

> Turn bare metal servers, on-premises VMs, or a Kubernetes cluster you run into a location that GVCs deploy workloads to.

A [location](https://docs.controlplane.com/concepts/location.md) backed by your own servers takes the place of a cloud region: Control Plane deploys every [workload](https://docs.controlplane.com/concepts/workload.md) in a [GVC](https://docs.controlplane.com/concepts/gvc.md) that spans it onto your hardware, and one GVC can span it and cloud regions at once. It is a [CPLN BYOK](https://docs.controlplane.com/byok/overview.md) location, so its workloads reach AWS, GCP, and Azure services through [Universal Cloud Identity](https://docs.controlplane.com/concepts/identity.md), and private addresses in your network through their external [firewall](https://docs.controlplane.com/reference/workload/firewall.md).

## Pick a Path for Your Hardware

| What you have | What you do |
| :- | :- |
| A Kubernetes cluster you run, such as k3s, RKE2, kubeadm, EKS, GKE, or AKS | Apply the location's install command to the cluster |
| Linux servers or VMs with no cluster | Build a cluster from them with the [Generic provider](https://docs.controlplane.com/mk8s/generic.md) of [Managed Kubernetes](https://docs.controlplane.com/mk8s/overview.md), and let its [`CPLN Platform` add-on](https://docs.controlplane.com/mk8s/add-ons/byok) install the location |

## Prerequisites

* The `create` and `use` [permissions](https://docs.controlplane.com/reference/location.md#permissions) on locations. For the Linux servers path, also `create` and `clusterAdmin` on Managed Kubernetes clusters.
* The [`cpln` CLI](https://docs.controlplane.com/cli-reference/installation) and `kubectl`.
* Capacity for the BYOK components, which run on your nodes beside your workloads: at least 2 nodes with 2 CPUs and 8 GB of memory each, as listed with the rest of the [BYOK requirements](https://docs.controlplane.com/byok/overview.md#requirements).
* For an existing cluster: at least one node labeled `cpln.io/nodeType=core`, a load balancer controller that assigns `LoadBalancer` services an address, a default StorageClass, and no service mesh.
* For Linux servers: each server meets the [Generic provider server requirements](https://docs.controlplane.com/mk8s/generic.md#step-1-server-setup).
* Outbound internet access from every node. For an air-gapped site, contact [support@controlplane.com](mailto:support@controlplane.com).

## Create the Location

> [tab] Console
>
> **1. Open the location form**
>
> Click `Locations` in the left menu, then click `New`.
>
> **2. Create the location**
>
> Enter a name such as `my-hardware` and click `Create`. The location page opens.

> [tab] CLI
>
> ```bash theme={null}
> cpln location create --name my-hardware
> ```

> [tab] Terraform
>
> ```hcl theme={null}
> resource "cpln_custom_location" "my_hardware" {
>   name           = "my-hardware"
>   cloud_provider = "byok"
>   enabled        = true
> }
> ```

> [tab] Pulumi
>
> > [tab] TypeScript
> >
> > ```typescript theme={null}
> > import * as cpln from "@pulumiverse/cpln";
> >
> > const myHardware = new cpln.CustomLocation("my-hardware", {
> >   name: "my-hardware",
> >   cloudProvider: "byok",
> >   enabled: true,
> > });
> > ```
>
> > [tab] Python
> >
> > ```python theme={null}
> > import pulumiverse_cpln as cpln
> >
> > my_hardware = cpln.CustomLocation("my-hardware",
> >     name="my-hardware",
> >     cloud_provider="byok",
> >     enabled=True)
> > ```
>
> > [tab] Go
> >
> > ```go theme={null}
> > package main
> >
> > import (
> > 	"github.com/pulumi/pulumi/sdk/v3/go/pulumi"
> > 	"github.com/pulumiverse/pulumi-cpln/sdk/go/cpln"
> > )
> >
> > func main() {
> > 	pulumi.Run(func(ctx *pulumi.Context) error {
> > 		_, err := cpln.NewCustomLocation(ctx, "my-hardware", &cpln.CustomLocationArgs{
> > 			Name:          pulumi.String("my-hardware"),
> > 			CloudProvider: pulumi.String("byok"),
> > 			Enabled:       pulumi.Bool(true),
> > 		})
> > 		return err
> > 	})
> > }
> > ```
>
> > [tab] C#
> >
> > ```csharp theme={null}
> > using Pulumi;
> > using Pulumiverse.Cpln;
> >
> > return await Deployment.RunAsync(() =>
> > {
> >     var myHardware = new CustomLocation("my-hardware", new CustomLocationArgs
> >     {
> >         Name = "my-hardware",
> >         CloudProvider = "byok",
> >         Enabled = true
> >     });
> > });
> > ```

## Connect Your Hardware

> [tab] Existing Cluster
>
> **1. Label the core nodes**
>
> BYOK runs its critical system pods on nodes labeled `cpln.io/nodeType=core`. Label at least one:
>
> ```bash theme={null}
> kubectl label node my-node-1 cpln.io/nodeType=core
> ```
>
> **2. Generate the install command**
>
> In the Console, open the location and choose `Actions` > `Install`. The `Install Location` dialog shows the command and the time left to apply it under `Expires In`. With the CLI:
>
> ```bash theme={null}
> cpln location install my-hardware
> ```
>
> The first line of the output is the deadline:
>
> ```text theme={null}
> # this commmand must be performed before 11/3/2026, 9:48:00 AM
> kubectl apply -f 'https://byok-api.cpln.io/install/download/.../byok-agent.k8s.yaml'
> ```
>
> The link downloads the location's install token, so treat the command as a secret.
>
> **3. Apply the command to the cluster**
>
> With `kubectl` pointed at the cluster, run the `kubectl apply` command before its deadline. It creates the `cpln-byok-agent` deployment in the `kube-system` namespace.
>
> **4. Wait for the agent**
>
> ```bash theme={null}
> kubectl get pod -l app=cpln-byok-agent -n kube-system
> ```
>
> ```text theme={null}
> NAME                               READY   STATUS    RESTARTS   AGE
> cpln-byok-agent-764ddcfd74-lpr6d   1/1     Running   0          9s
> ```
>
> The agent then installs the rest of the BYOK components into namespaces of their own, such as `controlplane`, `istio-system`, and `monitoring`. Rerun `kubectl get pods -A` until every pod reports `Running` or `Completed`.
>
> To serve the same location from another cluster, repeat these steps on that cluster.

> [tab] Linux Servers
>
> **1. Write the cluster manifest**
>
> Save this as `my-cluster.yaml`:
>
> ```yaml theme={null}
> kind: mk8s
> name: my-cluster
> spec:
>   version: 1.35.3
>   provider:
>     generic:
>       location: aws-eu-central-1
>       nodePools:
>         - name: general
>           labels:
>             cpln.io/nodeType: core
>   addOns:
>     byok:
>       location: //location/my-hardware
>   firewall:
>     - description: default
>       sourceCIDR: 0.0.0.0/0
> ```
>
> * `provider.generic.location` is the Control Plane location that runs the cluster's Kubernetes control plane. Pick the one closest to your servers; it cannot change after the cluster is created.
> * The `cpln.io/nodeType: core` label reaches every server that joins the `general` pool.
> * `addOns.byok.location` enables the `CPLN Platform` add-on, which installs the location into the cluster. It cannot change after it is set.
> * `firewall` controls who reaches the cluster's Kubernetes API. Each server you join adds its own address, so narrow `sourceCIDR` to the addresses you run `kubectl` from.
>
> When your servers have only private addresses, also add `cpln.io/allowPrivateIPs: 'true'` to the pool's labels. Without it, the cluster's load balancer has no address to give the BYOK ingress. See [Ingress IPs](https://docs.controlplane.com/mk8s/generic.md#ingress-ips).
>
> **2. Create the cluster**
>
> ```bash theme={null}
> cpln apply -f my-cluster.yaml
> ```
>
> Rerun `cpln mk8s health my-cluster` until it reports `READY` as `true`:
>
> ```text theme={null}
> +-----------+------+-----------------+
> |NAME       |READY |MESSAGE          |
> |-----------|------|-----------------|
> |my-cluster |true  |Cluster is ready |
> +-----------+------+-----------------+
> ```
>
> **3. Join each server**
>
> ```bash theme={null}
> cpln mk8s join my-cluster --type join-script --options nodePoolName=general > join.sh
> ```
>
> Copy `join.sh` to each server and run it as root with `sudo bash join.sh`. The script carries the cluster's join token, so treat it as a secret.
>
> **4. Wait for the nodes and the agent**
>
> ```bash theme={null}
> cpln mk8s kubeconfig my-cluster -f my-cluster.kubeconfig
> export KUBECONFIG=my-cluster.kubeconfig
> kubectl get nodes
> kubectl get pod -l app=cpln-byok-agent -n kube-system
> ```
>
> Every server is listed as `Ready`, and the agent reports `1/1 Running`.

## Deploy a Workload to the Location

Create a GVC that spans the location, then a workload in it:

```bash theme={null}
cpln gvc create --name my-gvc --location my-hardware
cpln workload create --name my-app --gvc my-gvc --image nginx:alpine --port 80 --public
```

To add the location to a GVC that already exists:

```bash theme={null}
cpln gvc add-location my-gvc --location my-hardware
```

> [!WARNING]
>
> Adding the location to an existing GVC deploys every workload already in that GVC onto your hardware.

## Verify

```bash theme={null}
cpln workload get-deployments my-app --gvc my-gvc
```

The `my-hardware` row reports `ready=true` once the workload runs on your hardware. Its endpoints resolve to the address your load balancer controller gave the BYOK ingress gateway, so they answer from any network that can reach that address.

## Troubleshoot

> [accordion] error: unable to read URL "[https://byok-api.cpln.io/install/download/.../byok-agent.k8s.yaml](https://byok-api.cpln.io/install/download/.../byok-agent.k8s.yaml)", server reported 404 Not Found, status code=404
>
> The install command's link expired before `kubectl` ran it. Generate a new command and apply it before the deadline on its first line.

> [accordion] 0/2 nodes are available: 2 Insufficient memory
>
> BYOK pods stay `Pending`, and `kubectl describe pod` reports this event. The BYOK components alone fill nodes smaller than the [BYOK requirements](https://docs.controlplane.com/byok/overview.md#requirements). Add nodes or memory until every node meets them.

> [accordion] BYOK PersistentVolumeClaims stay Pending
>
> The BYOK components claim persistent volumes from the cluster's default StorageClass, and the cluster has none. Install a storage provisioner or mark an existing StorageClass as the default.

> [accordion] The ingress gateway LoadBalancer service has no external address
>
> The `LoadBalancer` services in the `istio-system` namespace stay `<pending>`, and workloads never report ready. On an existing cluster, install a load balancer controller. On Linux servers with only private addresses, label the nodes with `cpln.io/allowPrivateIPs=true` as described in [Ingress IPs](https://docs.controlplane.com/mk8s/generic.md#ingress-ips).

## When to Use Something Else

* **Workloads stay in the cloud and only need to reach something in your network**, such as a database: use a [wormhole agent](https://docs.controlplane.com/guides/setup-agent.md) or [native networking](https://docs.controlplane.com/guides/native-networking/native-networking-setup.md).
* **You want to declare Control Plane resources from `kubectl` or Argo CD**: use the [Kubernetes operator](https://docs.controlplane.com/core/kubernetes-operator.md).
* **A single server, or nodes below the BYOK requirements**: the BYOK components need that capacity before any workload runs.

## Next Steps

> [card] [CPLN BYOK](https://docs.controlplane.com/byok/overview.md)
>
> Settings, cloud provider recommendations, and the uninstall procedure.

> [card] [Generic Provider](https://docs.controlplane.com/mk8s/generic.md)
>
> Networking, ingress IPs, and the full server requirements.
