Create and manage Kubernetes clusters
Send this guide to your coding agent.
Prerequisite: Install Port MCP
Open plan mode if your tool supports it; otherwise present the plan below filled in and wait for my approval. Implement this Port guide in my org via MCP: https://docs.port.io/guides/all/manage-clusters Read the raw markdown version at https://docs.port.io/guides/all/manage-clusters.md - it contains every tab and code block without page markup. Goal: get the guide's core flow working end-to-end in my org; adapting it to fit my existing setup takes priority over matching the guide 1:1. Plan: 1. Confirm MCP is connected, in the right org, with sufficient permissions. 2. If the guide offers alternative implementation paths (tabs), pick the one matching my installed integrations and tools, confirm it with me, and implement only that path. 3. Diff the guide's data model (blueprints, properties, relations, workflows, actions, agents, automations, integrations, webhook data sources, secrets) against mine. 4. Propose adaptations for gaps, reusing existing blueprints/relations over guide-named duplicates. 5. Flag what needs a UI click, credential, or secret from me, testing MCP capability empirically before ruling anything out. If the guide has a "Set up via API" section, use it for anything MCP can't do before treating a step as UI-only. 6. Stop on any blocker and give me options. Approving this plan authorizes the writes it lists; pause only for writes beyond what's listed. Build: - Extend blueprint schema additively when upserting; don't remove or overwrite existing properties, and treat type conflicts as a blocker, not an auto-fix. - Never print secret values into the chat or logs; ask me to set them in Port, or write them via the secrets API without echoing them back. - List any mock data in the plan, minimal and labeled mock; once approved, seed it without re-asking, and tell me what you seeded. - For anything the guide writes downstream (e.g. a webhook target), use a real entity, not a mock. - For pages/widgets, use the real page identifier from the app URL, not a guessed slug. - When you hit a UI step confirmed (not assumed) unsupported via MCP and not covered by the guide's API sections, pause, give exact clicks, then resume via MCP. - Validate and give links after each meaningful step (only a tool-returned URL, no guessed paths); don't proceed if the last run wasn't a success. Done: - Run the guide's "Let's test it" steps where possible (e.g. execute a workflow test run) and confirm the expected output exists in Port. - Summarize adaptations, seeded data, what was mocked or skipped, remaining UI steps, and how to verify.
This guide provides a step-by-step walkthrough for creating and deleting Kubernetes clusters with Crossplane and ArgoCD, orchestrated from a single Port workflow.
Once set up, the full cluster lifecycle runs through one governed flow:
- A developer, or an AI agent acting on their behalf, triggers the workflow from Port.
- The workflow dispatches a GitHub Actions workflow through Port's GitHub integration, which updates the manifests repository.
- ArgoCD syncs the repository state into the management cluster, and Crossplane reconciles it into your cloud provider.
- The workflow keeps the software catalog in sync, so the cluster is always visible in Port.
Because every self-service workflow is automatically exposed as a tool through the Port MCP server, the same flow works without any UI at all: agents discover it with list_workflows and execute it with trigger_run.
Port workflows are currently in open beta and available to all users. Workflows may undergo changes without prior notice.
Prerequisitesβ
- Prior knowledge of Port workflows is helpful for following this guide.
- Port's GitHub integration is installed. The workflow uses it to dispatch GitHub Actions workflows.
- A Control plane that will be used to create clusters and other infrastructure. We will use crossplane.
- A GitOps operator for automatically running operations on our cluster based on changes in our manifests repository. We will be using ArgoCD.
- Install Helm.
- A GitHub repository to contain your resources i.e. the github workflow file, port resources, and infrastructure manifests.
Clone our starter repository here to follow along through the guide. The repository contains the following folders:
.github: contains the github workflows.argocd: contains the ArgoCD application manifests. This is where we define the application that automates our process through GitOps.compositions: contains the crossplane compositions that define what a cluster is.crossplane-config: manifests for installing crossplane into your management cluster.infra: will contain the cluster manifests created by the automation.port: contains the Port blueprints and workflow definitions.scripts: contains the script that the GitHub workflow uses to create cluster manifests.
1. Set up the control planeβ
If you donβt have a Kubernetes cluster create one locally with kind.
- Install Crossplane into what will be your management cluster.
helm repo add crossplane-stable https://charts.crossplane.io/stable
helm repo update
helm upgrade --install crossplane crossplane-stable/crossplane --namespace crossplane-system --create-namespace --wait
- Now, let's install ArgoCD into the cluster.
kubectl create namespace argocd
kubectl apply -n argocd -f https://raw.githubusercontent.com/argoproj/argo-cd/stable/manifests/install.yaml
kubectl get pods -n argocd
ArgoCD comes with a default user: admin
To get the password, type the command below:
kubectl -n argocd get secret argocd-initial-admin-secret -o jsonpath="{.data.password}" | base64 -d
To access the UI, Kubectl port-forwarding can also be used to connect to the API server without exposing the service.
kubectl port-forward svc/argocd-server -n argocd 8080:443
The API server can then be accessed using https://localhost:8080
- Create the following Crossplane compositions, in the
crossplane-configfolder, to define the set of resources required to create a new cluster.
Compositions are templates for creating multiple managed resources as a single object. Learn more about them here.
crossplane-config/provider-kubernetes-incluster.yaml
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: crossplane-provider-kubernetes
namespace: crossplane-system
annotations:
argocd.argoproj.io/sync-wave: "-1"
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: crossplane-provider-kubernetes
annotations:
argocd.argoproj.io/sync-wave: "-1"
subjects:
- kind: ServiceAccount
name: crossplane-provider-kubernetes
namespace: crossplane-system
roleRef:
kind: ClusterRole
name: cluster-admin
apiGroup: rbac.authorization.k8s.io
---
apiVersion: pkg.crossplane.io/v1alpha1
kind: ControllerConfig
metadata:
name: crossplane-provider-kubernetes
annotations:
argocd.argoproj.io/sync-wave: "-1"
spec:
serviceAccountName: crossplane-provider-kubernetes
---
apiVersion: pkg.crossplane.io/v1
kind: Provider
metadata:
name: crossplane-provider-kubernetes
annotations:
argocd.argoproj.io/sync-wave: "-1"
argocd.argoproj.io/sync-options: SkipDryRunOnMissingResource=true
spec:
package: xpkg.upbound.io/crossplane-contrib/provider-kubernetes:v0.9.0
controllerConfigRef:
name: crossplane-provider-kubernetes
crossplane-config/provider-helm-incluster.yaml
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: crossplane-provider-helm
namespace: crossplane-system
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: crossplane-provider-helm
subjects:
- kind: ServiceAccount
name: crossplane-provider-helm
namespace: crossplane-system
roleRef:
kind: ClusterRole
name: cluster-admin
apiGroup: rbac.authorization.k8s.io
---
apiVersion: pkg.crossplane.io/v1alpha1
kind: ControllerConfig
metadata:
name: crossplane-provider-helm
spec:
serviceAccountName: crossplane-provider-helm
---
apiVersion: pkg.crossplane.io/v1
kind: Provider
metadata:
name: crossplane-provider-helm
spec:
package: xpkg.upbound.io/crossplane-contrib/provider-helm:v0.14.0
controllerConfigRef:
name: crossplane-provider-helm
kubectl apply --filename ./crossplane-config/provider-kubernetes-incluster.yaml
kubectl apply --filename ./crossplane-config/provider-helm-incluster.yaml
kubectl wait --for=condition=healthy provider.pkg.crossplane.io --all --timeout=300s
- Design a template to create a new cluster with desired properties. This is what the GitHub Actions workflow will copy and use to define the cluster manifest.
---
apiVersion: devopstoolkitseries.com/v1alpha1
kind: ClusterClaim
metadata:
name: NAME
namespace: infra
spec:
id: NAME
compositionSelector:
matchLabels:
provider: PROVIDER
cluster: CLUSTER
parameters:
nodeSize: SIZE
minNodeCount: 1
2. Add the GitHub Actions pipelinesβ
The Port workflow dispatches GitHub Actions workflows to update your manifests repository. These pipelines still scaffold the cluster manifests, so we create them first.
- Create a GitHub workflow named
create-cluster.yamlto checkout code, execute thecreate-cluster.shscript, and push changes back to the repository.
Create Cluster Workflow
name: Create a cluster
on:
workflow_dispatch:
inputs:
name:
required: true
description: "The name of the cluster"
provider:
required: true
description: "The provider where the cluster is hosted"
default: "aws"
cluster:
required: true
description: "The type of the cluster"
node-size:
required: true
description: "The size of the nodes"
default: "small"
min-node-count:
required: true
description: "The minimum number of nodes (autoscaler might increase this number)"
default: "1"
jobs:
deploy-app:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
persist-credentials: false
fetch-depth: 0
- name: Create cluster
run: |
chmod +x scripts/create-cluster.sh
./scripts/create-cluster.sh ${{ inputs.name }} ${{ inputs.provider }} ${{ inputs.cluster }} ${{ inputs.node-size }} ${{ inputs.min-node-count }}
- name: Commit changes
run: |
git config --local user.email "41898282+github-actions[bot]@users.noreply.github.com"
git config --local user.name "github-actions[bot]"
git add .
git commit -m "Create cluster ${{ inputs.name }}"
- name: Push changes
uses: ad-m/github-push-action@master
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
branch: ${{ github.ref }}
- Create a script named
create-cluster.shin thescriptsfolder of your repository to copy the template claim to theinfradirectory and replace placeholders with user inputs from Port.
NAME=$1
PROVIDER=$2
CLUSTER=$3
NODE_SIZE=$4
MIN_NODE_COUNT=$5
FILE_PATH=infra/${NAME}-cluster.yaml
cp crossplane/cluster-template.yaml $FILE_PATH
yq --inplace ".metadata.name = \"${NAME}\"" $FILE_PATH
yq --inplace ".spec.id = \"${NAME}\"" $FILE_PATH
yq --inplace ".spec.compositionSelector.matchLabels.provider = \"${PROVIDER}\"" $FILE_PATH
yq --inplace ".spec.compositionSelector.matchLabels.cluster = \"${CLUSTER}\"" $FILE_PATH
yq --inplace ".spec.parameters.nodeSize = \"${NODE_SIZE}\"" $FILE_PATH
yq --inplace ".spec.parameters.minNodeCount = ${MIN_NODE_COUNT}" $FILE_PATH
- Create another workflow named
delete-cluster.yamlto delete the cluster file and push changes to the repository.
Delete Cluster Workflow
name: Delete the cluster
on:
workflow_dispatch:
inputs:
name:
required: true
description: "The name of the cluster"
jobs:
deploy-app:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
persist-credentials: false
fetch-depth: 0
- name: Delete cluster
run: |
rm infra/${{ inputs.name }}-cluster.yaml
- name: Commit changes
run: |
git config --local user.email "41898282+github-actions[bot]@users.noreply.github.com"
git config --local user.name "github-actions[bot]"
git add .
git commit -m "Delete cluster ${{ inputs.name }}"
- name: Push changes
uses: ad-m/github-push-action@master
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
branch: ${{ github.ref }}
The delete pipeline removes infra/<name>-cluster.yaml, matching the file name that create-cluster.sh generates.
3. Model clusters in Portβ
- Create a Port blueprint that defines a data model for the cluster in the UI.
Blueprint
{
"identifier": "cluster",
"description": "This blueprint represents a Kubernetes Cluster",
"title": "Cluster",
"icon": "Cluster",
"schema": {
"properties": {
"provider": {
"type": "string",
"title": "Provider",
"default": "aws",
"description": "The provider where the cluster is hosted",
"enum": ["aws", "gcp"]
},
"node-size": {
"type": "string",
"title": "Node Size",
"default": "small",
"description": "The size of the nodes",
"enum": ["small", "medium", "large"]
},
"min-node-count": {
"type": "number",
"title": "Minimum number of nodes",
"default": 1,
"description": "The minimum number of nodes (autoscaler might increase this number)"
},
"kube-config": {
"type": "string",
"title": "Kube config",
"description": "Kube config"
},
"status": {
"type": "string",
"title": "Status",
"description": "The status of the cluster"
}
},
"required": ["provider", "node-size", "min-node-count"]
},
"mirrorProperties": {},
"calculationProperties": {},
"relations": {}
}
4. Build the Port workflowβ
We will build a single workflow that manages the full cluster lifecycle. It defines two self-service triggers:
Create a clusterdispatches the create pipeline, then upserts aclusterentity so the new cluster appears in the catalog immediately.Delete a clusterlets the user pick an existing cluster entity, dispatches the delete pipeline, then removes the entity from the catalog.
The workflow's title, description, and input descriptions double as its tool definition for AI agents, so they are written as instructions an agent can follow. See expose workflows as tools for the full guidelines.
<YOUR_GITHUB_OCEAN_INTEGRATION_ID>- your GitHub integration installation ID, found on the Data sources page.<GITHUB_ORG_ID>- your GitHub organization or user name.<GITHUB_REPO_ID>- your GitHub repository name.
Manage clusters workflow JSON (Click to expand)
{
"identifier": "manage_clusters",
"title": "Manage Kubernetes clusters",
"icon": "Cluster",
"description": "Create or delete a Kubernetes cluster through Crossplane and ArgoCD. Use this tool when a user asks to provision, create, spin up, delete, or tear down a Kubernetes cluster. Creating a cluster requires a name, provider, node size, and minimum node count. Deleting a cluster requires the cluster's identifier from the catalog.",
"nodes": [
{
"identifier": "create_cluster_trigger",
"title": "Create a cluster",
"icon": "Cluster",
"config": {
"type": "SELF_SERVE_TRIGGER",
"contexts": [
{
"on": "CREATE_ENTITY",
"blueprintIdentifier": "cluster"
}
],
"userInputs": {
"properties": {
"name": {
"type": "string",
"title": "Name",
"description": "The name of the cluster. Use lowercase letters, digits, and hyphens, for example 'team-dev-cluster'."
},
"provider": {
"type": "string",
"title": "Provider",
"default": "aws",
"description": "The provider where the cluster is hosted. Accepted values: aws, azure.",
"enum": ["aws", "azure"]
},
"node-size": {
"type": "string",
"title": "Node Size",
"default": "small",
"description": "The size of the nodes. Accepted values: small, medium, large.",
"enum": ["small", "medium", "large"]
},
"min-node-count": {
"type": "string",
"title": "Minimum number of nodes",
"default": "1",
"description": "The minimum number of nodes as a string, for example '1'. The autoscaler might increase this number."
}
},
"required": ["name", "provider", "node-size", "min-node-count"]
},
"published": true
}
},
{
"identifier": "create_cluster",
"title": "Dispatch create cluster workflow",
"icon": "GitHub",
"config": {
"type": "INTEGRATION_ACTION",
"installationId": "<YOUR_GITHUB_OCEAN_INTEGRATION_ID>",
"integrationProvider": "github-ocean",
"integrationInvocationType": "dispatch_workflow",
"integrationActionExecutionProperties": {
"org": "<GITHUB_ORG_ID>",
"repo": "<GITHUB_REPO_ID>",
"workflow": "create-cluster.yaml",
"workflowInputs": {
"name": "{{ .outputs.trigger.name }}",
"provider": "{{ .outputs.trigger.provider }}",
"node-size": "{{ .outputs.trigger[\"node-size\"] }}",
"min-node-count": "{{ .outputs.trigger[\"min-node-count\"] }}"
},
"reportWorkflowStatus": true
}
}
},
{
"identifier": "upsert_cluster_entity",
"title": "Add cluster to catalog",
"icon": "Cluster",
"config": {
"type": "UPSERT_ENTITY",
"blueprintIdentifier": "cluster",
"mapping": {
"identifier": "{{ .outputs.trigger.name }}",
"title": "{{ .outputs.trigger.name }}",
"properties": {
"provider": "{{ .outputs.trigger.provider }}",
"node-size": "{{ .outputs.trigger[\"node-size\"] }}",
"min-node-count": "{{ .outputs.trigger[\"min-node-count\"] | tonumber }}",
"status": "Provisioning"
}
}
}
},
{
"identifier": "delete_cluster_trigger",
"title": "Delete a cluster",
"icon": "Cluster",
"config": {
"type": "SELF_SERVE_TRIGGER",
"contexts": [
{
"on": "ENTITY",
"userInput": "cluster"
}
],
"userInputs": {
"properties": {
"cluster": {
"type": "string",
"format": "entity",
"blueprint": "cluster",
"title": "Cluster",
"description": "The cluster to delete. Use the cluster's identifier from the catalog."
}
},
"required": ["cluster"]
},
"published": true
}
},
{
"identifier": "delete_cluster",
"title": "Dispatch delete cluster workflow",
"icon": "GitHub",
"config": {
"type": "INTEGRATION_ACTION",
"installationId": "<YOUR_GITHUB_OCEAN_INTEGRATION_ID>",
"integrationProvider": "github-ocean",
"integrationInvocationType": "dispatch_workflow",
"integrationActionExecutionProperties": {
"org": "<GITHUB_ORG_ID>",
"repo": "<GITHUB_REPO_ID>",
"workflow": "delete-cluster.yaml",
"workflowInputs": {
"name": "{{ .outputs.trigger.cluster }}"
},
"reportWorkflowStatus": true
}
}
},
{
"identifier": "remove_cluster_entity",
"title": "Remove cluster from catalog",
"icon": "Delete",
"config": {
"type": "WEBHOOK",
"url": "https://api.port.io/v1/blueprints/cluster/entities/{{ .outputs.trigger.cluster }}",
"method": "DELETE",
"agent": false,
"synchronized": true
}
}
],
"connections": [
{
"sourceIdentifier": "create_cluster_trigger",
"targetIdentifier": "create_cluster"
},
{
"sourceIdentifier": "create_cluster",
"targetIdentifier": "upsert_cluster_entity"
},
{
"sourceIdentifier": "delete_cluster_trigger",
"targetIdentifier": "delete_cluster"
},
{
"sourceIdentifier": "delete_cluster",
"targetIdentifier": "remove_cluster_entity"
}
]
}
Note the following details about the workflow configuration:
- The
create_cluster_triggernode uses aCREATE_ENTITYcontext so it also appears in theclusterblueprint's create flow, and thedelete_cluster_triggernode uses anENTITYcontext so it appears in the bolt (β‘) menu of everyclusterentity with the entity pre-filled. - User inputs are referenced with
{{ .outputs.trigger.<input> }}. Inputs whose keys contain hyphens, such asnode-sizeandmin-node-count, must use bracket notation like{{ .outputs.trigger["node-size"] }}, since dot notation would be interpreted as subtraction in JQ. See data flow for details. .outputs.triggeralways resolves to the trigger that fired, so the same alias works for both the create and delete branches of this multi-trigger workflow.- With
reportWorkflowStatusenabled, each dispatch node waits for the GitHub Actions run to finish, so the catalog nodes only run after the pipeline has committed its changes. - The
remove_cluster_entitywebhook calls Port's own API. Calls to Port's API from workflow nodes are authenticated automatically, so no token is needed.
Choose how you want to create the workflow:
- Port UI
- Port API
-
Go to the Workflows page in Port.
-
Click on the
+ Workflowbutton in the top-right corner. -
Click on the
Skip to editorbutton. -
Copy and paste the workflow JSON above into the editor to replace the example workflow.
-
Click
Publishto save the workflow.
Send the workflow JSON above as the request body of the create workflow endpoint:
curl --location --request POST 'https://api.port.io/v1/workflows' \
--header 'Authorization: Bearer <YOUR_API_TOKEN>' \
--header 'Content-Type: application/json' \
--data @manage-clusters-workflow.json
To update the workflow later, use PUT /v1/workflows/manage_clusters with the same body.
The GitHub integration action authenticates through your installed GitHub integration, so no token or secret is required in the node. Integration actions are available for GitHub today. For other providers such as GitLab, Bitbucket, or Azure DevOps, use a webhook node to hit the pipeline trigger URL, and pass credentials with {{ .secrets["<SECRET_NAME>"] }}.
5. Connect GitOpsβ
- Now to add the final piece in our automation, create an ArgoCD application that will be responsible for syncing the GitHub repository state into the management cluster so that crossplane creates the resources.
kubectl apply --filename apps.yaml
ArgoCD Application
- Change the
repoURLto your repository. - Create or set your namespace.
---
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: production-infra
namespace: argocd
finalizers:
- resources-finalizer.argocd.argoproj.io
spec:
project: production
source:
repoURL: https://github.com/port-labs-labs/crossplane-demo
targetRevision: HEAD
path: infra
destination:
server: https://kubernetes.default.svc
namespace: production
syncPolicy:
automated:
selfHeal: true
prune: true
allowEmpty: true
6. Test the workflowβ
Trigger the workflow from whichever surface fits your setup:
- Port UI
- Port API
- AI agent
-
On the self-service page, find the
Create a clustertrigger and fill in the cluster properties. -
Click the execute button to trigger the creation process. You can follow the run from the workflow's runs view.
To remove a cluster, open the bolt (β‘) menu on its entity and run Delete a cluster, or run the trigger from the self-service page.
Trigger a run with the trigger workflow run endpoint. Since this workflow has multiple triggers, pass the trigger node's identifier in the trigger_node query parameter:
curl --location --request POST 'https://api.port.io/v1/workflows/manage_clusters/runs?trigger_node=create_cluster_trigger' \
--header 'Authorization: Bearer <YOUR_API_TOKEN>' \
--header 'Content-Type: application/json' \
--data '{
"inputs": {
"name": "team-dev-cluster",
"provider": "aws",
"node-size": "small",
"min-node-count": "1"
}
}'
To delete the cluster:
curl --location --request POST 'https://api.port.io/v1/workflows/manage_clusters/runs?trigger_node=delete_cluster_trigger' \
--header 'Authorization: Bearer <YOUR_API_TOKEN>' \
--header 'Content-Type: application/json' \
--data '{
"inputs": {
"cluster": "team-dev-cluster"
}
}'
Any agent connected to the Port MCP server can run this workflow, no UI needed. Ask your agent, for example:
Create a small aws cluster named team-dev-cluster.
The agent discovers the workflow with list_workflows, reads the input descriptions to resolve the values, and executes it with trigger_run. For deletion, the agent resolves the cluster's identifier from the catalog with list_entities before triggering the delete branch. See expose workflows as tools for how the tool definition works and how to scope agent permissions.
Once triggered, the pieces come together:
- The workflow dispatches the matching GitHub Actions workflow, which updates the
infrafolder of your repository. - ArgoCD will synchronize the manifest within the control plane cluster.
- Crossplane will create or remove the cluster resources in the specified provider.
- The catalog reflects the change: creation upserts a
clusterentity with statusProvisioning, deletion removes it.
Done! π You can now create and delete clusters from Port.