"Should we be cloud-agnostic so we're not locked in?" is the wrong question, because lock-in is not a yes or no. It is a price per layer, paid at a specific moment. Lock-in on a load balancer costs days. Lock-in on your workflow orchestration language costs quarters. Same word, 50x difference in price.
This article walks through a data and AI platform we built for a client that had to run in four environments the client did not choose: two public clouds, a single on-premises server, and an air-gapped variant. It gives you the layer-by-layer map of where we paid for portability, where we refused, and what each choice cost, plus four tests you can run on any layer of your own architecture before you abstract it. If your own team picks your deployment target and nobody can overrule them, most of what follows is over-engineering for you, and the last section covers the four things still worth doing.
The short answer: Go cloud-agnostic only in layers where a second deployment target is already real and the cloud-native option would leak into application code. Keep orchestration in code, talk to standard interfaces (S3, Postgres, Kubernetes), and let networking, IAM, and ingress stay native.
Lock-in is a price per layer, not a yes or no
Most "avoid lock-in" debates go nowhere because nobody scopes the layer. One engineer is picturing a managed queue, another the IAM model, a third an orchestration language that holds business logic in a cloud console. They are arguing about three different bills.
Replace the yes-or-no question with four that have answers. Who chooses the deployment target: you, or someone who pays you? How many targets will actually exist, not could exist? Which layer are you locking: compute, the data model, orchestration? What does being wrong cost, measured in weeks?
Answer those four and the debate stops being theological. It becomes a budget. And once you price by layer, one principle falls out of the numbers: portability is a property of your seams, not of your code. Same shape, different code.
A signed contract chose our targets, not a strategy
The platform is a data and AI pipeline of roughly 15 services: ingestion, enrichment, embeddings, graph and search, and an API and UI on top. It ships into the environments of the client's customers rather than running only in a cloud the client controls.
Over about two years, contracts dictated four targets. The reference stack runs on managed Kubernetes in one public cloud. A customer's existing cloud required a second managed Kubernetes footprint in another. A customer with no cloud allowance required a single-server on-premises deployment. A fourth variant of the same platform runs fully air-gapped.
Nobody asked "should we support a second cloud?" A signed contract asked. The deployment target became a commercial variable we did not control, and the architecture had to absorb it. We were on the second cloud before we had a portability strategy, which turned out to help: the abstraction was retrofitted with evidence instead of imagined.
Same shape, different code
We did not write code that runs anywhere. We wrote the same system shape in each target, with a small set of well-chosen seams where the cloud plugs in.
The goal was never "one codebase compiles everywhere." The goal was "an engineer can move between targets without relearning the system." Those sound similar and cost very different amounts.
Write-once-run-anywhere means a leaky abstraction over every service the clouds implement differently, and you get to debug the abstraction on top of both clouds. Same shape, different code means the map is identical, the layer names are identical, the workflow is identical, and the implementation underneath is whatever the cloud does natively.
Optimize for portability of the developer's mental model, not for portable code. Everything below follows from that one choice.
Four tests before you abstract a layer
Go agnostic in a layer only when at least two of these are true.
Test 1. Is the second target already real? A contract, a customer, or a regulation, not a roadmap slide. The single most expensive mistake in this space is abstracting for a second cloud that never arrives.
Test 2. Does the cloud-native option leak into application code? Yes for orchestration, where a proprietary state machine holds your business logic. No for Postgres, where the managed service sits behind a wire protocol the application already speaks.
Test 3. Is the interface stable, with more than one implementation? The S3 API, the Postgres wire protocol, the Kafka protocol, and the Kubernetes API all qualify. A vendor's SDK does not.
Test 4. Would porting later cost more than a quarter? It would for orchestration and messaging. It would not for ingress.
One rule sits above the four tests: never build the abstraction before the second real implementation exists. The first implementation always encodes the wrong assumptions, and you will not know which ones until a second target disagrees with them.
Run the four tests on one of your layers
Pick a layer you are thinking about abstracting, such as your queue, your workflow engine, or your ingress, and answer each test honestly.
Test 1. Is the second deployment target already real: a contract, a customer, or a regulation?
Test 2. Does the cloud-native option leak into application code?
Test 3. Is the interface stable, with more than one implementation?
Test 4. Would porting this layer later cost more than a quarter?
Go agnostic only when at least two tests are true.
Answer all four to see what that means for this layer.
The layer map: where we paid, where we refused
Portability was decided one layer at a time. This map is the decision record. Select a layer to see why, or filter by decision.
Where we paid for portability, layer by layer
Everything above the dashed line touches the application. Everything below it stops at the cluster edge.
-
The Kubernetes API is the contract: one real API, three implementations.
-
One Helm chart per service, one values file per target. The chart is the shape; the values are the seam.
-
Kafka, self-hosted. The protocol has multiple implementations.
-
Temporal, with workflows in application code. A proprietary DSL means a rewrite per cloud.
-
External Secrets Operator: same manifest, different backend.
-
The S3 API everywhere. It is the de facto standard and an on-premises implementation exists.
-
Amazon RDS for PostgreSQL on one cloud, Azure Database for PostgreSQL Flexible Server on the other, a container on-premises. The wire protocol is the portability.
- Cluster edge
-
Duplicated on purpose, with zero leakage into the application.
-
The cheapest layer to be native in.
The two extremes tell the story. Postgres is the managed service on both clouds and a container on the single server, and the application never knows, because the protocol already gave us the portability. IAM and networking we wrote twice, natively, and we would do it again.
HatchWorks AI builds data and AI platforms that ship into environments our clients don't control. Our engineers can run your architecture through the four tests before a second deployment target makes the decision for you.
Explore AI-Driven Development →Two Terraform repos beat one leaky module
The classic trap is the cross-cloud module layer: a generic "cluster" module that resolves to Amazon EKS or Azure Kubernetes Service (AKS) depending on a variable. You inherit the union of two clouds' complexity and the intersection of their features, and you get to debug both.
We rejected it and wrote provider-native Terraform, 100% idiomatic aws_* and azurerm_* resources, with an identical structure in every repo. Each repo decomposes into the same ordered layers: foundation (accounts and state), network, storage, Kubernetes cluster, data layer (databases and queues), platform layer (shared add-ons), GitOps (Argo CD or Flux), and application configuration. Same ordering, same layer names, same state and workspace conventions, same CI pipelines.
The bill is roughly 7,100 lines of Terraform in one cloud and 5,800 in the other, and about 6,000 of those lines we maintain twice. What that buys: every layer upgrade is a native upgrade, and any engineer can read either repo because the map is the same. Terraform was chosen as a workflow, not as an abstraction.
One detail: the layer numbers drifted between the repos. The system layer is 50 in one and 70 in the other, and it has never once mattered. The names are the interface, not the numbers.
Kafka and Temporal: the two layers where we paid the most
Messaging and orchestration are where the tests came back yes, and where the price of portability was highest. Each deserves its own bill.
Messaging: we operate Kafka
On a single public cloud, a native managed queue would have been meaningfully cheaper to own. We chose self-hosted Kafka anyway, for three reasons.
Consumer groups, ordering, and replay are core to the ingestion path, not implementation details, so a managed queue meant redesigning rather than porting. The Kafka protocol has wide support, including managed endpoints, so a customer that insists on a managed service still does not touch application code. And one messaging system means one operational model across public cloud, on-premises, and offline.
The price: we operate Kafka. If the platform had one deployment target, choosing Kafka here would have been over-engineering.
Orchestration: a real distributed system to run
The pipelines (ingest, parse, enrich, embed, load) run for hours, partial failure is normal, and automatic recovery is required. The cloud-native answers are AWS Step Functions and Azure Durable Functions, and both fail the leak test as badly as anything can. Orchestration lives outside the application in a cloud specification. Three targets mean three rewrites, and there is no on-premises equivalent, so there is no portable path at all.
A state machine defined as JSON in a cloud console is business logic that does not live in your repo, cannot be unit-tested with your code, and has no offline story.
Temporal puts the workflows in ordinary application code. Portability is free because there is nothing cloud-shaped to port, durability and retries and timers come built in, and the same component deploys identically in every environment.
The price is a real distributed system to run: persistence, a visibility store, four service roles, and a team learning curve that is not small. We accepted it in exchange for complete workflow portability. It was the highest-leverage portability decision on the platform.
The payoff we didn't plan for: portable autoscaling
Autoscaling is usually the sneakiest lock-in, because it chains a cloud metric to a cloud scaler to cloud IAM. Our workers scale on workflow backlog read from Temporal through KEDA (Kubernetes Event-driven Autoscaling), not on a cloud queue depth or CPU. The signal originates inside the application runtime, so the identical KEDA ScaledObject manifest runs unchanged on both the AWS and Azure clusters. Choosing a portable orchestrator removed a dependency chain we never set out to remove.
The Helm chart also refuses to template if both CPU-based Horizontal Pod Autoscaler (HPA) and backlog scaling are enabled, so a conflicting configuration fails before any manifest reaches the API server. That is the general rule for a shared abstraction: fail loudly on invalid combinations, or you have only relocated the bug.
Divergence is fine until it reaches the application
Divergence is fine where it stops at the cluster edge. It is not fine where it reaches the application. Below that line we embraced infrastructure variance completely.
One cloud runs a service mesh with mutual TLS (mTLS) and gateways; the other uses native load balancer controllers, behind the same Helm charts with feature toggles. Node pools, disk classes, and instance families are tuned per cloud with no wrapper over the hardware. Admission and policy tooling differs where a cloud already provides native controls, because duplicating a control the vendor already ships is pure cost.
We could have forced the mesh onto both clouds. It would have cost weeks and bought nothing, because nothing above the ingress cares. The test for every layer is the same: does this reach the application container?
What the second build cost
The first cloud took about three weeks for the core layers (network, Kubernetes, data) and about four months to reach full parity across streaming, workflows, search, cache, GitOps, and application configuration, accumulated as customers needed things. The second cloud took about a week and a half for the core layers and about four weeks for full parity, planned across 20 layers. Application changes required for the second cloud: values files only.
First cloud vs. second cloud
Same platform, same shape. The difference is that the decisions were already made.
About 2x faster for network, Kubernetes, and data.
Bars share one scale, with four months shown as roughly 17 weeks. Application changes for the second cloud: values files only.
The second build was not faster because Terraform is portable. It isn't. It was faster because the decisions were already made: the layer decomposition, the seams, the service graph. We were re-implementing a known shape instead of designing one, and that is the compounding asset.
The third target, a single on-premises server, uses the same images and the same service graph under Compose instead of Kubernetes. The fourth, the air-gapped variant, is possible only because every artifact already came from an internal registry we control. That registry mirror started as supply-chain hygiene. It turned out to be the thing that made offline delivery possible at all.
When multi-cloud is over-engineering
The caveats are the reason to trust the rest. Do not do any of this if you have one deployment target and a roadmap that does not credibly change; hypothetical second clouds never arrive. Do not do it if a managed product is your core differentiator, because then the cloud's ML or data service is the product, and abstracting it away removes what you sell.
Do not do it if your team is too small to run self-hosted infrastructure. Portable Kafka you cannot operate at 3 a.m. is worse lock-in than a managed queue. Do not abstract before a second concrete implementation exists. And do not call it portability when it is a 10-line wrapper around two completely different behaviors.
Our bill, named in full: roughly 6,000 duplicated lines of infrastructure code, self-hosted operations for Kafka and Temporal, and a high Kubernetes floor on even the smallest target. We pay it because target choice belongs to the client's customers. If it belongs to you, don't.
The part most people miss: option value is nearly free
Four practices cost almost nothing on a single cloud and are exactly what turns a future port into a month instead of a year. None of them is multi-cloud. They are option value.
Keep orchestration in application code. Workflows written as ordinary code in your repo, not in a cloud-proprietary DSL, are testable with the rest of the code and leave nothing cloud-shaped to port later.
Talk to standard interfaces. The S3 API, the Postgres wire protocol, the Kubernetes API. Use the managed service behind each of them freely; the protocol is what you keep.
Mirror every artifact. Pin and mirror all container images and dependencies in a registry you control. It is supply-chain hygiene today and an offline deployment story the day a customer asks for one.
Layer your infrastructure code with stable names. Decompose it into cleanly ordered layers whose names are the contract. The numbers can drift. The names cannot.
Do these on one cloud and you have not built a multi-cloud platform. You have kept the price of a second one at about a month.
Portability is a property of your seams: same shape, different code. Price lock-in per layer, because the edge is cheap and application-visible orchestration is expensive. Duplicate deliberately, because native infrastructure code written twice beats one leaky abstraction. Abstract on the second build, never before.
The question to take back to your own platform is not "should we be cloud-agnostic." It is: who chooses your deployment target, and what would it cost you, in weeks, if they changed their mind?
HatchWorks AI builds and runs data and AI platforms that ship into environments our clients do not control, which is how the layer map above got written in the first place. If your platform is about to meet its second target, we can walk through your layers with you and put a price on each one before a contract does.
Price your layers before a contract does.
HatchWorks AI builds and runs data and AI platforms across public clouds, on-premises servers, and air-gapped environments. If your platform is about to meet its second target, our engineers will walk through your architecture layer by layer and show you where portability pays and where it doesn't.
Talk to us about your platform →FAQ
What is cloud-agnostic architecture?
Cloud-agnostic architecture lets a system run on more than one cloud or on-premises environment without rewriting application code. In practice, it rarely means identical code everywhere. It means standard interfaces, such as the Kubernetes API, the S3 API, and the Postgres wire protocol, at the seams where each environment plugs in.
What is the difference between cloud-agnostic and multi-cloud?
Multi-cloud describes where you run: more than one cloud provider. Cloud-agnostic describes how you build: without depending on one provider. You can run multi-cloud with separate, cloud-specific stacks, and you can build cloud-agnostic while running on a single cloud.
How do you avoid cloud vendor lock-in?
Price it per layer instead of avoiding it everywhere. Keep business logic, including workflow orchestration, in application code. Talk to standard interfaces and use managed services behind them freely. Mirror container images in a registry you control. Let networking, IAM, and ingress stay native, because lock-in there is cheap to undo.
Is Terraform cloud-agnostic?
Terraform works across clouds, but the code you write is not portable, because AWS and Azure resources are different resource types. On this platform we wrote separate provider-native repos with identical layer structure, about 6,000 lines maintained twice, instead of one shared module that hid both clouds behind variables.
Is Kubernetes cloud-agnostic?
The Kubernetes API is one of the most portable contracts available, with managed versions on every major cloud and options for on-premises. The clusters underneath still differ in networking, storage classes, IAM, and load balancers. Workloads stay portable only if those details stay out of application manifests.
When is multi-cloud not worth it?
When you choose your own deployment target and your roadmap doesn't credibly change it. Also when a managed cloud product is your differentiator, when your team is too small to run self-hosted infrastructure, or when no second implementation exists yet. In those cases, build cheap portability habits on one cloud instead.


