Git, CI/CD & GitOpsX · Merge ConflictsConflicts
IaC conflict examples — Terraform state, Ansible inventory, Kubernetes YAML
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
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 planto determine drift between desired and actual state. - It must correspond exactly to the resources in the cloud;
a corrupted state produces a
terraform planthat 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:
| Domain | Verification command |
|---|---|
| Terraform | terraform plan |
| Ansible | ansible-inventory --graph |
| Kubernetes | kubectl apply --dry-run=server -f <file> |
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:
- State files do not live in Git. Add
*.tfstateand*.tfstate.backupto.gitignore. Store state in S3 or Terraform Cloud with locking. The state file conflict becomes structurally impossible. - 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. - 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.
prettieron 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-runas the post-resolution check.
Quiz
Knowledge check · 4 questions
Q1. Why is a merge conflict on `terraform/state/prod.tfstate` uniquely dangerous compared to conflicts on `.tf` source files?
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.
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.
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.