Skip to content

Add a Cloud Provider

Caracal’s deployment logic contains no cloud provider. Each deployment model has a provider-neutral core that owns the whole deployment shape, and a small adapter per provider that translates generic concepts into that provider’s resources. Supporting a new cloud, or an internal platform, means writing adapters. It never means changing the core.

The core decides everything that is a property of Caracal rather than of a cloud: which images run, which start command selects a service role, which ports they listen on, which credentials each service needs, how readiness is probed, how far each service scales, and that schema migrations complete before any service rolls.

An adapter never restates these. If you find yourself encoding a port, an image, or a service’s environment in an adapter, that fact belongs in the core.

ModelCoreAdapter
Virtual machineinfra/tofu/modules/caracalHost renders cloud-initinfra/tofu/providers/<provider>/host creates the instance, exposure, identity, and DNS
Managed container platforminfra/containerPlatform/topology.yaml and render.mjsinfra/containerPlatform/targets/<provider>.mjs renders platform manifests
Kubernetesinfra/helm/caracalA values overlay in infra/helm/caracal/examples

Implement one OpenTofu module that satisfies the host contract. The contract is the same on every cloud, so a caller swaps providers by changing a module source and nothing else.

Inputs: name, region, machineSize, diskGb, userData, adminUsername, adminPublicKey, ingressCidrs, adminCidrs, networkCidr, dnsZone, hostnames, dnsTtl, tags. Outputs: publicIp, hostId, identityId, hostnames.

Group any input only your provider needs after the shared contract, under a comment naming the provider. Create the instance, its network exposure, a cloud identity, and the DNS records; nothing else. Open inbound 80 alongside 443, because certificate issuance answers its challenge over plain HTTP before a certificate exists.

module "bootstrap" {
source = "../../modules/caracalHost"
caracalVersion = "v0.2.1"
tlsProxy = {
email = "ops@example.com"
routes = { "console.example.com" = "web" }
}
}
module "host" {
source = "../../providers/myCloud/host"
name = "caracal-prod"
region = "region-1"
userData = module.bootstrap.userData
adminPublicKey = file("~/.ssh/id_ed25519.pub")
hostnames = ["console.example.com"]
}

bash infra/tofu/scripts/validate.sh validates every adapter against its real provider schema and fails if any contract input or output is missing.

Implement one module exporting three members, then register it in render.mjs.

MemberPurpose
secretDeliveryfile when the platform can project a secret onto a filesystem, env when it can only bind a variable
internalUrl(service, config)The address other services reach this one on inside the deployment. Receives { name, port }
render(plan, config)Returns a map of file name to file body
experimentalSet true until the adapter has run against a live account

secretDelivery is the whole of the secrets abstraction. Every Caracal service accepts a credential either as a variable or through its _FILE form; the core reads your declaration and binds whichever the platform supports. Declare file and the core sets DATABASE_URL_FILE and hands you the list of files to project. Declare env and it sets DATABASE_URL and hands you the secret name to reference. Your adapter never chooses a variable name.

Name rendered files so they sort in apply order, with migration jobs ahead of services: 10-job-migrate, 20-app-sts, and so on. Ship an apply flow that runs the jobs to completion before rolling any service, because no platform sequences that for you.

Add an example deployment config under examples/, then run bash infra/containerPlatform/scripts/validate.sh. It renders every adapter through the same assertions: stock images selected by command, credentials resolved from a secret manager and never materialised, internal services not publicly exposed, and jobs sorted ahead of services.

The chart is already provider-neutral. A cloud is four values:

ValueConcept
replayPersistence.storageClassNameDurable per-replica storage
ingress.*.className and annotationsIngress and certificates
serviceAccount.annotationsKeyless cloud identity
global.podLabelsAny label the provider’s identity webhook requires

Copy an existing overlay from infra/helm/caracal/examples, substitute those values and the matching External Secrets store, and add it to the loop in the chart’s validation script. A cloud that needs a chart change is a bug in the chart, not in the overlay.

Mark a new adapter experimental until it has been exercised against a live account. For a container platform adapter that is experimental: true, which makes the renderer warn on every run; elsewhere it is a note at the top of the file and a row in Cloud Support Matrix. Rendering cleanly proves a manifest is well formed. It does not prove a deployment works.

Read Cloud Support Matrix for what each provider currently implements.