DEV Community

Avneet bansal
Avneet bansal

Posted on

The GitOps Bridge: Keep Terraform and ArgoCD Out of Each Other's Way


Two tools, two clocks. Terraform provisions the cluster, the node groups, the networking, the IAM. It changes rarely and under heavy approval. ArgoCD deploys the applications onto that cluster. It changes many times a day under light approval. The trouble starts when you let them reach into each other, because then a routine app deploy can be blocked behind an infrastructure change, and an infrastructure change can take applications down with it. The fix is not a better tool. It is a thin contract between the two, so each stays in its own lane. I used this to compress a three-environment EKS migration from a planned six months to about three. Here is the contract, and what it actually bought.

TL;DR

  • Infrastructure (Terraform) and applications (ArgoCD/Flux) have different change frequencies, approval flows, and blast radii. Coupling them is the root problem.
  • The GitOps Bridge is a narrow handoff: Terraform writes cluster facts into a well-defined interface (ConfigMap/Secret labels and annotations), and GitOps controllers read that interface to deploy.
  • Neither side imports the other's state. The interface is the only shared surface.
  • On a real migration this let three environments come up reproducibly, with the customer executing the IaC themselves under a zero-access constraint, and cut the timeline roughly in half.

The coupling you do not notice until it hurts

The common anti-pattern is to let Terraform deploy applications (via the Helm or kubectl provider) or to let application manifests carry infrastructure facts hard-coded inside them. Both feel convenient. Both create coupling that only shows its cost later.

When Terraform deploys apps, every application change rides the Terraform lifecycle: a terraform apply that should only touch a node group now also reconciles your workloads, and a drift in one app can fail a plan that was supposed to be about infrastructure. When app manifests hard-code infrastructure facts (the cluster's OIDC issuer, a role ARN, a VPC ID, the region), those manifests are no longer portable across environments, and every environment promotion becomes a hand-edit. The blast radius of each change grows to cover both layers, and the audit trail blurs, because you can no longer tell from the change history whether a given commit was an infrastructure decision or an application one.

The two layers genuinely have different natures. Infrastructure changes are infrequent, high-risk, and want slow, careful approval. Application changes are frequent, lower-risk, and want to flow. Forcing them through one pipeline makes the fast side as slow as the slow side, and the slow side as risky as the fast side.

The contract

The bridge is deliberately small. Terraform's job ends at provisioning the cluster and then publishing a set of facts about it into a known location. In practice that location is a ConfigMap and a Secret in a known namespace, carrying labels and annotations that describe the cluster: its name, region, the OIDC provider URL, role ARNs for workloads, networking identifiers, and any environment-specific values the apps will need. Terraform owns writing these. It never installs an application.

ArgoCD's job begins by reading that interface. A root application points at the known ConfigMap/Secret, pulls the cluster facts, and uses them to parameterize everything it deploys. The app manifests themselves carry no hard-coded infrastructure values. They carry references into the bridge. ArgoCD owns reading these. It never provisions infrastructure.

The important property is what is absent. Terraform does not read ArgoCD's state, and ArgoCD does not read Terraform's state. There is no shared state file, no circular dependency where each waits on the other. The only thing they share is the interface, and the interface is data, not control. Terraform writes facts; ArgoCD reads facts. That one-directional, data-only handoff is the whole design, and it is what lets each side keep its own clock.

Why this made a hard migration tractable

The migration that proved it out had a constraint that is more common than people admit: I could not directly touch the target environments. The work had to be delivered as infrastructure-as-code that the customer ran themselves, across three environments, an internal lab, a sandbox, and production, with no interactive access to any of them.

That constraint is exactly where the bridge pays off. Because the apps consume cluster facts through the interface rather than embedding them, the same application definitions deploy unchanged to all three environments. Each environment's Terraform writes its own values into its own bridge, and the GitOps layer adapts automatically. There was no per-environment hand-editing of manifests, which is where this kind of migration usually loses weeks and acquires drift. The customer could run the IaC in each environment and get a reproducible result, because the only thing that varied between environments lived in the bridge, generated by that environment's own provisioning.

For one customer, the combination (Terraform plus the bridge plus GitOps, with Karpenter handling node provisioning on the cluster side) took a migration planned for about six months down to roughly three. The halving did not come from any single tool being fast. It came from removing the coupling that would otherwise have forced serial, hand-held, per-environment work. When each environment comes up the same way from the same definitions, you stop paying the integration tax three times.

How to adopt it

Start by drawing the line. List every value your application manifests currently hard-code that is really an infrastructure fact: cluster name, region, OIDC issuer, IAM role ARNs, subnet or security-group IDs, DNS zone names. Those are the contents of your bridge.

Have Terraform write them. After it provisions the cluster, have it create a ConfigMap (for non-secret facts) and a Secret (for anything sensitive) in a known namespace, populated from its outputs. Decide the key names once and treat them as the contract; renaming a bridge key is now a breaking change, which is the right level of ceremony for it.

Have GitOps read them. Point a root ArgoCD (or Flux) application at that namespace, and parameterize your app set from the bridge values rather than from literals. Each environment gets its own bridge from its own Terraform, and the same app definitions follow.

A minimal, generic version of both sides looks like this. First, Terraform publishes the facts after it provisions the cluster (the values are illustrative placeholders, not from any real environment):

# Terraform: write the bridge after the cluster exists.
# The labels are what ArgoCD's ApplicationSet selects on; the data is the facts.
resource "kubernetes_config_map" "cluster_bridge" {
  metadata {
    name      = "cluster-bridge"
    namespace = "argocd"
    labels = {
      "bridge.example.com/environment" = var.environment   # dev | staging | prod
    }
  }

  data = {
    cluster_name   = module.eks.cluster_name
    region         = var.region
    oidc_issuer    = module.eks.oidc_provider          # for IRSA
    account_id     = data.aws_caller_identity.current.account_id
    # a workload's IAM role ARN, resolved per environment:
    app_irsa_role  = module.app_irsa.iam_role_arn
  }
}
Enter fullscreen mode Exit fullscreen mode

Then an ArgoCD ApplicationSet reads that ConfigMap and templates every app from it, so the app definitions themselves carry references, never literals:

apiVersion: argoproj.io/v1alpha1
kind: ApplicationSet
metadata:
  name: platform-addons
  namespace: argocd
spec:
  generators:
    - clusters:                      # or a configMap/plugin generator reading the bridge
        selector:
          matchLabels:
            bridge.example.com/environment: prod
  template:
    metadata:
      name: '{{name}}-addons'
    spec:
      project: default
      source:
        repoURL: https://github.com/your-org/platform-addons
        targetRevision: main
        path: addons
        helm:
          # bridge facts flow in as values; no hard-coded ARNs or issuers in the chart
          parameters:
            - name: region
              value: '{{metadata.annotations.region}}'
            - name: oidcIssuer
              value: '{{metadata.annotations.oidc_issuer}}'
            - name: appIrsaRole
              value: '{{metadata.annotations.app_irsa_role}}'
      destination:
        server: '{{server}}'
        namespace: kube-system
Enter fullscreen mode Exit fullscreen mode

The exact generator you use (cluster, configMap, or a plugin generator) depends on your setup; the invariant is the one that matters: Terraform owns the left side and writes facts, ArgoCD owns the right side and reads them, and the app charts reference bridge values instead of embedding them. Swap prod for dev or staging and nothing in the app definitions changes, only the bridge each environment's Terraform wrote.

Hold the boundary. The one discipline that keeps this working is refusing to cross the line later under deadline pressure: no Helm-provider app installs sneaking into Terraform, no infrastructure ARNs sneaking back into manifests. The value is entirely in the separation, so the separation is the thing to protect.

The reward is that your fast layer stays fast and your slow layer stays safe. An application deploy no longer waits on an infrastructure plan. An infrastructure change no longer reconciles your workloads as a side effect. And a new environment is a new bridge, not a new round of manual edits. Keep the two clocks separate, and let a thin, one-way, data-only contract be the only thing between them.


If you run Terraform and a GitOps controller together, it is worth asking which infrastructure facts your app manifests still hard-code. Those are the ones the bridge is for.

Top comments (0)