GitOps, the word after the practice: gitflow, GitLab CI, Terraform and Ansible
The word has been spreading for a year, GitOps, laid down by Weaveworks in 2017 around Kubernetes and a simple idea, git as the source of truth of the infrastructure and a loop that converges reality toward what the repository describes. I read it the way I had read DevOps a few years earlier, not discovering a method but recognizing my own (DevOps, a word for what I was already tinkering with ), back then with git and bash hooks. By 2018 the hack had grown and it took the shape of an adapted gitflow, conventions and a pipeline that does all the work.
The repository as source of truth
The principle has not changed since my hooks of 2010, only the tooling filled out.
All the infrastructure is described in git, the Terraform that creates the machines and the network, the Ansible that configures them, the variables, the inventories and nothing is done by hand on a console anymore, unless you then have to justify why the repository lies.
The repository is not a backup of the truth, it is the truth and production is only its applied reflection.
The practical consequence is that git log becomes the record of who changed what in the infra and that a rollback is a revert followed by a fresh apply, not an archaeology of memory.
The gitflow, adapted: a branch creates its infra
A textbook gitflow, designed for application software, does not land right on infrastructure, so I adapted it rather than endured it. The heart of my adaptation came down to one rule, the branch is the environment. Creating a branch did not prepare a future deployment, it provisioned the matching infrastructure and deleting it tore that infrastructure down. The branch lifecycle governed the infra lifecycle, exactly, with no gap to manage by hand.
In practice, the branch name became the key to everything, a dedicated Terraform workspace on one side, a dynamic GitLab environment on the other, both indexed on $CI_COMMIT_REF_SLUG.
Pushing feature/metrics brought a terraform workspace of the same name into being, with its own state, hence its own machines, its own network, its own DNS records, an isolated replica of prod for the duration of the work.
The main branch equalled production, the working branches equalled as many ephemeral environments and the merge request review was about a real infra, not a promise.
The pipeline, Terraform then Ansible
The core is GitLab CI orchestrating two tools in their respective roles.
Terraform creates, Ansible configures and holding that boundary avoids hacks on both sides, which I detailed elsewhere (Terraform creates, Ansible configures
).
The branch = infra switch reads straight off the pipeline, the plan then the apply on any branch that creates its workspace and the destroy hooked onto the environment closing.
For now GitLab only offers only and except to say when a job runs, which forces a few not very elegant workarounds to make the logic hold and the YAML below shows the skeleton rather than every patch.
stages: [plan, apply, configure]
.workspace: &workspace
before_script:
- terraform init
- terraform workspace select "$CI_COMMIT_REF_SLUG" || terraform workspace new "$CI_COMMIT_REF_SLUG"
terraform-plan:
<<: *workspace
stage: plan
script:
- terraform plan -out=tfplan
artifacts:
paths: [tfplan]
expire_in: 1 week
only: [branches]
terraform-apply:
<<: *workspace
stage: apply
script:
- terraform apply -input=false tfplan
environment:
name: "$CI_COMMIT_REF_SLUG"
on_stop: terraform-destroy
only: [branches]
ansible-configure:
stage: configure
script:
- ansible-playbook -i "inventories/$CI_COMMIT_REF_SLUG" site.yml
only: [branches]
terraform-destroy:
<<: *workspace
stage: apply
script:
- terraform destroy -input=false -auto-approve
- terraform workspace select default
- terraform workspace delete "$CI_COMMIT_REF_SLUG"
environment:
name: "$CI_COMMIT_REF_SLUG"
action: stop
when: manual
only: [branches]
Every push triggers the plan, published as an artifact and that is what you read in the merge request like you read a code diff, because it is one, the diff of the state of the world to come.
The apply follows on the branch’s workspace then the configuration and GitLab records the environment as active.
When the branch merges or disappears, the on_stop triggers the destroy, the environment closes and the ephemeral infra gives its resources back, without a human having to remember to clean up.
The secrets live in the CI’s protected variables, the Terraform state in a remote locked backend, one per workspace and the Ansible inventory is sometimes read from DNS rather than a file (DNS-driven inventory
).
The honest part, what it is not quite
I put my cards on the table, here is what my setup is not. GitOps in the strict sense, the Weaveworks one, rests on a reconciliation loop, an agent that lives in the cluster, continuously watches the gap between the repository and reality and corrects it on its own, all designed for Kubernetes. What I do is push, driven by CI, it is IaC triggered by git, not a controller that reconciles in pull. The principle is the same, git is the source of truth and everything replays from it, but the mechanism differs and the difference is not cosmetic. My pipeline does not see a drift, if someone changes a machine by hand between two applies, nothing catches it before the next run, where a reconciliation loop would correct it by itself. So I call this GitOps in the broad sense, because the spirit is there and I name the bet rather than dress it up.
Where to land
The label matters less than the discipline it covers, infrastructure described in git, applied by a pipeline and nothing by hand. The adapted gitflow and the conventions are not decoration, they are what makes the automaton reliable and the history readable. Terraform creates, Ansible configures, CI chains them, git arbitrates and the day I add a real reconciliation loop, it will be one more step on the same staircase, not a recantation.
[edit: 2019] The workarounds did not last long, since the arrival of rules in GitLab CI, with version 12.3, finally made it possible to write the logic the way it had been designed.
The plan now runs on the merge request, where it gets reviewed and on every branch push, the apply stays automatic for ephemeral environments but becomes manual on the main branch and the destroy can no longer be triggered on production by accident.
This is the version that ran for years without my having to touch it again:
stages: [plan, apply, configure]
.workspace: &workspace
before_script:
- terraform init
- terraform workspace select "$CI_COMMIT_REF_SLUG" || terraform workspace new "$CI_COMMIT_REF_SLUG"
terraform-plan:
<<: *workspace
stage: plan
script:
- terraform plan -out=tfplan
artifacts:
paths: [tfplan]
expire_in: 1 week
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
- if: '$CI_PIPELINE_SOURCE == "push"'
terraform-apply:
<<: *workspace
stage: apply
script:
- terraform apply -input=false tfplan
environment:
name: "$CI_COMMIT_REF_SLUG"
on_stop: terraform-destroy
rules:
- if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_REF_NAME == "master"'
when: manual
- if: '$CI_PIPELINE_SOURCE == "push"'
ansible-configure:
stage: configure
script:
- ansible-playbook -i "inventories/$CI_COMMIT_REF_SLUG" site.yml
rules:
- if: '$CI_PIPELINE_SOURCE == "push"'
terraform-destroy:
<<: *workspace
stage: apply
script:
- terraform destroy -input=false -auto-approve
- terraform workspace select default
- terraform workspace delete "$CI_COMMIT_REF_SLUG"
environment:
name: "$CI_COMMIT_REF_SLUG"
action: stop
rules:
- if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_REF_NAME != "master"'
when: manual
[edit: 2026] GitOps in the strict sense, the reconciliation loop I did not have in 2018, won on Kubernetes, carried by Argo CD and Flux, both CNCF graduated at the end of 2022. Weaveworks, which coined the word, shut down in early 2024 and Flux did not stop for all that, the community having taken the project over, which says rather well that a practice outlives the company that named it. On the IaC side the landscape moved too, Terraform switching to the BSL licence in 2023 and OpenTofu being born from the fork that followed and it is now possible to reconcile Terraform or OpenTofu in pull, with controllers such as tofu-controller in the Flux ecosystem, or to describe cloud infrastructure as Kubernetes resources with Crossplane. GitLab itself ended up relying on Flux for its Kubernetes agent’s GitOps. Outside Kubernetes, however, CI-driven push remains the norm and this post’s setup, branch included, has lost none of its relevance for a VM or bare-metal infra.
[edit: 2026] The Ansible implementation of all this: the gitlab-runner role .