Building a Provider-Agnostic Hosting Platform Architecture

Building a Provider-Agnostic Hosting Platform Architecture

Let’s face one truth: having a hosting platform that only works with one cloud provider -that’s not really a platform. It is a wrapper. And in most cases, the exact moment a customer asks for a second provider, the wrapper cracks because business logic that was quietly tied to one vendor’s API shape starts leaking into every corner of the codebase.

Here, we’ll look for a different approach. Provisioning logic gets separated from infrastructure integrations. That is the basis of a provider-agnostic architecture designed to support long-term hosting platform development.

In the upcoming sections, we’ll cover the control plane, a canonical resource model, provider adapters, async provisioning, failure handling, and billing normalization. Concrete code examples will be placed whenever it matters. In other cases we’ll use diagrams, because the tradeoffs matter more than syntax.

Control Plane Versus Provider Infrastructure

A cloud hosting platform can be divided into two architectural layers. One half is what we call the control plane. It owns customer intent, authorization, resource state, and orchestration. The other half is provider infrastructure: the actual VMs, containers, storage volumes, and networks that a hosting provider runs.

What really matters is having a strict separation of concerns. This means the control plane should not be aware of platform-specific stuff (for instance, that AWS calls a virtual machine an “instance” while OpenStack calls it a “server”, or that one provider bills per second while the other does it on an hourly basis). All of those details belong purely to the provider layer. 

The rule above is well-known by most of the teams. They blur the line constantly, though. For instance, a team needs to do a quick fix for one provider, and this ends up in a shared service. While there’s only one provider, that’s fine. Six months later, that shared service can’t be reused for a second provider without a rewrite.

Chudovo’s platform enforces the boundary at the interface level. The control plane talks to providers only through adapters, never directly.

Tenant Boundaries and Credential Isolation

Another thing that is also defined by the control plane is the security boundary between customers and providers. Tenant identity, quotas, roles, and authorization policies should remain in the platform instead of being delegated inconsistently to each infrastructure API. Every provisioning request needs a tenant context that follows it through the job queue, adapter call, and audit log.

An opposite treatment is required for provider credentials. They should be retrieved by the adapter from a Secrets Manager only when needed (out of customer-facing services). A production design normally uses separate credentials by provider account, region, or tenant boundary, with the minimum permissions required for provisioning. This pattern helps to limit the damage caused by a leaked credential and makes rotation possible without changing the canonical resource model.

A Canonical Resource Model

Any control plane that pretends to stay provider-agnostic needs its own vocabulary: a neutral resource model that every adapter translates into and out of.

Here is a simplified version of that model:

Canonical Resource Core Attributes Example Provider Mapping
Compute Instance CPU, memory, image, network interfaces AWS EC2 instance, OpenStack Nova server, or KVM virtual machine
Block Storage size, IOPS tier, attachment state AWS EBS volume, Ceph RBD image
Virtual Network CIDR block, subnets, routing rules AWS VPC, OpenStack Neutron network
Object Storage bucket, region, access policy AWS S3 bucket, MinIO bucket

The table above does not pretend to be fully complete, because real systems track dozens more attributes. The point here is to show that every field maps to something concrete on at least one provider. The model doesn’t assume every provider has the same feature. Some providers support IOPS tiers. Some don’t. The model has room for absence.

Provider Adapters and Infrastructure APIs

An adapter’s job is translation, in both directions:

  • Inbound: it takes a canonical resource request and turns it into provider-specific API calls.
  • Outbound: it takes provider responses and status codes and turns them back into canonical state.

A minimal adapter interface looks something like this:

interface ProviderAdapter {
  createInstance(
    spec: CanonicalInstanceSpec,
    context: OperationContext
  ): Promise<ProviderOperationRef>;

  getOperationStatus(
    ref: ProviderOperationRef
  ): Promise<CanonicalOperationStatus>;

  getInstanceStatus(
    ref: ProviderInstanceRef
  ): Promise<CanonicalStatus>;

  deleteInstance(
    ref: ProviderInstanceRef,
    context: OperationContext
  ): Promise<ProviderOperationRef>;

  listCapabilities(): Promise<CapabilitySet>;
}

By design, Chudovo’s team keeps adapters intentionally thin. No business rule regarding when to provision, retry, or scale should be included in the adapter; they belong to the control plane. 

What really matters is the following: the adapter just knows how to speak to one provider’s API, and how to report back honestly. A provider’s API might return a success code before the resource is actually ready. An honest adapter reports the raw provider state. The control plane decides what “ready” means.

Capability Differences Between Providers

An actual fact is that providers differ in real ways. Live migration, GPU availability by region, storage tiers: none of it lines up cleanly across vendors. A multi-provider hosting platform can’t pretend those capability gaps don’t exist. It has to expose them. 

Chudovo’s adapters publish a capability set: a structured list of what a given provider integration actually supports. The control plane is the one that checks capabilities before it accepts a provisioning request, not after. The main purpose here is to restrict which configurations the platform accepts. In other words, any customer asking for a feature-rich configuration on a minimal provider gets an explicit rejection instead of a silent failure. Most customers prefer the rejection. Silent failures cost more later, in support tickets.

Contract Testing Across Provider Adapters

A common interface is only useful if every adapter interprets it consistently. Chudovo’s team validates this by testing adapters against the same contract suite. This usually means that you create a resource,  observe its state transitions, retrieve it, delete it, and confirm that repeated operations remain safe. Later on, Provider-specific tests then cover behaviors such as throttling, unavailable regions, delayed status updates, and unsupported capabilities.

Provider sandboxes or isolated accounts are needed in order to run these kinds of tests. Mocked APIs are useful for deterministic failure cases, but they cannot reproduce every behavior of a real provider. Since API response formats change, permissions drift, and documented lifecycle states do not always match what appears in production, a smaller set of scheduled integration tests helps detect that drift before customers encounter it.

A new adapter is not complete merely because it implements the interface; it must satisfy the same lifecycle guarantees as the existing providers.

Asynchronous Provisioning and State Reconciliation

Provisioning is not instant. A VM might take fifteen seconds to boot. A network might take longer to converge. While a synchronous “create and wait” model works for demos, it falls apart under real load. This is even more evident when a provider’s API is slow to respond or times out mid-request.

What really works is to treat provisioning as asynchronous by default. This means: 

  • A request creates a job. 
  • The job tracks state transitions: requested, pending, provisioning, ready, or failed. 
  • A background reconciliation loop polls provider state and updates the canonical record.

Most of the hard bugs in this system show up during reconciliation, not during the initial request. Providers don’t always agree with the platform’s internal record. Let’s consider this case: a VM might exist on the provider side but be missing from the control plane’s database, because a previous request timed out before the response arrived. Whenever the reconciliation loop detects these mismatches, it applies an explicit policy: resume monitoring, adopt the resource, retry the operation, mark it for investigation, or safely remove it. In no case should it assume that either the stored state or the provider state is automatically authoritative.

Failure Handling, Retries, and Idempotency

A well-designed, provider-agnostic control plane has to handle failures without corrupting resource state. Two techniques are key here in order to manage those scenarios: 

  • retries with backoff 
  • idempotency keys. 

A commonly used approach is to provide a client-generated idempotency key in each provisioning request. Should a request time out and the control plane retries it, the provider adapter passes the same key. Providers that support idempotent creation will not spin up a duplicate resource.

const sleep = (milliseconds: number): Promise<void> =>
  new Promise(resolve => setTimeout(resolve, milliseconds));

async function createInstanceWithRetry(
  adapter: ProviderAdapter,
  spec: CanonicalInstanceSpec,
  idempotencyKey: string,
  maxAttempts = 3
): Promise<ProviderOperationRef> {
  for (let attempt = 0; attempt < maxAttempts; attempt++) {
    try {
      return await adapter.createInstance(spec, {
        idempotencyKey
      });
    } catch (error) {
      if (!(error instanceof ProviderTimeoutError)) {
        throw error;
      }

      if (attempt === maxAttempts - 1) {
        throw error;
      }

      const exponentialDelay = 2 ** attempt * 1000;
      const jitter = Math.random() * 1000;

      await sleep(exponentialDelay + jitter);
    }
  }

  throw new Error("Maximum retry attempts reached");
}

Some providers might not support idempotency keys natively. In such cases, the control plane is the one that closes the race itself. To do this, it inserts a row into its own operation table just before calling the adapter. The idempotency key is the unique constraint. Under this model, whichever concurrent attempt wins that insert owns the operation and proceeds to call the provider. Any other attempt with the same key will fail; it will back off and poll the existing row instead of retrying the provider call. 

async function reserveOperation(
  db: DatabaseClient,
  idempotencyKey: string
): Promise<boolean> {
  try {
    await db.insert("provisioning_operations", {
      idempotencyKey,
      status: "in_progress"
    });

    return true; // this attempt owns the operation
  } catch (error) {
    if (isUniqueConstraintViolation(error)) {
      return false; // another attempt already claimed it
    }

    throw error;
  }
}

Observability Across Provider Boundaries

Debugging can become a hard job if each component records a request in a different way. A common strategy to improve this is to have a correlation ID that is propagated by the platform from the incoming API request through the provisioning job, adapter, provider operation, and reconciliation cycle. This way, the entire lifecycle can be recovered by the logs and traces without relying on timestamps alone. 

Provider context is also useful in order to have proper metrics. To take more advantage of metrics like provisioning latency, failure rate, throttling responses, reconciliation drift, and orphaned-resource counts, it’s always recommended to do segmentation based on provider, region, resource type, and operation. Having that in place, the distinction between a platform-wide failure and one degraded provider endpoint becomes easier.

A final note on Alerts: their main focus needs to be on customer impact and stuck state transitions. Not every temporary API error that the retry policy can resolve automatically.

Provider-Specific Metering and Billing Normalization

Providers meter usage differently: per-second billing, per-hour billing, tiered storage pricing, egress fees that only appear over a certain threshold. Per-second AWS pricing next to per-hour private cloud pricing on the same bill confuses people, and it shouldn’t be their problem to reconcile. 

The primary invoice should present normalized units, with provider-specific data retained for auditability. Usually, a billing normalization layer converts each provider’s usage records into a canonical billing event containing resource type, duration, quantity, and cost. This way, customers see one consistent invoice format regardless of which provider is actually running their workload underneath.

Adding a New Provider Without Touching Core Logic

To tell you whether this design actually holds up, a single question is enough: can a new provider be added without changing the control plane’s code?

In Chudovo’s platform, adding a provider means writing a new adapter that implements the standard interface. In a few words, this implies defining that provider’s capability set and mapping its resource types into the canonical model.

Ideally, the core orchestration workflow does not change. The majority of the work remains confined to the new adapter, capability definitions, lifecycle mappings, metering rules, credentials, and integration tests. Occasionally, a genuinely new provider capability may require a backward-compatible extension to the canonical model.

Avoiding Vendor Lock-In Without Pretending Every Provider is Identical

In some platforms, the abstraction exists on paper only. While they claim multi-provider support, one provider’s assumptions are hardcoded everywhere.

At Chudovo, a private cloud adapter and a public cloud adapter don’t do the same amount of work internally. And they’re not supposed to. While the contract stays the same, the implementation underneath doesn’t pretend otherwise. Vendor lock-in isn’t avoided by treating providers as interchangeable. It’s avoided by making the switching cost real and small, because the differences are modeled instead of ignored.

Architecture Overview

Provider-Agnostic Hosting Platform Architecture

The portals sit on top. Everything funnels through the control plane and the canonical model before it reaches any specific provider. Each adapter is the only piece of code that knows about its provider’s actual API.

This hosting platform architecture keeps provider-specific dependencies below the canonical model. The portals sit on top, and every provisioning request passes through the control plane before reaching an adapter.

Closing thoughts

In the sections above, we exposed the architecture of a hosting platform with multi-provider support. While it does not erase the cost of supporting multiple infrastructure providers, such cost is moved into adapters and a capability model (instead of scattering it across conditionals in every service). It might look like too much in order to support one single provider, but it makes each subsequent one pretty straightforward. In other words, adding provider four gets easier than adding provider two did.

That’s the actual payoff, and it shows up months later, not on day one.