Skip to main content
RunBook Academy

Git, CI/CD & GitOpsX · Merge ConflictsConflicts

IaC conflict examples — Terraform state, Ansible inventory, Kubernetes YAML

Intermediate⏱ ~24 min🧪 Lab requiredgitterraform

What you'll learn

  • Recognise the three failure modes for Terraform state file merges (binary conflict, partial state, drift between state and code)
  • Identify the recurring host-list and host-group conflicts in Ansible inventory
  • Diagnose the silent-shape-change in Kubernetes YAML conflicts where the merge is clean but the result is invalid
  • Apply the rule that Terraform state files should never be merged, only regenerated
  • Use `terraform plan` and `ansible-inventory --graph` to verify post-resolution validity

Prerequisites

Practice

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 repositories do not just inherit every conflict an application repository has; they add new ones that have no analogue in code. A Terraform state file is not text-mergeable, but Git will still try. An Ansible inventory is a list of hosts, so any concurrent edit produces a case-1 or case-2 conflict regardless of what changed. A Kubernetes manifest is structured YAML, and a clean merge can produce a manifest that no API server will accept. Each domain has its own verification step that catches what Git’s textual merge cannot.

Terraform state file conflicts

The .tfstate file is a record of every resource Terraform manages — IDs, attribute values, dependencies, metadata. It is the single most dangerous file in the repository to merge, because:

  • It is binary in Git’s eyes (the JSON contains hashes and metadata that Git’s text heuristics flag as binary).
  • It changes on every terraform apply.
  • It is consumed by terraform plan to determine drift between desired and actual state.
  • It must correspond exactly to the resources in the cloud; a corrupted state produces a terraform plan that proposes destroying live resources.
git status
# Unmerged paths:
#         both modified:   terraform/state/prod.tfstate
#         both modified:   terraform/iam/main.tf

If state must live in the repo (a deprecated practice, but some teams still have it), the resolution sequence is:

# 1. Abort the merge and refuse to combine binary state files
git merge --abort

# 2. Resolve only the textual conflicts (e.g. main.tf)
# 3. Apply one side's code against one side's state
# 4. Reconcile the other side's code changes against the
#    resulting state with `terraform state mv`,
#    `terraform state rm`, and `terraform import`

The conflict count shown above is one conflict too many. The state file should not be in the conflict at all.

flowchart TB
    A["Branch A applies\nstate A"] --> C["State diverges"]
    B["Branch B applies\nstate B"] --> C
    C --> D["Merge sees two states"]
    D -->|"manual resolution"| E["Pick one state"]
    E -->|"reconcile other side"| F["terraform state mv / rm / import"]
    F --> G["Unified state\nmatches code"]

The diagram shows why this is hard: a state conflict means both sides’ terraform apply ran successfully, producing two divergent recorded states that no algorithm can merge into a single correct one. The only resolution is a deliberate, guided reconciliation with cloud resources.

Ansible inventory conflicts

The Ansible inventory is the file that says which hosts are in which groups, with which variables. It is YAML in modern practice, but the shape is a list of hosts with attributes, which makes most edits case-1 conflicts and most additions or removals case-2:

# Before the merge: 10 hosts in the "webservers" group
webservers:
  hosts:
    web01.prod.example.com:
      ansible_host: 10.0.1.10
    web02.prod.example.com:
      ansible_host: 10.0.1.11

# Branch A added a new host at the bottom
webservers:
  hosts:
    web01.prod.example.com:
      ansible_host: 10.0.1.10
    web02.prod.example.com:
      ansible_host: 10.0.1.11
    web03.prod.example.com:    # <-- new from branch A
      ansible_host: 10.0.1.12

# Branch B added a new host at the top, sorted alphabetically
webservers:
  hosts:
    web-beta.prod.example.com:    # <-- new from branch B
      ansible_host: 10.0.1.50
    web01.prod.example.com:
      ansible_host: 10.0.1.10
    web02.prod.example.com:
      ansible_host: 10.0.1.11

Two branches, two new hosts, two different ordering conventions. The merge is a case-1 textual conflict on the webservers.hosts block because both sides added to the same list. The resolution requires combining both new hosts, and deciding on the ordering. The decision is a team convention (alphabetical, by IP, by role), not a Git-level question.

The case-2 flavour is rarer but more dangerous: one branch removes a host (decommissioned in production) and another branch adds a variable to that host. Resolving requires confirming the decommission status with the operations team before git rming the host entry.

Kubernetes manifest conflicts

A Kubernetes manifest is structured YAML. Concurrent edits to the same deployment produce conflicts that look like ordinary textual conflicts but have a hidden shape:

# Before the merge
spec:
  replicas: 3
  template:
    spec:
      containers:
      - name: api
        image: registry/api:1.4.2
        resources:
          limits:
            memory: 512Mi

# Branch A increased replicas
spec:
  replicas: 5
  template:
    spec:
      containers:
      - name: api
        image: registry/api:1.4.2

# Branch B updated the image
spec:
  replicas: 3
  template:
    spec:
      containers:
      - name: api
        image: registry/api:1.5.0

The two changes are non-overlapping in the textual sense (replicas vs image), so the merge may succeed automatically. But the result has two different states interleaved at different lines — replicas: 5 (from A) and image: registry/api:1.5.0 (from B), which is in fact the desired combined state. This is a clean merge, no markers needed.

The failure mode is when both branches modify the same field to different values. Then the textual merge produces a marker block, and the resolution requires understanding which image and which replica count to ship.

sequenceDiagram
    participant A as Branch A
    participant K as kubectl
    participant B as Branch B
    A->>K: apply with replicas: 5, image: api:1.4.2
    B->>K: apply with replicas: 3, image: api:1.5.0
    Note over A,B: Two deploys, two desired states
    A->>A: merge
    B->>A: feature merges in
    A->>K: apply merged result
    Note over K: Merge succeeded but the deployment\nmight not match either intent

The diagram shows why K8s YAML merges are subtle: the deployment’s kubectl apply is the only step that validates the combined manifest against the cluster’s actual state. A merge that produces syntactically valid YAML but semantically confusing fields will deploy something the team did not intend.

General verification pattern

For every IaC domain, the verification tool is the same kind of thing: a domain-specific validator that reads the resolved file as the project intends to use it. The three covered here:

DomainVerification command
Terraformterraform plan
Ansibleansible-inventory --graph
Kuberneteskubectl apply --dry-run=server -f &lt;file&gt;

The pattern is the same as the lesson’s overall rule: do not trust a clean merge to be semantically correct. Run the project’s own validator after every resolution.

Production discipline

Three rules for IaC conflicts in a production-grade workflow:

  1. State files do not live in Git. Add *.tfstate and *.tfstate.backup to .gitignore. Store state in S3 or Terraform Cloud with locking. The state file conflict becomes structurally impossible.
  2. Run the domain validator after every conflict resolution. terraform plan, ansible-inventory --graph, kubectl apply --dry-run. A clean Git merge is necessary but not sufficient.
  3. Conventions beat conflict resolution. An inventory file with an alphabetical ordering convention produces fewer case-1 conflicts because additions are appendable rather than insertion-at-arbitrary-position. Encode the convention in a sort hook (e.g. prettier on YAML) so the convention is enforced, not merely documented.

Cross-course references

  • Terraform for Production Sysadmins — Parts IX-XII (State) cover Terraform state management; the rule that state must not be in Git is the most important corollary.
  • Ansible for Production Sysadmins — Part XXXVII (RepoArch) covers inventory file conventions to minimise case-1 conflicts.
  • Kubernetes for Production Sysadmins — Part V (Manifests) covers GitOps manifest validation with kubectl --dry-run as the post-resolution check.

Quiz

Knowledge check · 4 questions

  1. Q1. Why is a merge conflict on `terraform/state/prod.tfstate` uniquely dangerous compared to conflicts on `.tf` source files?

  2. Q2. An Ansible inventory YAML conflict that adds a new host at one location and a different host at another location in the same `hosts:` block typically produces a case-1 textual conflict on the hosts block, and the resolution includes both new hosts plus whatever ordering convention the team has adopted.

  3. Q3. Name the file types that should not be merged by Git (state files, encrypted blobs, vendor binaries) and the primary reason each is uniquely dangerous.

  4. Q4. Diagnose a corrupted-state incident and recommend the immediate mitigation plus the long-term fix.

    A team has historically committed `terraform/state/prod.tfstate` to the repository. Two engineers in parallel apply changes to production resources: engineer A applies a new S3 bucket policy, engineer B applies a security group rule change. Both engineers commit their `.tf` files and their refreshed state files. When the second engineer's PR merges into main, `terraform/state/prod.tfstate` is in conflict. The merge is resolved with `git checkout --theirs`. Next `terraform plan` from main proposes destroying the S3 bucket because the merged state does not contain the new policy resource.

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