Skip to main content
RunBook Academy

Git, CI/CD & GitOpsXII · Merge vs RebaseIaC

IaC and the merge-rebase question — Terraform state, configuration drift, and forced merges

Advanced⏱ ~22 mingit

What you'll learn

  • Explain why Terraform state file references make merge mandatory on the IaC trunk
  • Identify how a rebase-rewritten OID breaks state file pinning and module version pins
  • Recognise when configuration drift forces a merge verb even when the rebase would otherwise be safe
  • Apply `git merge --no-ff` to IaC trunks to record every applied plan as a merge commit
  • Distinguish between branches where rebase is safe (a feature plan branch) and where merge is forced (the IaC trunk)

Prerequisites

Verified against Git 2.55.x teaching target; 2.40+ minimum · GitHub Actions continuous service; Aug 2026 documentation baseline · Argo CD v3.5.x teaching target; v3.0+ minimum · Flux v2.9.x · Sigstore Cosign v3.1.x · SLSA v1.2 · OCI Distribution Specification v1.1 · Git LFS v3.7.1 · Kubernetes (cross-course target) 1.36.x

Not yet marked complete on this device.

Infrastructure-as-code repositories have a structural property that application repositories do not: the Git history is referenced by systems outside Git. Terraform state files reference the commit OIDs they were applied from; module registries pin to specific commit OIDs; container images are built from specific commits; GitOps controllers track specific commits. Every one of these references is an OID that a rebase would rewrite. For IaC repositories, the merge verb is not a policy preference on protected branches — it is structurally forced by the downstream references. Rebase is reserved for branches whose OIDs are not consumed by anything outside the local clone.

Why Terraform state forces the merge verb

A Terraform state file records the exact state of the infrastructure at the moment of the last successful apply. The state file includes, either explicitly or implicitly, the commit OID from which the apply was performed. When Terraform performs a subsequent plan, it compares the desired state (from the current commit) against the recorded state (from the last applied commit); if the OIDs differ, Terraform can detect that the state has been re-anchored.

# terraform.tfstate (excerpt)
{
  "version": 4,
  "terraform_version": "1.6.0",
  "serial": 47,
  "lineage": "a1b2c3d4-...",
  "outputs": {},
  "resources": [...]
}

The lineage field and the absence of a last_modified_commit field (in older Terraform versions) mean that Terraform does not directly track the Git commit OID in the state file. However, the artifact registry and the module registry do: a Terraform module version (v1.2.3) is pinned to a specific commit OID in the module’s Git repository; a Terraform plan artifact (plan.json) is uploaded with a commit OID reference; the CI pipeline’s audit log records which commit produced which plan.

# Module reference in main.tf
module "vpc" {
  source = "git::ssh://git@github.com/acme/terraform-modules.git//modules/vpc?ref=v1.2.3"
}

# The module registry resolves ref=v1.2.3 to a specific commit OID:
# v1.2.3 -> a1b2c3d4e5f6...

The ref=v1.2.3 is a Git tag; the tag points to a specific commit OID. If that OID is rewritten by a rebase, the tag points to a different (or unreachable) commit. Consumers of module.vpc get either an error (if the new OID does not exist) or a different module version than the one the tag recorded (if the tag was force-pushed to the new OID). The state file in the downstream repository is now pinned to an OID that does not match what the module’s tag claims.

flowchart LR
    subgraph MODULE_REPO["module repository (rebase rewrites OIDs)"]
        MT["v1.2.3 tag"] --> MA["a1b2c3d4 (original)"]
    end
    subgraph DOWNSTREAM["downstream repository"]
        DT["main.tf ref=v1.2.3"]
        DS["state file pinned to MA"]
    end
    MT -.force-pushed to.-> MB["new OID (rewritten)"]
    DT -.fails to resolve.-> MB
    DS -.pinned to unreachable OID.-> MA

The diagram shows the breakage chain: the module repository rebases, the v1.2.3 tag is force-pushed to the new OID, the downstream repository’s main.tf reference resolves to the new OID (a different module than the one the state file was built against), and the state file’s implicit pin to the original OID is now unreachable.

Configuration drift and forced merges

A second structural reason to force the merge verb: configuration drift detection. GitOps controllers (Argo CD, Flux) compare the desired state (in Git) against the actual state (in the cluster). The comparison is anchored to a specific commit OID — the commit the controller last successfully synced. If that OID is rewritten by a rebase, the controller’s view of the desired state changes (the new OID has different content), and the comparison produces drift that does not correspond to any intentional change.

# Argo CD Application manifest
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: production
spec:
  source:
    repoURL: git@github.com:acme/k8s-manifests.git
    targetRevision: main
    # The controller tracks the commit OID of the last successful sync
  destination:
    server: https://kubernetes.default.svc

When the controller syncs, it records the commit OID it synced from. The next sync compares the cluster state against the new commit OID’s content. If the commit OID is the same, the controller reports “Synced”. If the commit OID has changed (because of a rebase), the controller reports “OutOfSync” and attempts to reconcile. The reconciliation applies the new content; if the new content is what the rebase produced, the reconciliation is correct; if the rebase was a mistake, the reconciliation applies the wrong content.

flowchart LR
    subgraph GIT_PRE["before rebase"]
        GA["commit A (controller synced from)"]
    end
    subgraph GIT_POST["after rebase + force-push"]
        GB["commit B (rewritten, new tip)"]
    end
    subgraph CONTROLLER["GitOps controller"]
        CS["last sync: A"]
        CN["next sync: B"]
    end
    GA -.force-pushed to.-> GB
    CS -.compares against.-> CN
    CN -.reports drift.-> CN

The controller’s drift report is correct in the sense that the commit OID has changed; the drift is wrong in the sense that the change was not an intentional change to the desired state. The operator must investigate every drift report to determine whether the change was intended (a real commit) or a rebase artefact (an OID rewrite). The cost is paid in operator attention, not in technical breakage.

When rebase is still safe in IaC repositories

The forced-merge policy applies to protected branches — the IaC trunk (main, production), module trunks, and any branch whose OIDs are consumed downstream. Rebase is still safe on:

  1. Local plan branches. A branch that exists only on the engineer’s laptop, used to develop and test a Terraform plan, never pushed or consumed. The rebase is contained.
  2. Feature plan branches before review. A branch that has been pushed for review but no reviewer has fetched, no CI has built, no artifact has been pinned. The rebase window is open.
  3. Pre-merge replay. The rebase happens at the moment of merge, with no downstream consumer holding the OIDs in between. The merge that follows is a fast-forward.
# Safe: rebase a local plan branch onto main
git checkout plan/vpc-peering
git fetch origin
git rebase origin/main
# Successfully rebased and updated refs/heads/plan/vpc-peering.

# Forced merge: into the IaC trunk
git checkout main
git merge --no-ff plan/vpc-peering
# Merge made by the 'recursive' strategy.
# (merge commit records the plan that was applied)

The boundary is the same as the general rule from XII-02: rebase is safe when no downstream consumer holds the OIDs. For IaC, the downstream consumers are particularly numerous and particularly sticky: state files, module registries, artifact registries, GitOps controllers, signed tags. The rebase window is therefore narrow — narrow enough that most IaC teams adopt a merge-only policy on protected branches and reserve rebase for the local plan branch.

Special case: state file pinning

Some teams pin Terraform state files to specific commit OIDs explicitly, as an audit-trail measure. The state file is uploaded to remote storage with a metadata record naming the commit OID it was applied from; the CI pipeline’s apply job records the OID in a separate audit log. This pinning is what makes the state file auditable: given a state file, the auditor can answer “which commit produced this state?”.

# CI pipeline records the commit OID alongside the state upload
terraform apply -auto-approve
COMMIT_OID=$(git rev-parse HEAD)
aws s3 cp terraform.tfstate s3://acme-tfstate/production/
echo "$COMMIT_OID" > terraform.tfstate.commit
aws s3 cp terraform.tfstate.commit s3://acme-tfstate/production/

If the commit OID the state file references is rewritten by a rebase, the audit trail breaks. The auditor retrieves the state file, sees the commit OID, looks it up in the Git history, and finds nothing (the OID is unreachable from any branch). The audit answer is “we don’t know which commit produced this state” — which is the worst possible answer for a regulated environment.

flowchart LR
    subgraph STATE_PIPELINE["apply pipeline records OID"]
        SP1["git rev-parse HEAD"] --> SP2["terraform apply"]
        SP2 --> SP3["upload state + OID"]
    end
    subgraph AUDIT["audit query 6 months later"]
        AQ1["download state + OID"]
        AQ2["git log OID"]
    end
    SP3 -.rebase rewrites OID.-> AQ1
    AQ1 -.OID unreachable.-> AQ2

The structural conclusion: any commit OID that may end up in a state file, an audit log, a module tag, or a GitOps controller’s sync record must be treated as immutable. The merge verb is the only way to preserve those OIDs.

Production discipline

  1. Use git merge --no-ff on IaC trunks. Every applied plan must be recorded as a merge commit in the trunk’s DAG, so the audit trail can trace the production state back to the PR.
  2. Reserve rebase for local plan branches. The rebase window in IaC is narrow: local branches that have never been pushed or consumed.
  3. Never rebase a tagged commit in a module repository. The tag is a contract with downstream consumers; the OID is immutable from the moment the tag is cut.
  4. Configure merge.ff = false on IaC protected branches. The setting enforces the no-fast-forward behaviour at the tooling level.
  5. Document the IaC-specific policy in the repository’s README. The general policy from XII-04 applies, but IaC repositories have additional constraints (state files, module tags) that must be documented explicitly.

Cross-course references

  • Terraform for Production Sysadmins - Part IX (StateMgmt) covers Terraform state pinning and the audit trail; the merge verb is forced by the state file’s implicit OID reference.
  • GitOps with Argo CD - Part VI (MergeStrategies) maps the IaC merge policy onto GitOps: the GitOps controller reads the IaC trunk’s merge commits to detect applied plans, and a rebase-rewritten trunk produces drift reports that do not correspond to real changes.
  • Ansible for Production Sysadmins - Part XXXVII (RepoArch) covers Ansible repository architecture; the same forced-merge policy applies because Ansible playbooks in production are referenced by their commit OIDs in the run logs and the audit trail.

Quiz

Knowledge check · 4 questions

  1. Q1. A Terraform module repository has just rebased and force-pushed a commit that the `v1.2.3` tag points at. What is the immediate consequence for downstream repositories that consume `module.x { source = "...//modules/x?ref=v1.2.3" }`?

  2. Q2. Rebasing a tagged commit in a Terraform module repository is acceptable if the tag is also force-pushed to the new OID, because consumers that resolve by tag will get the new content.

  3. Q3. Name the three IaC-specific downstream consumers that force the merge verb on IaC trunks, and explain why each one makes rebase unsafe.

  4. Q4. Diagnose the breakage chain from a module repository rebase and recommend the recovery procedure for downstream consumers.

    A platform team maintains a Terraform module repository at `git@github.com:acme/terraform-modules.git`. An engineer rebased the `main` branch last week to clean up the history before a major release. The `v1.2.3` tag was force-pushed to the new tip OID. Five downstream repositories consume the module via `source = "git::ssh://git@github.com:acme/terraform-modules.git//modules/vpc?ref=v1.2.3"`. This morning, all five downstream repositories' nightly `terraform plan` jobs reported drift on the VPC module that does not correspond to any change in the downstream's own code.

Passing score: 75%. Answers are checked in this browser.