Modelplane Modelplane docs

Build the platform

This is the platform team’s side of Modelplane. You set up the gateway that fronts your models, give the control plane cloud credentials, and register your first GPU cluster: a hardware profile published as an InferenceClass and an InferenceCluster that offers it.

In the next step, the ML team will create a model deployment that schedules against this capacity without knowing which cluster it runs on.

Prerequisites

  • An AWS account with permissions to create EKS clusters, VPCs, and IAM roles
  • AWS access key ID and secret access key
  • A GCP service account JSON key, granted these roles on the project:

    RoleNeeded for
    roles/container.adminthe cluster and its node pools
    roles/compute.adminthe VPC network and subnet
    roles/serviceusage.serviceUsageAdminenabling the APIs the cluster needs
    roles/iam.serviceAccountAdminthe node service account
    roles/iam.serviceAccountKeyAdminthe node service account’s key
    roles/iam.serviceAccountUserattaching that account to the nodes
    roles/resourcemanager.projectIamAdmingranting the node account container.admin

    The last one is worth a look before you hand the key over. Modelplane grants the node service account roles/container.admin, so the credential doing the provisioning has to be able to set project IAM policy.

  • An Azure account with permissions to create AKS clusters and managed identities
  • An Azure service principal JSON with clientId, clientSecret, subscriptionId, and tenantId
  • A Nebius account with permissions to create clusters
  • A Nebius service account JSON key and your project ID
  • A Vultr account with access to GPU plans
  • A Vultr API key

Configure cloud credentials

Give the control plane credentials so it can provision clusters in your cloud account.

Create an AWS credentials file:

ini
[default]
aws_access_key_id = 
aws_secret_access_key = 

Create a Kubernetes secret:

bash
kubectl create secret generic aws-creds \
  --from-file=credentials= \
  -n crossplane-system

Apply the ClusterProviderConfig referencing your secret:

clusterproviderconfig-aws.yaml
# Points the AWS provider at the credentials Secret you created. Named default,
# so InferenceClusters with an EKS source use it without further configuration.
apiVersion: aws.m.upbound.io/v1beta1
kind: ClusterProviderConfig
metadata:
  name: default
spec:
  credentials:
    source: Secret
    secretRef:
      namespace: crossplane-system
      name: aws-creds
      key: credentials

Create a Kubernetes secret:

bash
kubectl create secret generic gcp-creds \
  --from-file=credentials=.json \
  -n crossplane-system

Apply the ClusterProviderConfig, setting projectID to your GCP project:

clusterproviderconfig-gke.yaml
# Points the GCP provider at the credentials Secret you created. Named default,
# so InferenceClusters with a GKE source use it without further configuration.
apiVersion: gcp.m.upbound.io/v1beta1
kind: ClusterProviderConfig
metadata:
  name: default
spec:
  projectID: my-gcp-project  # replace with your GCP project
  credentials:
    source: Secret
    secretRef:
      namespace: crossplane-system
      name: gcp-creds
      key: credentials
bash
curl -fsSL /examples/getting-started/clusterproviderconfig-gke.yaml \
  | sed 's/my-gcp-project//' \
  | kubectl apply -f -

Create a Kubernetes secret from your service principal JSON:

bash
kubectl create secret generic azure-credentials \
  --from-file=credentials.json=.json \
  -n crossplane-system

Apply the ClusterProviderConfig referencing your secret:

clusterproviderconfig-azure.yaml
# Points the Azure providers at the credentials Secret you created. Named
# default, so InferenceClusters with an AKS source use it without further
# configuration. The Secret carries a service principal JSON with
# clientId, clientSecret, subscriptionId, and tenantId.
apiVersion: azure.m.upbound.io/v1beta1
kind: ClusterProviderConfig
metadata:
  name: default
spec:
  credentials:
    source: Secret
    secretRef:
      namespace: crossplane-system
      name: azure-credentials
      key: credentials.json

Create a Kubernetes secret from your service account JSON:

bash
kubectl create secret generic nebius-credentials \
  --from-file=credentials.json=.json \
  -n crossplane-system

Apply the ClusterProviderConfig, setting projectID to your Nebius project:

clusterproviderconfig-nebius.yaml
# Points the Nebius provider at the credentials Secret you created. Named
# default, so InferenceClusters with a Nebius source use it without further
# configuration - Modelplane also reuses its credentials Secret to
# authenticate to the clusters it provisions.
apiVersion: nebius.m.upbound.io/v1beta1
kind: ClusterProviderConfig
metadata:
  name: default
spec:
  identity:
    type: ServiceAccount
  credentials:
    source: Secret
    secretRef:
      namespace: crossplane-system
      name: nebius-credentials
      key: credentials.json
  projectID: project-e00example
bash
curl -fsSL /examples/getting-started/clusterproviderconfig-nebius.yaml \
  | sed 's/project-e00example//' \
  | kubectl apply -f -

Create a Kubernetes secret from your API key:

bash
kubectl create secret generic vultr-credentials \
  --from-literal=api-key= \
  -n crossplane-system

Apply the ClusterProviderConfig referencing your secret:

clusterproviderconfig-vultr.yaml
# Points the Vultr provider at the API key Secret you created. Named
# default, so InferenceClusters with a Vultr source use it without further
# configuration. The API key only provisions clusters - VKE kubeconfigs
# embed static client certificates, so consumers never need it to reach
# the clusters Modelplane provisions.
apiVersion: vultr.m.upbound.io/v1beta1
kind: ClusterProviderConfig
metadata:
  name: default
spec:
  credentials:
    source: Secret
    secretRef:
      namespace: crossplane-system
      name: vultr-credentials
      key: api-key

Publish hardware and register the cluster

The InferenceClass describes a hardware profile and how to provision it. The InferenceCluster registers a cluster that offers it. Apply both:

platform.yaml
apiVersion: modelplane.ai/v1alpha1
kind: InferenceClass
metadata:
  name: l4-1x-g6
spec:
  description: "EKS g6.xlarge, 1x NVIDIA L4"
  provisioning:
    provider: EKS
    eks:
      instanceType: g6.xlarge
      diskSizeGb: 50
      accelerator:
        type: nvidia-l4
        count: 1
  devices:
  - name: gpu
    claim: DRA
    driver: gpu.nvidia.com
    deviceClassName: gpu.nvidia.com
    count: 1
    attributes:
      architecture: { string: Ada Lovelace }
    capacity:
      memory: { value: "23034Mi" }   # L4's real reported VRAM (not the nominal 24GB)
---
apiVersion: modelplane.ai/v1alpha1
kind: InferenceCluster
metadata:
  name: eks-us-east
  labels:
    modelplane.ai/region: us-east
spec:
  cluster:
    source: EKS
    eks:
      region: us-east-1
  nodePools:
  - name: gpu-l4
    className: l4-1x-g6
    nodeCount: 1
    minNodeCount: 1
    maxNodeCount: 1
    zones:
    - us-east-1b

Modelplane provisions the cluster. This takes about 15 minutes:

bash
kubectl wait --for=condition=Ready ic/eks-us-east --timeout=20m

Apply the manifest:

platform.yaml
apiVersion: modelplane.ai/v1alpha1
kind: InferenceClass
metadata:
  name: gke-l4-1x-g2
spec:
  description: "GKE g2-standard-8, 1x NVIDIA L4"
  provisioning:
    provider: GKE
    gke:
      machineType: g2-standard-8
      diskSizeGb: 100
      accelerator:
        type: nvidia-l4
        count: 1
  devices:
  - name: gpu
    claim: DRA
    driver: gpu.nvidia.com
    deviceClassName: gpu.nvidia.com
    count: 1
    attributes:
      architecture: { string: Ada Lovelace }
    capacity:
      memory: { value: "23034Mi" }   # L4's real reported VRAM (not the nominal 24GB)
---
apiVersion: modelplane.ai/v1alpha1
kind: InferenceCluster
metadata:
  name: starter
  labels:
    modelplane.ai/region: us-central
spec:
  cluster:
    source: GKE
    gke:
      region: us-central1
  nodePools:
  - name: gpu-l4
    className: gke-l4-1x-g2
    nodeCount: 1
    minNodeCount: 0
    maxNodeCount: 2
    zones:
    - us-central1-a
bash
kubectl apply -f /examples/getting-started/gke/platform.yaml

Modelplane provisions the cluster. This takes about 15 minutes:

bash
kubectl wait --for=condition=Ready ic/starter --timeout=20m
platform.yaml
apiVersion: modelplane.ai/v1alpha1
kind: InferenceClass
metadata:
  name: a10-1x
spec:
  description: "AKS Standard_NV36ads_A10_v5, 1x NVIDIA A10 24GB"
  provisioning:
    provider: AKS
    aks:
      # verify: Azure has no L4 SKU; NVadsA10v5 is the small-GPU option (>=20Gi).
      # Confirm it runs the DRA / GPU-operator path in your subscription.
      vmSize: Standard_NV36ads_A10_v5
      diskSizeGb: 100
      accelerator:
        type: nvidia-a10
        count: 1
  devices:
  - name: gpu
    claim: DRA
    driver: gpu.nvidia.com
    deviceClassName: gpu.nvidia.com
    count: 1
    attributes:
      architecture: { string: Ampere }
      cudaComputeCapability: { version: "8.6.0" }
    capacity:
      memory: { value: "24564Mi" }   # verify: A10 reported VRAM (~24GB)
---
apiVersion: modelplane.ai/v1alpha1
kind: InferenceCluster
metadata:
  name: aks-westeurope
  labels:
    modelplane.ai/region: westeurope
spec:
  cluster:
    source: AKS
    aks:
      location: westeurope
  nodePools:
  - name: gpua10
    className: a10-1x
    nodeCount: 1
    minNodeCount: 1
    maxNodeCount: 1

Modelplane provisions the cluster. This takes about 15 minutes:

bash
kubectl wait --for=condition=Ready ic/aks-westeurope --timeout=20m
platform.yaml
apiVersion: modelplane.ai/v1alpha1
kind: InferenceClass
metadata:
  name: l40s-1x
spec:
  description: "Nebius gpu-l40s-a, 1x NVIDIA L40S 48GB"
  provisioning:
    provider: Nebius
    nebius:
      # gpu-l40s-a (L40S PCIe, Intel Ice Lake) is available in eu-north1.
      platform: gpu-l40s-a
      preset: 1gpu-8vcpu-32gb
      diskSizeGb: 100
      driversPreset: cuda13.0
      accelerator:
        type: nvidia-l40s
        count: 1
  devices:
  - name: gpu
    claim: DRA
    driver: gpu.nvidia.com
    deviceClassName: gpu.nvidia.com
    count: 1
    attributes:
      architecture: { string: Ada Lovelace }
      cudaComputeCapability: { version: "8.9.0" }
    capacity:
      memory: { value: "46068Mi" }   # L40S reported VRAM (48GB nominal)
---
apiVersion: modelplane.ai/v1alpha1
kind: InferenceCluster
metadata:
  name: nebius-eu-north
  labels:
    modelplane.ai/region: eu-north
spec:
  cluster:
    source: Nebius
    nebius: {}
  nodePools:
  - name: gpu-l40s
    className: l40s-1x
    nodeCount: 1
    minNodeCount: 1
    maxNodeCount: 1

Modelplane provisions the cluster. This takes about 15 minutes:

bash
kubectl wait --for=condition=Ready ic/nebius-eu-north --timeout=20m
platform.yaml
apiVersion: modelplane.ai/v1alpha1
kind: InferenceClass
metadata:
  name: l40s-1x
spec:
  # The largest GPU plan available in ewr. Vultr GPU plans are region-gated;
  # list what a region offers with the /v2/regions/<region>/availability API
  # before picking another plan.
  description: "Vultr vcg-l40s-16c-180g-48vram, 1x NVIDIA L40S 48GB"
  provisioning:
    provider: Vultr
    vultr:
      plan: vcg-l40s-16c-180g-48vram
      accelerator:
        type: nvidia-l40s
        count: 1
  devices:
  - name: gpu
    claim: DRA
    driver: gpu.nvidia.com
    deviceClassName: gpu.nvidia.com
    count: 1
    attributes:
      architecture: { string: Ada Lovelace }
      cudaComputeCapability: { version: "8.9.0" }
    capacity:
      memory: { value: "46068Mi" }   # L40S's real reported VRAM (not the nominal 48GB)
---
apiVersion: modelplane.ai/v1alpha1
kind: InferenceCluster
metadata:
  name: vultr-ewr
  labels:
    modelplane.ai/region: ewr
spec:
  cluster:
    source: Vultr
    vultr:
      region: ewr
  nodePools:
  - name: gpu-l40s
    className: l40s-1x
    nodeCount: 1
    minNodeCount: 1
    maxNodeCount: 1

Modelplane provisions the cluster. This takes about 15 minutes:

bash
kubectl wait --for=condition=Ready ic/vultr-ewr --timeout=20m
Note

Modelplane is reconciling the infrastructure against the source of truth, the manifest you just applied.

While you wait, Modelplane is creating the cloud cluster and its GPU node pool, then installing the inference stack with LeaderWorkerSet for multi-node serving (the default; a cluster can opt into Grove and KAI Scheduler instead via InferenceCluster.spec.stack: Dynamo), llm-d for inference-aware routing, Envoy Gateway for traffic management, and the storage class for model weights. This is the same reconciliation loop Crossplane uses to configure other infrastructure, extended to the inference layer.

Note
A cloud GPU cluster costs money while it runs. To stop the tour and resume later, follow Clean up.

Set up the InferenceGateway

The InferenceGateway is the address callers reach your models through. It speaks the OpenAI and Anthropic APIs, authenticates callers, and resolves the model a request names to a ModelService.

It runs on an InferenceCluster rather than on your control plane, named by spec.clusterName, because that cluster already runs the gateway software. It comes after registering the cluster because it needs one to run on. Here it shares the cluster serving the model, which is fine; in a real fleet you’d more often give a gateway a cluster of its own.

This one is the smallest useful shape: no hostname, no certificate and no caller keys, so it answers on its address over plain HTTP and authenticates nobody. Fine here, wrong on a network you don’t trust. See Set Up the Gateway for the production shape.

inference-gateway.yaml
# An InferenceGateway is the front door for inference requests: the only address
# a caller sees. It speaks the OpenAI and Anthropic APIs, authenticates callers,
# and resolves the model a request names to a ModelService.
#
# It runs on an InferenceCluster, which already runs the gateway software, so
# this installs nothing on your control plane. The cluster needs no GPU pools: a
# cluster with none is a gateway and nothing else, and a cluster that serves
# models can host one too.
#
# You can run several, one per region, and distributing callers across them is
# yours to configure. This one is the smallest useful shape: no hostname, no
# certificate and no caller keys, so it answers on its address over plain HTTP
# and authenticates nobody. Fine for getting started, not for an untrusted
# network.
apiVersion: modelplane.ai/v1alpha1
kind: InferenceGateway
metadata:
  name: local
spec:
  clusterName: local

Wait until the gateway is ready:

bash
kubectl wait --for=condition=Ready ig/local --timeout=5m

With the cluster registered and a gateway in front of it, the ML team can deploy a model.

Next step

Now that the platform is provisioned, the ML team can deploy a model by describing what the model needs, not the infrastructure.