<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Kupe Cloud</title>
    <description>The latest articles on DEV Community by Kupe Cloud (kupecloud).</description>
    <link>https://dev.to/kupecloud</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Forganization%2Fprofile_image%2F14549%2F9c66f795-2746-45bc-b770-342a9c72efdf.png</url>
      <title>DEV Community: Kupe Cloud</title>
      <link>https://dev.to/kupecloud</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/kupecloud"/>
    <language>en</language>
    <item>
      <title>Private container images, connected to your Kupe clusters | Kupe Cloud</title>
      <dc:creator>Billy Walker</dc:creator>
      <pubDate>Mon, 26 Oct 2026 12:51:16 +0000</pubDate>
      <link>https://dev.to/kupecloud/private-container-images-connected-to-your-kupe-clusters-kupe-cloud-4hap</link>
      <guid>https://dev.to/kupecloud/private-container-images-connected-to-your-kupe-clusters-kupe-cloud-4hap</guid>
      <description>&lt;p&gt;A private image needs somewhere to live and a way for your cluster to pull it. The storage is usually straightforward. Access has a habit of spreading: a credential in CI, a pull Secret in one namespace, another copy when a service moves, and a rotation to remember later. Your existing registry may automate some of that already. We wanted Kupe to take care of it for the clusters it manages.&lt;/p&gt;

&lt;p&gt;Kupe now includes an opt-in private container registry for each account. You push images with your usual tools, connect the clusters that need them, and deploy. &lt;strong&gt;Connected clusters can pull your images without an &lt;code&gt;imagePullSecrets&lt;/code&gt; entry in your manifests.&lt;/strong&gt; Kupe manages the pull credentials.&lt;/p&gt;

&lt;h2&gt;
  
  
  The registry belongs to your account
&lt;/h2&gt;

&lt;p&gt;Your images sit alongside the clusters and managed services. Each account can turn on one registry, and you choose which of its clusters to connect. You can use the same image across development and production without setting up a separate registry for each cluster.&lt;/p&gt;

&lt;p&gt;The registry has its own lifecycle. Deleting a cluster removes that cluster's connection; it doesn't delete the images. When you create its replacement, you can connect it to the same registry. That keeps an application build available independently of any one cluster.&lt;/p&gt;

&lt;p&gt;Access follows your Kupe account too. Members sign in with their existing identity, and CI uses access tokens with pull-only or push-and-pull access. Removing a member revokes the sessions and tokens they created. There is no separate set of registry users to keep in sync when someone joins or leaves.&lt;/p&gt;

&lt;p&gt;Kupe operates the registry for you, with no separate installation to maintain or account to administer. The console, CLI, API and Terraform provider all work with the same registry and cluster connections.&lt;/p&gt;

&lt;h2&gt;
  
  
  Connect once, pull across the cluster
&lt;/h2&gt;

&lt;p&gt;A connection gives a cluster access to the account's images. Once it's ready, new pods can pull from the registry in any namespace. Creating another namespace doesn't give you another pull Secret to distribute, and rotating the connection's credentials doesn't require changes to your application manifests.&lt;/p&gt;

&lt;p&gt;The pull credential stays on the host, outside your tenant cluster. Workloads and people with access to that cluster cannot read or copy it. That was a deliberate choice: a cluster needs permission to fetch an image, but the application running from that image doesn't need the credential itself.&lt;/p&gt;

&lt;p&gt;There is a tradeoff here. &lt;strong&gt;A connection grants pull access to every repository in the account's registry, across the whole cluster.&lt;/strong&gt; This first version has no per-namespace pull restrictions. If two teams must not be able to pull each other's images, they need separate accounts or a registry setup with that finer boundary. Namespaces alone don't provide it.&lt;/p&gt;

&lt;p&gt;Builds running inside a cluster can also receive push access in selected namespaces. That uses a Secret inside those namespaces, so it needs more care: workloads able to use it can push to the registry, including replacing existing tags. We keep that permission separate from the cluster's ordinary pull access.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep the build tools you use
&lt;/h2&gt;

&lt;p&gt;The registry works with standard OCI clients and stores container images, multi-platform images, Helm charts and signatures. An existing pipeline can push to Kupe using an access token; you don't need to adopt a Kupe-specific build system.&lt;/p&gt;

&lt;p&gt;For a team managing infrastructure in Git, Terraform can declare the registry and its cluster connections alongside the clusters themselves. If you prefer the console or CLI, those expose the same choices. The useful outcome is the same: the image is stored in your account, and the clusters you've connected can run it.&lt;/p&gt;

&lt;p&gt;You can browse repositories, images and tags in the console. Storage use, push and pull activity, access tokens and cluster connections also appear in the Registry dashboard in Grafana. Alerts cover storage filling up, expiring tokens and connections that haven't become ready. Registry operations belong with the rest of the platform's monitoring; you shouldn't need another monitoring setup just to see whether your image store is filling up.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this first version leaves to you
&lt;/h2&gt;

&lt;p&gt;The registry handles image storage and access today. A few limits are worth knowing before you rely on it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It doesn't scan stored images yet. Kupe's workload vulnerability reports cover images deployed to clusters.&lt;/li&gt;
&lt;li&gt;Tags are mutable, and there are no automatic retention rules. You decide what to keep and delete.&lt;/li&gt;
&lt;li&gt;The registry isn't backed up, and deleted images cannot be restored. Keep the source and build pipeline needed to publish them again.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Deletion protection is on by default. You can set a storage limit within your plan's allowance; reaching it stops new pushes while existing images remain available to pull.&lt;/p&gt;

&lt;p&gt;Scanning on push, per-image SBOMs, retention rules and immutable tags are on the roadmap. They're planned additions, so choose the registry based on what it does today.&lt;/p&gt;

&lt;p&gt;To try it, enable &lt;strong&gt;Registry&lt;/strong&gt; in the console and connect one of your clusters. The &lt;a href="https://docs.kupe.cloud/registry/" rel="noopener noreferrer"&gt;registry guide&lt;/a&gt; covers pushing your first image and setting up CI, with &lt;a href="https://docs.kupe.cloud/terraform/guides/manage-the-registry/" rel="noopener noreferrer"&gt;Terraform examples&lt;/a&gt; if that's how you manage your account. If the path leaves you managing a pull Secret by hand, we'd like to hear where: &lt;a href="mailto:support@kupe.cloud"&gt;support@kupe.cloud&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>devops</category>
      <category>security</category>
      <category>kubernetes</category>
      <category>productupdates</category>
    </item>
    <item>
      <title>Managed PostgreSQL for Kubernetes apps | Kupe Cloud</title>
      <dc:creator>Billy Walker</dc:creator>
      <pubDate>Wed, 21 Oct 2026 13:05:09 +0000</pubDate>
      <link>https://dev.to/kupecloud/managed-postgresql-for-kubernetes-apps-kupe-cloud-2165</link>
      <guid>https://dev.to/kupecloud/managed-postgresql-for-kubernetes-apps-kupe-cloud-2165</guid>
      <description>&lt;p&gt;Your app needs Postgres. The familiar Kubernetes route is to install a database operator, choose its size, arrange TLS, put credentials somewhere the app can reach, and keep the whole arrangement upgraded. That is real work, and we would rather not make every team repeat it on every cluster. It also means the database competes with your app for the cluster's CPU and memory, and you secure it on your own.&lt;/p&gt;

&lt;p&gt;Kupe now provides managed PostgreSQL databases. You create a database for your account, then bind a role into the namespace where an app runs. The namespace receives a Service and a credentials Secret; the app mounts the Secret and uses the supplied connection URI. We built the feature around that last step. Creating a database is the easy half. The harder half is getting credentials to the right workload without pasting them into a manifest.&lt;/p&gt;

&lt;h2&gt;
  
  
  A database that outlives a cluster
&lt;/h2&gt;

&lt;p&gt;A managed database belongs to your account rather than to one cluster. A cluster can be deleted and recreated, and your data should not go with it.&lt;/p&gt;

&lt;p&gt;You choose CPU, memory and storage for an instance, with PostgreSQL 18 as the default engine version and PostgreSQL 17 also available. High availability adds a second instance with a synchronous standby. Storage can grow, but it cannot shrink; a database keeps its PostgreSQL major version, while minor updates are applied automatically.&lt;/p&gt;

&lt;p&gt;There are a few guardrails worth knowing before you create one:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Deletion protection starts on. Turn it off before deleting a database.&lt;/li&gt;
&lt;li&gt;Deleted storage is retained for seven days for support-assisted recovery. There is no self-service restore in v1.&lt;/li&gt;
&lt;li&gt;A database can alert you when storage is running low, when one instance is down while another is serving, or when no instance is up. Those alerts sit with cluster alerts in the console.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Bind the database where the app runs
&lt;/h2&gt;

&lt;p&gt;A binding joins one database role to one namespace in one cluster. It is the contract that tells Kupe where to deliver access. The cluster does not need to exist when you create the binding: if it is deleted, the binding waits and delivers again when a cluster with the same name returns.&lt;/p&gt;

&lt;p&gt;In each bound namespace, Kupe creates a Service named after the database and a credentials Secret. The default Secret name is &lt;code&gt;{database}-{role}&lt;/code&gt;. It follows the &lt;a href="https://servicebinding.io/" rel="noopener noreferrer"&gt;servicebinding.io&lt;/a&gt; key convention and includes &lt;code&gt;host&lt;/code&gt;, &lt;code&gt;port&lt;/code&gt;, &lt;code&gt;database&lt;/code&gt;, &lt;code&gt;username&lt;/code&gt;, &lt;code&gt;password&lt;/code&gt;, &lt;code&gt;sslmode&lt;/code&gt;, &lt;code&gt;ca.crt&lt;/code&gt;, &lt;code&gt;uri&lt;/code&gt;, &lt;code&gt;type&lt;/code&gt; and &lt;code&gt;provider&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The useful bit is the &lt;code&gt;uri&lt;/code&gt;. It is already configured for TLS certificate verification with &lt;code&gt;sslmode=verify-full&lt;/code&gt;, using the database's own CA. Connections without TLS are refused. Mount the Secret as a volume at &lt;code&gt;/bindings/&amp;lt;secret-name&amp;gt;&lt;/code&gt;, for example &lt;code&gt;/bindings/orders-db-readwrite&lt;/code&gt;, and set &lt;code&gt;SERVICE_BINDING_ROOT=/bindings&lt;/code&gt;. The &lt;code&gt;uri&lt;/code&gt; only works at that path, because its CA setting points at the mounted &lt;code&gt;ca.crt&lt;/code&gt;. The &lt;a href="https://docs.kupe.cloud/platform/databases/" rel="noopener noreferrer"&gt;database guide&lt;/a&gt; shows the workload configuration. Your application can then consume the binding without pasting a password or CA bundle into a Deployment manifest.&lt;/p&gt;

&lt;p&gt;The Secret tracks the database. When a password rotates or the CA is renewed, every bound Secret receives the new value. Kubernetes updates mounted Secret files in place; an application that reads its password once at startup needs a restart to pick up the change.&lt;/p&gt;

&lt;p&gt;Network access follows the same binding. Pods in the bound namespace of the bound cluster can connect; there is no public endpoint. Remove the binding and Kupe removes the Service, Secret and network path. That does &lt;strong&gt;not&lt;/strong&gt; rotate the role password. If access may already have escaped the namespace, rotate the role credentials as well.&lt;/p&gt;

&lt;h2&gt;
  
  
  Use roles to separate migrations from the service
&lt;/h2&gt;

&lt;p&gt;Managed PostgreSQL v1 has two roles:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;owner&lt;/code&gt; can change the schema. It belongs with migrations.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;readwrite&lt;/code&gt; can read and write the owner's tables but cannot change their schema. It belongs with the running service.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Put &lt;code&gt;owner&lt;/code&gt; in &lt;code&gt;orders-migrate&lt;/code&gt; and &lt;code&gt;readwrite&lt;/code&gt; in &lt;code&gt;orders-api&lt;/code&gt;, and the API namespace has no schema-changing credential to mount. That is the reason bindings are per namespace rather than a single database Secret copied around a cluster.&lt;/p&gt;

&lt;p&gt;From the CLI, the database and both bindings take three commands:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kupe database create orders-db &lt;span class="nt"&gt;--cpu&lt;/span&gt; 250m &lt;span class="nt"&gt;--memory&lt;/span&gt; 512Mi &lt;span class="nt"&gt;--storage&lt;/span&gt; 5Gi
kupe database binding add orders-db &lt;span class="nt"&gt;--cluster&lt;/span&gt; dev &lt;span class="nt"&gt;--namespace&lt;/span&gt; orders-migrate &lt;span class="nt"&gt;--role&lt;/span&gt; owner
kupe database binding add orders-db &lt;span class="nt"&gt;--cluster&lt;/span&gt; dev &lt;span class="nt"&gt;--namespace&lt;/span&gt; orders-api &lt;span class="nt"&gt;--role&lt;/span&gt; readwrite
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;create&lt;/code&gt; and &lt;code&gt;binding add&lt;/code&gt; wait until the requested object is ready. The &lt;a href="https://docs.kupe.cloud/reference/cli-database/" rel="noopener noreferrer"&gt;CLI reference&lt;/a&gt; also covers resizing, high availability, deletion protection, credential rotation and waiting in scripts.&lt;/p&gt;

&lt;h2&gt;
  
  
  The same binding in the console, CLI or Terraform
&lt;/h2&gt;

&lt;p&gt;The console, CLI and Terraform provider create the same resource. Choose the one that fits how you already manage infrastructure.&lt;/p&gt;

&lt;p&gt;In the console, open a database and choose &lt;strong&gt;Add Binding&lt;/strong&gt;, then select the cluster, namespace and role. A binding row shows &lt;code&gt;Ready&lt;/code&gt; once its Service and Secret are in place.&lt;/p&gt;

&lt;p&gt;For infrastructure managed in pull requests, the Terraform provider has &lt;code&gt;kupe_database&lt;/code&gt;, &lt;code&gt;kupe_database_binding&lt;/code&gt; and a &lt;code&gt;kupe_database&lt;/code&gt; data source. Here is the binding portion of a database definition:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"kupe_database_binding"&lt;/span&gt; &lt;span class="s2"&gt;"migrations"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;database&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;kupe_database&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;orders&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;
  &lt;span class="nx"&gt;cluster&lt;/span&gt;    &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;kupe_cluster&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;prod&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;
  &lt;span class="nx"&gt;namespaces&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"orders-migrate"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="nx"&gt;role&lt;/span&gt;       &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"owner"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"kupe_database_binding"&lt;/span&gt; &lt;span class="s2"&gt;"api"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;database&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;kupe_database&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;orders&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;
  &lt;span class="nx"&gt;cluster&lt;/span&gt;    &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;kupe_cluster&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;prod&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;
  &lt;span class="nx"&gt;namespaces&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"orders-api"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="nx"&gt;role&lt;/span&gt;       &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"readwrite"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The provider rejects v1 changes that cannot work, including shrinking storage or changing the PostgreSQL major version. It also respects deletion protection. If a namespace already contains a same-named Service or Secret that Kupe did not create, the binding remains failed rather than overwriting somebody else's object. The binding stays failed until you remove or rename your object, which beats an apply that silently replaces a Secret your workload depends on.&lt;/p&gt;

&lt;p&gt;The public &lt;a href="https://github.com/kupecloud/hello-kupe/tree/main/samples/managed-database" rel="noopener noreferrer"&gt;managed-database sample&lt;/a&gt; puts this shape to use: a migration Job uses the owner role, a Go API uses &lt;code&gt;readwrite&lt;/code&gt;, and its frontend has no database binding at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  The edges of v1
&lt;/h2&gt;

&lt;p&gt;This first version has clear limits. It is PostgreSQL only, supports one PostgreSQL database per instance, has no custom roles or extensions, no built-in connection pooling, no backups or self-service restore, and no in-place major version upgrades.&lt;/p&gt;

&lt;p&gt;We are working towards backups with point-in-time restore, more control over roles and in-place major upgrades. Those changes are additive: an application built on a v1 binding will not need a different connection model when they arrive.&lt;/p&gt;

&lt;p&gt;Start with a small database and one namespace. Bind &lt;code&gt;readwrite&lt;/code&gt;, mount the Secret, and connect using its &lt;code&gt;uri&lt;/code&gt;. The &lt;a href="https://docs.kupe.cloud/get-started/add-a-database" rel="noopener noreferrer"&gt;get-started guide&lt;/a&gt; has the full path; if you run into something rough while trying it, email &lt;a href="mailto:support@kupe.cloud"&gt;support@kupe.cloud&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>productupdates</category>
      <category>buildingkupe</category>
      <category>devops</category>
      <category>kubernetes</category>
    </item>
    <item>
      <title>Create a Kubernetes cluster from the command line | Kupe Cloud</title>
      <dc:creator>Billy Walker</dc:creator>
      <pubDate>Tue, 13 Oct 2026 17:02:19 +0000</pubDate>
      <link>https://dev.to/kupecloud/create-a-kubernetes-cluster-from-the-command-line-kupe-cloud-2d72</link>
      <guid>https://dev.to/kupecloud/create-a-kubernetes-cluster-from-the-command-line-kupe-cloud-2d72</guid>
      <description>&lt;p&gt;Getting a Kubernetes cluster shouldn't involve a wizard, a downloaded file and a kubeconfig you'll have lost by Friday. With the Kupe CLI it's four commands, and the last one is &lt;code&gt;kubectl&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kupe auth login
kupe cluster create dev &lt;span class="nt"&gt;--cpu-limit&lt;/span&gt; 2 &lt;span class="nt"&gt;--memory-limit&lt;/span&gt; 8Gi &lt;span class="nt"&gt;--storage-limit&lt;/span&gt; 50Gi
kupe cluster kubeconfig dev &lt;span class="nt"&gt;--merge&lt;/span&gt; &lt;span class="nt"&gt;--exec&lt;/span&gt;
kubectl get pods
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This post walks through what each of those does, why we built the CLI the way we did, and how the same commands hold up when a CI job runs them with nobody watching. If you already use &lt;code&gt;kubectl&lt;/code&gt;, most of it should feel familiar. That was the plan.&lt;/p&gt;

&lt;h2&gt;
  
  
  Install and sign in
&lt;/h2&gt;

&lt;p&gt;You'll need a Kupe Cloud account first, and &lt;a href="https://kupe.cloud/signup" rel="noopener noreferrer"&gt;sign-up is open&lt;/a&gt; during the alpha.&lt;/p&gt;

&lt;p&gt;The quickest install is Homebrew (&lt;code&gt;brew install kupecloud/tap/kupe&lt;/code&gt;) or the install script (&lt;code&gt;curl -fsSL https://get.kupe.cloud | sh&lt;/code&gt;). The &lt;a href="https://docs.kupe.cloud/platform/kupe-cli/" rel="noopener noreferrer"&gt;CLI docs&lt;/a&gt; cover Windows and every other route.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;kupe auth login&lt;/code&gt; uses the OAuth device flow, the same pattern a smart TV uses when it shows you a code and tells you to finish on your phone. The CLI prints a short code and a URL, you approve in any browser, and the CLI collects the tokens. Your password never passes through it, and because there's no local web server involved it behaves the same over SSH or in a dev container. Tokens live in your operating system's keyring where one exists, and refresh themselves on later commands.&lt;/p&gt;

&lt;h2&gt;
  
  
  Create a cluster
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kupe cluster create dev &lt;span class="nt"&gt;--cpu-limit&lt;/span&gt; 2 &lt;span class="nt"&gt;--memory-limit&lt;/span&gt; 8Gi &lt;span class="nt"&gt;--storage-limit&lt;/span&gt; 50Gi
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The three limits have no defaults. They cap what workloads in the cluster can use and count against your plan's pool; &lt;code&gt;kupe plan list&lt;/code&gt; shows what your plan allows.&lt;/p&gt;

&lt;p&gt;Underneath, cluster creation is asynchronous. The API accepts the request straight away and the cluster moves through phases (&lt;code&gt;pending&lt;/code&gt;, &lt;code&gt;provisioning&lt;/code&gt;, &lt;code&gt;running&lt;/code&gt;) while the platform builds it. The CLI watches that phase and only returns once the cluster is &lt;code&gt;running&lt;/code&gt;, so the next line of a script can assume the cluster exists. If you'd rather have your prompt back immediately, pass &lt;code&gt;--wait=false&lt;/code&gt; and come back later with &lt;code&gt;kupe cluster wait dev --for running&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Connect kubectl
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kupe cluster kubeconfig dev &lt;span class="nt"&gt;--merge&lt;/span&gt; &lt;span class="nt"&gt;--exec&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without &lt;code&gt;--merge&lt;/code&gt;, the command prints a kubeconfig to stdout for you to redirect into a file. With it, the cluster is added to the kubeconfig you already use, under a context named &lt;code&gt;kupe-&amp;lt;tenant&amp;gt;-&amp;lt;cluster&amp;gt;&lt;/code&gt;, and becomes your current context, so there's no second file to keep track of.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;--exec&lt;/code&gt; flag changes what goes in that kubeconfig. By default it embeds your token, which makes the file a secret and means it stops working when the token does. With &lt;code&gt;--exec&lt;/code&gt; there is no token in the file at all. Instead, the file tells &lt;code&gt;kubectl&lt;/code&gt; to run &lt;code&gt;kupe auth get-token&lt;/code&gt; whenever it needs a credential, using Kubernetes' standard &lt;a href="https://kubernetes.io/docs/reference/access-authn-authz/authentication/" rel="noopener noreferrer"&gt;exec credential plugin&lt;/a&gt; mechanism (hence the flag name). The AWS, Google and Azure CLIs connect &lt;code&gt;kubectl&lt;/code&gt; to their managed clusters the same way.&lt;/p&gt;

&lt;p&gt;The trade-off is that &lt;code&gt;kubectl&lt;/code&gt; now needs &lt;code&gt;kupe&lt;/code&gt; on its &lt;code&gt;PATH&lt;/code&gt;. That suits a laptop. For a throwaway container where you can't count on the binary being there, leave &lt;code&gt;--exec&lt;/code&gt; off and treat the kubeconfig as the credential it is.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it looks like kubectl (mostly)
&lt;/h2&gt;

&lt;p&gt;Our design notes start from two rules. The first is that a developer at a terminal and a CI job run the same commands, and the binary adapts to which one it is. Spinners, colour and confirmation prompts only appear on a terminal. What the CLI writes to stdout is the same either way, with progress and errors kept on stderr, so &lt;code&gt;kupe cluster list -o json | jq&lt;/code&gt; never finds a spinner in its input.&lt;/p&gt;

&lt;p&gt;The second rule is that we shouldn't surprise &lt;code&gt;kubectl&lt;/code&gt; users, since you're probably switching between the two all day. So &lt;code&gt;-o&lt;/code&gt; takes &lt;code&gt;table&lt;/code&gt;, &lt;code&gt;wide&lt;/code&gt;, &lt;code&gt;json&lt;/code&gt;, &lt;code&gt;yaml&lt;/code&gt;, &lt;code&gt;name&lt;/code&gt; and &lt;code&gt;go-template=...&lt;/code&gt;, and exit codes and error shapes follow kubectl wherever it has a convention.&lt;/p&gt;

&lt;p&gt;We deliberately broke from &lt;code&gt;kubectl&lt;/code&gt; in a few places, and each time there was an alternative we weighed:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Noun first.&lt;/strong&gt; &lt;code&gt;kubectl&lt;/code&gt; puts the verb first (&lt;code&gt;kubectl get pods&lt;/code&gt;) because it's a generic client for dozens of resource kinds that all share the same verbs. Kupe has a handful of nouns and you'll type &lt;code&gt;kupe cluster &amp;lt;tab&amp;gt;&lt;/code&gt; far more often than &lt;code&gt;kupe &amp;lt;verb&amp;gt;&lt;/code&gt;, so &lt;code&gt;kupe cluster create&lt;/code&gt; gives completion a smaller list to offer. &lt;code&gt;gh&lt;/code&gt;, &lt;code&gt;fly&lt;/code&gt; and &lt;code&gt;hcloud&lt;/code&gt; read the same way.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;-o json&lt;/code&gt;, not &lt;code&gt;--json&lt;/code&gt;.&lt;/strong&gt; &lt;code&gt;gh&lt;/code&gt; and &lt;code&gt;fly&lt;/code&gt; use a &lt;code&gt;--json&lt;/code&gt; flag. We kept kubectl's &lt;code&gt;-o&lt;/code&gt; because &lt;code&gt;yaml&lt;/code&gt;, &lt;code&gt;name&lt;/code&gt;, &lt;code&gt;wide&lt;/code&gt; and &lt;code&gt;go-template&lt;/code&gt; all belong under one umbrella.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Our own printer.&lt;/strong&gt; We looked at reusing the printing library from Kubernetes itself, but it expects Kubernetes objects. Our resources are plain structs, so we wrote a small printer instead.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Contexts keep you honest
&lt;/h2&gt;

&lt;p&gt;Sign in to a second tenant and you get a second context, so staging and production can't blur together:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kupe auth login &lt;span class="nt"&gt;--tenant&lt;/span&gt; acme-staging &lt;span class="nt"&gt;--context&lt;/span&gt; staging
kupe config use-context staging
kupe config current-context
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;kupe config view&lt;/code&gt; shows the active configuration with the tokens redacted, which makes it safe to paste into a support request. Every command also takes &lt;code&gt;--tenant&lt;/code&gt; and &lt;code&gt;--context&lt;/code&gt; (or &lt;code&gt;KUPE_TENANT&lt;/code&gt; and &lt;code&gt;KUPE_CONTEXT&lt;/code&gt;), which is what you'll reach for in scripts.&lt;/p&gt;

&lt;h2&gt;
  
  
  Running it in CI
&lt;/h2&gt;

&lt;p&gt;A pipeline can't open a browser, so it signs in with an API key instead. Mint one scoped to the tenant, give it an expiry, and store the output as a CI secret:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kupe apikey create &lt;span class="nt"&gt;--name&lt;/span&gt; ci &lt;span class="nt"&gt;--role&lt;/span&gt; admin &lt;span class="nt"&gt;--expires-at&lt;/span&gt; 90d
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The raw key is printed once and can't be retrieved again. Pass it to the CLI as &lt;code&gt;KUPE_API_TOKEN&lt;/code&gt;; the &lt;code&gt;--token&lt;/code&gt; flag also works but leaves the value in shell history and process listings, which is why the help text itself warns against it.&lt;/p&gt;

&lt;p&gt;Here is a job that creates a cluster, deploys to it, and always cleans up:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/usr/bin/env bash&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-euo&lt;/span&gt; pipefail

&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;KUPE_API_TOKEN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$CI_KUPE_TOKEN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;KUPE_TENANT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"acme"&lt;/span&gt;

&lt;span class="nv"&gt;cluster&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"ci-&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;GITHUB_SHA&lt;/span&gt;:0:7&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nb"&gt;trap&lt;/span&gt; &lt;span class="s1"&gt;'kupe cluster delete "$cluster" --yes'&lt;/span&gt; EXIT

kupe cluster create &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$cluster&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--cpu-limit&lt;/span&gt; 2 &lt;span class="nt"&gt;--memory-limit&lt;/span&gt; 8Gi &lt;span class="nt"&gt;--storage-limit&lt;/span&gt; 50Gi &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--wait-timeout&lt;/span&gt; 10m

kupe cluster kubeconfig &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$cluster&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--merge&lt;/span&gt; &lt;span class="nt"&gt;--force&lt;/span&gt;
kubectl &lt;span class="nt"&gt;--context&lt;/span&gt; &lt;span class="s2"&gt;"kupe-&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;KUPE_TENANT&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;-&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;cluster&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; apply &lt;span class="nt"&gt;-f&lt;/span&gt; example.yaml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three details do the work here. &lt;code&gt;create&lt;/code&gt; waits by default, so there's no &lt;code&gt;sleep&lt;/code&gt; to guess at. Outside a terminal, &lt;code&gt;delete&lt;/code&gt; refuses to run without &lt;code&gt;--yes&lt;/code&gt;, so a stray redirect can't delete infrastructure. And &lt;code&gt;delete&lt;/code&gt; waits as well, so a script that recreates the same name straight away isn't racing the old cluster on its way out. We learned that one from our own tests, when a destroy-and-recreate collided with the previous cluster's teardown. The platform copes with that now, but waiting is still the safer default.&lt;/p&gt;

&lt;p&gt;The exit codes are part of the contract too, and they're stable across releases. &lt;code&gt;3&lt;/code&gt; means authentication failed, &lt;code&gt;4&lt;/code&gt; means not found, &lt;code&gt;5&lt;/code&gt; means conflict, and &lt;code&gt;8&lt;/code&gt; means a &lt;code&gt;--wait-timeout&lt;/code&gt; ran out. That lets a script tell a bad token from a slow cluster without parsing error text. The &lt;a href="https://github.com/kupecloud/kupe-cli/blob/main/docs/design.md" rel="noopener noreferrer"&gt;full table&lt;/a&gt; is in the repository.&lt;/p&gt;

&lt;p&gt;We rely on that contract ourselves. The CLI's live test suite builds the real binary and runs it against the deployed API: &lt;code&gt;whoami&lt;/code&gt;, tenant and member commands, a bad token failing without leaking into the output, and an opt-in create-and-delete of a real cluster. If a command's output or exit code changes, that's where we find out.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;-o name&lt;/code&gt; helps with housekeeping too. A cluster per commit counts against your plan's cluster limit, so this clears out any CI clusters a cancelled job left behind:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kupe cluster list &lt;span class="nt"&gt;-o&lt;/span&gt; name | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="s1"&gt;'^ci-'&lt;/span&gt; | xargs &lt;span class="nt"&gt;-n1&lt;/span&gt; kupe cluster delete &lt;span class="nt"&gt;--yes&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Where to start
&lt;/h2&gt;

&lt;p&gt;The CLI is imperative, which makes it good for trying things out and for the glue between CI steps, and a poor record of what exists. If you want clusters reviewable in a pull request, use the &lt;a href="https://docs.kupe.cloud/terraform/" rel="noopener noreferrer"&gt;Terraform provider&lt;/a&gt;. If you're writing a service, call the &lt;a href="https://docs.kupe.cloud/api/" rel="noopener noreferrer"&gt;API&lt;/a&gt;. If your team needs a shared view, use the &lt;a href="https://console.kupe.cloud" rel="noopener noreferrer"&gt;console&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;For a first run, create a throwaway cluster, merge its kubeconfig, run a few &lt;code&gt;kubectl&lt;/code&gt; commands, then delete it and watch the delete wait for you. If you hit any issues while experimenting with the CLI, &lt;a href="https://kupe.cloud/contact" rel="noopener noreferrer"&gt;get in touch&lt;/a&gt; and we'll do our best to help.&lt;/p&gt;

</description>
      <category>devops</category>
      <category>kubernetes</category>
      <category>buildingkupe</category>
      <category>engineeringnotes</category>
    </item>
    <item>
      <title>Kupe Cloud alpha is open</title>
      <dc:creator>Billy Walker</dc:creator>
      <pubDate>Wed, 07 Oct 2026 18:33:28 +0000</pubDate>
      <link>https://dev.to/kupecloud/kupe-cloud-alpha-is-open-1eb4</link>
      <guid>https://dev.to/kupecloud/kupe-cloud-alpha-is-open-1eb4</guid>
      <description>&lt;p&gt;Signup for Kupe Cloud is open. There is no invite code, no card and no charge during alpha.&lt;/p&gt;

&lt;p&gt;Alpha is where we find out whether Kupe does what we built it to do: give a team a cluster with GitOps, secrets, metrics, logs and alerting already wired, so they can get on with shipping. We kept it closed for the first two months. The core paths went to a small group of people who already run Kubernetes for a living, because they notice when something is off and can say why. We spent the same two months trying to break the platform ourselves, on production, after every change.&lt;/p&gt;

&lt;p&gt;This post is about what that found, and what changed before we opened signup.&lt;/p&gt;

&lt;h2&gt;
  
  
  We became the first tenant
&lt;/h2&gt;

&lt;p&gt;The end-to-end suite has sixteen phases and a full run takes just under forty minutes. It goes through the public signup service rather than an admin shortcut, creates the cluster through the public API and deploys through Argo CD inside that cluster. Only the residue sweep afterwards runs with admin access, because somebody has to check the cupboard after the party. Everything on the customer path runs as a customer would.&lt;/p&gt;

&lt;p&gt;We ran three load profiles alongside it: steady traffic at a small demo app, a workload that outgrows its cluster, and concurrent tenant onboarding through the real signup route. The traffic run handled 298,708 requests over fifteen minutes, with a p95 of 140 ms and no failed requests through the external gateway and TLS. That result came from a tiny demo app on the smallest cluster size. It says the path holds under that test; it does not predict your application's latency.&lt;/p&gt;

&lt;p&gt;The cluster-growth tests answered a different question: does the platform add capacity while the workload is still asking for it? In one run, the HPA moved from two to thirteen replicas and the platform added a node under load. A separate scale-out run onboarded two tenants concurrently and left no residue. These are small runs on purpose. They gave us enough surface area to find the awkward parts before asking strangers to do it for us.&lt;/p&gt;

&lt;h2&gt;
  
  
  The things that broke were useful
&lt;/h2&gt;

&lt;p&gt;Closed alpha earned its keep here. A routine operating-system update brought one node back without networking because a component of the cluster network could not start on the updated kernel. Our reboot tooling could not run on that node either, so it retained the cluster-wide reboot lock.&lt;/p&gt;

&lt;p&gt;The first fix looked sensible and changed nothing. It turned off in-place updates on the nodes our provisioning tooling manages, and the workers that needed it are created by the autoscaler, which that tooling never touches. We stopped patching nodes in place. Nodes are now replaced from validated images instead. A follow-up audit found an image-rebuild job that had been reporting success while building nothing. Green is a poor signal when the successful path is to skip every input.&lt;/p&gt;

&lt;p&gt;Capacity is the other ordinary failure. Our infrastructure provider does not always have a server available when the autoscaler asks for one. The autoscaler now tries the next size up, alerting includes the pool and the reason, and there is a runbook for the operator who gets the alert. With an empty pool, the first cluster waits for a node to arrive.&lt;/p&gt;

&lt;p&gt;A few failures only appear when two tenants do the same thing at once. Two clusters with the same name collided on a host-side role name. A concurrent teardown could also lose the record that told the platform to purge a tenant's data. The scale-out driver found both, and deletion now runs through the same endpoints customers use. You can read the public &lt;a href="https://docs.kupe.cloud/platform/deletion" rel="noopener noreferrer"&gt;deletion documentation&lt;/a&gt; before trying it yourself.&lt;/p&gt;

&lt;p&gt;Alerting needed the same scrutiny. Six chronic alerts were re-posting the same unactionable backlog every four hours. They were responsible for most of the noise, rather than the resource alerts we initially suspected. Fixing that reduced alert volume by 92%. Backlog work and incidents now travel through separate alert paths; an alert in the incident channel should be worth somebody's sleep.&lt;/p&gt;

&lt;p&gt;We also made isolation a routine assertion instead of a promise in an architecture diagram. Every end-to-end run creates a second real tenant and checks API access, metrics, logs, dashboards, GitOps destinations, host RBAC and the network boundary. The test that proves one tenant cannot read another's metrics runs before each change ships.&lt;/p&gt;

&lt;h2&gt;
  
  
  What changed in the platform
&lt;/h2&gt;

&lt;p&gt;The image pipeline builds on a cadence, validates the result, canaries a node, then rolls control planes before core nodes. Promotion runs through a staging estate with full end-to-end and load tests before production. That architecture exists to keep an update from becoming a cluster-wide leap of faith. A cluster without high availability can still see a brief API interruption while a node is replaced; choose HA when you create the cluster if that interruption is not acceptable. The &lt;a href="https://docs.kupe.cloud/platform/high-availability" rel="noopener noreferrer"&gt;high-availability documentation&lt;/a&gt; explains that choice.&lt;/p&gt;

&lt;p&gt;Signup is open with one tenant per account for now, plus a cap on total tenants that we raise as we go. The point is containment: an ordinary bad day should stay small. Tenant and account deletion are self-service from the console or CLI, and the suite checks for residue after every deletion.&lt;/p&gt;

&lt;p&gt;Releases are built, signed and pinned by digest before they are versioned. The cluster will not run an unsigned image, and baseline policies are enforced. The property that matters is that an untrusted image never becomes a running workload.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is still rough
&lt;/h2&gt;

&lt;p&gt;Alpha is still alpha. Dedicated nodes and backup tooling do not exist yet, there is no SLA, and maintenance can interrupt a single-replica cluster. The &lt;a href="https://dev.to/alpha"&gt;alpha service notice&lt;/a&gt; has the current limits and the parts of the service that remain experimental.&lt;/p&gt;

&lt;h2&gt;
  
  
  Start with the first fifteen minutes
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://dev.to/signup"&gt;Create an account&lt;/a&gt;, create a cluster and deploy something small. It takes no invite code and nothing to pay. Then tell us where the path felt heavier than you expected, especially in the first fifteen minutes: a label you expected to find, a default you expected to be set, or a step that made you stop and ask why it existed. That is exactly the feedback alpha is for. Send it to &lt;a href="mailto:support@kupe.cloud"&gt;support@kupe.cloud&lt;/a&gt;, and check &lt;a href="https://status.kupe.cloud" rel="noopener noreferrer"&gt;status.kupe.cloud&lt;/a&gt; before treating an odd result as your fault.&lt;/p&gt;

</description>
      <category>devops</category>
      <category>kubernetes</category>
      <category>productupdates</category>
      <category>buildingkupe</category>
    </item>
    <item>
      <title>vCluster custom resource sync, one plugin for every kind | Kupe Cloud</title>
      <dc:creator>Billy Walker</dc:creator>
      <pubDate>Mon, 05 Oct 2026 12:12:52 +0000</pubDate>
      <link>https://dev.to/kupecloud/vcluster-custom-resource-sync-one-plugin-for-every-kind-kupe-cloud-2jg5</link>
      <guid>https://dev.to/kupecloud/vcluster-custom-resource-sync-one-plugin-for-every-kind-kupe-cloud-2jg5</guid>
      <description>&lt;p&gt;vCluster runs a full Kubernetes control plane as a pod inside a namespace of a host cluster. We use it to run our tenants' clusters: each one gets cluster-admin inside a real API boundary, without the cost of a dedicated cluster underneath. Anything self-contained inside the virtual cluster works out of the box: Deployments, Services, the operators a tenant installs. The trouble starts when a tenant writes an Argo CD &lt;code&gt;Application&lt;/code&gt; that only the central Argo CD on the host can reconcile, an &lt;code&gt;HTTPRoute&lt;/code&gt; that needs to attach to a shared Gateway, or an alert rule that belongs on the host ruler. Every multi-tenant platform grows these crossings: objects written on one side of a boundary that a controller on the other side has to act on.&lt;/p&gt;

&lt;p&gt;The options for syncing resources across that boundary are thinner than you'd expect. vCluster's open-source syncer handles the core resources well: Pods, Services, Secrets and friends. But the resources we most needed to sync were custom resources, and some of them needed references translated on the way across. vCluster does offer custom-resource syncing and sync patches, but they ship in the Pro image, behind a licence validated against vCluster Platform. That's a reasonable product boundary; it just wasn't a dependency we wanted in the boot and data path of every tenant cluster.&lt;/p&gt;

&lt;p&gt;The open-source routes weren't in better shape. Loft's own &lt;a href="https://github.com/loft-sh/vcluster-generic-crd-sync-plugin" rel="noopener noreferrer"&gt;generic CRD sync plugin&lt;/a&gt; pointed in the right direction but has been archived, which leaves the vCluster plugin SDK and a controller of your own.&lt;/p&gt;

&lt;p&gt;So that's what we built: an &lt;a href="https://github.com/kupecloud/vcluster-generic-sync-plugin" rel="noopener noreferrer"&gt;open vCluster generic sync plugin&lt;/a&gt;, Apache-2.0, a single plugin process that syncs any resource kind you declare in its configuration. It carries the pieces a real platform needs: a direction and mode per kind, reference translation, label and namespace selectors, and status sync. Every crossing is explicit, reviewable and independent of a licence service.&lt;/p&gt;

&lt;h2&gt;
  
  
  Resources cross the boundary for different reasons
&lt;/h2&gt;

&lt;p&gt;It's worth naming the actual objects rather than filing them all under “multi-tenant integration”. Each crossing has an owner, a direction and a reason:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Some objects leave the tenant cluster for a host controller: an &lt;code&gt;Application&lt;/code&gt; for the central Argo CD, a &lt;code&gt;PrometheusRule&lt;/code&gt; for the host Mimir ruler.&lt;/li&gt;
&lt;li&gt;Some go the same way but need their status back: an &lt;code&gt;HTTPRoute&lt;/code&gt; attaches to the host Gateway, and the tenant needs to see whether it was accepted.&lt;/li&gt;
&lt;li&gt;Some only cross on a condition: a dashboard &lt;code&gt;ConfigMap&lt;/code&gt; leaves only when it carries the dashboard label.&lt;/li&gt;
&lt;li&gt;Some travel the other way: platform-managed &lt;code&gt;Secret&lt;/code&gt; objects come back into the tenant cluster.&lt;/li&gt;
&lt;li&gt;Some are read-only imports: &lt;code&gt;GatewayClass&lt;/code&gt; objects are mirrored so tenants can discover the shared gateway classes.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Those aren't interchangeable operations. A route needs its service references translated; a gateway class must remain host-owned; a repository credential should be admitted only by a narrow label selector. A single broad “sync CRDs” switch would hide all of that policy in code and RBAC.&lt;/p&gt;

&lt;p&gt;Our plugin makes the policy a list of &lt;code&gt;syncResources&lt;/code&gt; entries. Each entry names an &lt;code&gt;apiVersion&lt;/code&gt;, &lt;code&gt;kind&lt;/code&gt;, a direction (&lt;code&gt;toHost&lt;/code&gt; or &lt;code&gt;fromHost&lt;/code&gt;) and an optional selector, plus a mode: &lt;code&gt;sync&lt;/code&gt; reconciles the source object to its target, while &lt;code&gt;mirror&lt;/code&gt; is a read-only import. Patches translate references and nothing else: &lt;code&gt;rewriteName&lt;/code&gt;, &lt;code&gt;rewriteNamespace&lt;/code&gt;, &lt;code&gt;rewriteRef&lt;/code&gt;, &lt;code&gt;rewriteHostRef&lt;/code&gt;, &lt;code&gt;rewriteLabelSelector&lt;/code&gt; and &lt;code&gt;none&lt;/code&gt; are the complete vocabulary.&lt;/p&gt;

&lt;p&gt;That restriction is deliberate. A sync configuration should say which reference is being translated and why. It shouldn't become a second, unreviewed templating language.&lt;/p&gt;

&lt;h2&gt;
  
  
  The plugin protects the host copy
&lt;/h2&gt;

&lt;p&gt;A resource crossing a cluster boundary isn't a blind copy. The plugin has a few rules that keep source-of-truth decisions visible.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;statusSync&lt;/code&gt; only operates for a resource with a real status subresource, and status always flows from host to virtual cluster. The plugin strips tenant-written status before it creates the host object. A tenant cannot write an &lt;code&gt;Accepted=True&lt;/code&gt; condition into a route or an application and have the platform treat it as host state.&lt;/p&gt;

&lt;p&gt;Selectors support labels plus namespace include and exclude rules. Exclusions win. Deletes are provenance-gated: the plugin removes only an object it created and stamped, never an unrelated object that happens to share a name. Trace logging redacts &lt;code&gt;Secret&lt;/code&gt; data, and the plugin exports Prometheus metrics on its own endpoint.&lt;/p&gt;

&lt;p&gt;The configuration parser is strict as well. An unknown field is an error, so a misspelt key in the sort of YAML that lives for months in a tenant chart fails at load time.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Argo field we had to leave alone
&lt;/h2&gt;

&lt;p&gt;One lesson came the hard way, from a real failure in our own Argo CD integration. Argo stores a pending sync in the top-level &lt;code&gt;operation&lt;/code&gt; field of an &lt;code&gt;Application&lt;/code&gt;. Our syncer treated the virtual copy as complete desired state and could remove that field after Argo wrote it but before Argo's worker read it. Auto-sync then never ran.&lt;/p&gt;

&lt;p&gt;The fix became &lt;code&gt;hostOwnedFields&lt;/code&gt;. These are top-level fields owned by a host controller: the syncer neither copies them from the virtual object nor deletes them when the virtual object lacks them, and host-side changes do not trigger reconciliation. &lt;code&gt;Application.operation&lt;/code&gt; is now host-owned by default.&lt;/p&gt;

&lt;p&gt;That rule sounds narrow until you run a controller on both sides of a boundary. The object may be shared; every field does not have to be.&lt;/p&gt;

&lt;h2&gt;
  
  
  A small custom-resource sync
&lt;/h2&gt;

&lt;p&gt;The smallest useful configuration syncs one custom resource from a tenant vCluster to its host cluster. For this walkthrough we'll borrow the test-only &lt;code&gt;Widget&lt;/code&gt; CRD from the plugin's own end-to-end suite.&lt;/p&gt;

&lt;p&gt;First install the CRD on both sides:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl apply &lt;span class="nt"&gt;-f&lt;/span&gt; https://raw.githubusercontent.com/kupecloud/vcluster-generic-sync-plugin/main/test/testdata/widget-crd.yaml
vcluster connect my-vcluster &lt;span class="nt"&gt;--&lt;/span&gt; kubectl apply &lt;span class="nt"&gt;-f&lt;/span&gt; https://raw.githubusercontent.com/kupecloud/vcluster-generic-sync-plugin/main/test/testdata/widget-crd.yaml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then add the plugin and the RBAC required to discover the CRD and reconcile &lt;code&gt;Widget&lt;/code&gt; objects:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;plugin&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;generic-sync&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;v2&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ghcr.io/kupecloud/vcluster-generic-sync-plugin:latest&lt;/span&gt; &lt;span class="c1"&gt;# pin a release in production&lt;/span&gt;
    &lt;span class="na"&gt;imagePullPolicy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;IfNotPresent&lt;/span&gt;
    &lt;span class="na"&gt;rbac&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;extraRules&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;apiGroups&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;example.com'&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
            &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;widgets'&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
            &lt;span class="na"&gt;verbs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;create'&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;delete'&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;patch'&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;update'&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;get'&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;list'&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;watch'&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;clusterRole&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;extraRules&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;apiGroups&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;apiextensions.k8s.io'&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
            &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;customresourcedefinitions'&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
            &lt;span class="na"&gt;verbs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;get'&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;list'&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;watch'&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;config&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;v1&lt;/span&gt;
      &lt;span class="na"&gt;syncResources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;example.com/v1&lt;/span&gt;
          &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Widget&lt;/span&gt;
          &lt;span class="na"&gt;direction&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;toHost&lt;/span&gt;

&lt;span class="na"&gt;rbac&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;clusterRole&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;enabled&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The two &lt;code&gt;version&lt;/code&gt; fields are unrelated: &lt;code&gt;v2&lt;/code&gt; is vCluster's plugin format, while &lt;code&gt;config.version: v1&lt;/code&gt; is the plugin's own configuration schema. The &lt;code&gt;rbac&lt;/code&gt; blocks are plain vCluster chart values; the chart folds &lt;code&gt;extraRules&lt;/code&gt; into the Role and ClusterRole it creates for the syncer pod.&lt;/p&gt;

&lt;p&gt;Create the &lt;a href="https://raw.githubusercontent.com/kupecloud/vcluster-generic-sync-plugin/main/test/testdata/widget.yaml" rel="noopener noreferrer"&gt;matching test object&lt;/a&gt; through the vCluster connection. The plugin's &lt;a href="https://github.com/kupecloud/vcluster-generic-sync-plugin/blob/main/docs/examples/base-config.md" rel="noopener noreferrer"&gt;base configuration guide&lt;/a&gt; explains the RBAC shape in more detail. In a real installation, scope those rules to each kind and namespace you intend to sync; &lt;code&gt;*&lt;/code&gt; is easier to write and much harder to explain later.&lt;/p&gt;

&lt;h2&gt;
  
  
  Shared Gateway, tenant routes
&lt;/h2&gt;

&lt;p&gt;Gateway API shows why a generic syncer needs patches. The tenant's &lt;code&gt;HTTPRoute&lt;/code&gt; has two kinds of references:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;parentRefs&lt;/code&gt; point at a Gateway that already exists on the host. Keep those values unchanged.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;backendRefs&lt;/code&gt; point at Services from the tenant cluster. Rewrite those references to the host-side names created by vCluster.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The plugin's &lt;a href="https://github.com/kupecloud/vcluster-generic-sync-plugin/blob/main/docs/examples/gateway-api.md" rel="noopener noreferrer"&gt;Gateway API example&lt;/a&gt; installs the Gateway API CRDs on both clusters and supplies the complete RBAC. The sync configuration does the real work:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;plugin&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;generic-sync&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;v2&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ghcr.io/kupecloud/vcluster-generic-sync-plugin:latest&lt;/span&gt; &lt;span class="c1"&gt;# pin a release in production&lt;/span&gt;
    &lt;span class="na"&gt;config&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;v1&lt;/span&gt;
      &lt;span class="na"&gt;syncResources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gateway.networking.k8s.io/v1&lt;/span&gt;
          &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;HTTPRoute&lt;/span&gt;
          &lt;span class="na"&gt;direction&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;toHost&lt;/span&gt;
          &lt;span class="na"&gt;statusSync&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
          &lt;span class="na"&gt;patches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;spec.parentRefs[*]&lt;/span&gt;
              &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;none&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;spec.rules[*].backendRefs[*]&lt;/span&gt;
              &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;rewriteRef&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;type: none&lt;/code&gt; is a sharp tool. It lets a route target a shared host Gateway directly, so the host must enforce which vCluster namespaces may attach, which hostnames they may claim, and where the listener sends traffic. The plugin can preserve a reference; it cannot decide whether that reference is safe for your gateway policy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Seven crossings, scoped permissions
&lt;/h2&gt;

&lt;p&gt;Our production configuration contains seven resource kinds built from the same pieces: a read-only &lt;code&gt;GatewayClass&lt;/code&gt; mirror; host-side &lt;code&gt;HTTPRoute&lt;/code&gt;, &lt;code&gt;Application&lt;/code&gt;, &lt;code&gt;PrometheusRule&lt;/code&gt; and dashboard sync; repository-secret sync guarded by Argo labels; and platform-managed &lt;code&gt;Secret&lt;/code&gt; import.&lt;/p&gt;

&lt;p&gt;The RBAC follows the resource boundary. &lt;code&gt;Application&lt;/code&gt; and repository-secret permissions live in namespace &lt;code&gt;Role&lt;/code&gt;s rather than a broad &lt;code&gt;ClusterRole&lt;/code&gt;, so the plugin in one tenant vCluster cannot list another tenant's credentials. &lt;code&gt;hostNamespace&lt;/code&gt; is useful for controllers such as central Argo CD, but it's not a reason to make every host namespace visible.&lt;/p&gt;

&lt;p&gt;There's a limit to what “generic” means here. The plugin can sync arbitrary declared kinds, yet it stamps &lt;code&gt;kupe.cloud/*&lt;/code&gt; labels and annotations as its provenance contract. That's how it knows which objects it owns, which copies it may delete, and which changes are worth reconciling. The generic part is the configured resource type; the ownership contract stays specific.&lt;/p&gt;

&lt;h2&gt;
  
  
  Start with one crossing
&lt;/h2&gt;

&lt;p&gt;If you're adding the plugin to your own vCluster setup, start with a single kind: the one that already has an obvious source of truth. Write one &lt;code&gt;syncResources&lt;/code&gt; entry for it, grant the narrowest RBAC that lets it work, and watch what lands on the host: the object, its translated references, its status. Once that first crossing behaves, the rest are the same pattern with different nouns.&lt;/p&gt;

&lt;p&gt;If something goes wrong, look at ownership before configuration. In our experience the first failure is a field or a reference that both sides think they own, and &lt;code&gt;hostOwnedFields&lt;/code&gt; or a patch is usually the fix.&lt;/p&gt;

&lt;p&gt;The plugin lives at &lt;a href="https://github.com/kupecloud/vcluster-generic-sync-plugin" rel="noopener noreferrer"&gt;github.com/kupecloud/vcluster-generic-sync-plugin&lt;/a&gt;, with the &lt;a href="https://github.com/kupecloud/vcluster-generic-sync-plugin/tree/main/docs/configuration" rel="noopener noreferrer"&gt;configuration reference&lt;/a&gt;, worked examples and the end-to-end suite the &lt;code&gt;Widget&lt;/code&gt; walkthrough came from. If you hit a bug or something that doesn't behave as documented, &lt;a href="https://github.com/kupecloud/vcluster-generic-sync-plugin/issues" rel="noopener noreferrer"&gt;open an issue&lt;/a&gt; and we'll happily help.&lt;/p&gt;

</description>
      <category>devops</category>
      <category>kubernetes</category>
      <category>buildingkupe</category>
      <category>engineeringnotes</category>
    </item>
  </channel>
</rss>
