Ansible in CI/CD and GitOps
At scale, an Ansible run does not start from a human typing ansible-playbook. It starts from a pipeline or a controller reacting to a git merge, with the repo as the source of truth and an approval gate before anything touches prod. The person decides what to merge; the merge decides what runs.
The shift is from ad-hoc runs — a person decides to run, from somewhere, with whatever is on their machine — to governed automation, where a merge or a button triggers a known playbook, in a pinned execution environment, with RBAC and an audit trail. Larkspur's deploys flow from the larkspur-io/infra repo through CI and AAP, and never from anyone's laptop. This topic wires together everything the chapter built: EEs, the controller, and git.
Pipeline-Driven Runs
CI executes Ansible on every relevant change. On each pull request it runs yamllint and ansible-lint for static checks, molecule for role tests, and ansible-playbook --check against staging to catch drift before merge — all inside the EE, so CI's toolchain matches production's exactly. A problem that would surface in production surfaces at review time instead, in the same image the controller will run.
jobs: verify: runs-on: ubuntu-latest container: registry.larkspur.io/larkspur-ee:2026.04 # same EE as prod steps: - uses: actions/checkout@v4 - run: ansible-lint - run: molecule test - run: ansible-playbook -i inventory/staging site.yml --check --diff
Running CI inside larkspur-ee:2026.04 is the load-bearing detail. The lint, the role tests, and the check run pass or fail against the same collections and ansible-core the controller uses for the real converge, so a green pipeline is evidence about production rather than about the runner's default image.
Controller-Triggered Runs
For the actual converge, CI does not run the privileged playbook itself. It calls the AAP API to launch the "Deploy Larkspur" job template, so the run with prod credentials happens in the controller — with its encrypted credentials, its RBAC, and its audit log — not in the CI runner with secrets exported as environment variables. The pipeline's job is to decide that a converge should happen and ask the controller to do it; the controller's job is to do it under governance. That split is the whole point.
Git as the Source of Truth
The desired configuration lives in larkspur-io/infra, and a merge to main is the signal to converge. AAP's project syncs from git on each run, so the playbook that executes is exactly what was reviewed and merged — there is no out-of-band edit on the controller, no "someone tweaked the template's playbook last week." This is the GitOps discipline applied to servers: the repo is the declared state, a merge is the only way to change it, and the controller reconciles the fleet to whatever the repo says.
Webhook and Schedule Triggers
A controller job template fires from a git webhook on merge, from a schedule for recurring compliance converges, or directly from the API. Whichever it is, the trigger is recorded — every run traces back to a merge, a schedule, or a named person who clicked launch. There is no category of run that just happened with no origin, which is what makes the audit trail from the previous topic actually complete rather than full of holes labeled "manual."
Approval Gates
The prod converge sits behind a manual approval — a workflow gate in AAP, or an environment protection rule in CI — so a merge prepares the change and a named human authorizes the production blast. This is the line between "automated" and "automated and governed." Everything upstream can be fully automatic: lint, test, check, the staging converge, the controller call. The prod step still waits for a person to say go, because deciding that a reviewed change is ready for production is a judgment, not a step you let a webhook make unattended.
- Exporting prod SSH keys and vault passwords as CI environment variables to run
ansible-playbookstraight from the pipeline — the secrets now live in CI's environment and logs; launch the controller's job template and let it inject credentials instead. - Running the converge directly in the CI runner with whatever Ansible the runner image has, skipping the pinned EE, so the pipeline's behavior quietly diverges from the controller's.
- Letting anyone re-run a prod deploy with no approval gate, so a merge is indistinguishable from a production change — the gate is exactly what separates preparing a change from authorizing it.
- Allowing the controller's project to run uncommitted or hand-edited playbooks instead of syncing from git on every run, which breaks the git-is-truth guarantee the whole pipeline rests on.
- Treating
--checkin CI as a full test of a prod deploy — check mode catches a lot, but a custom module that mishandles check_mode or an order-dependent rolling deploy can pass check and still fail live.
- Run lint,
molecule, and--checkin CI on every PR inside the deploy EE, so problems surface at review time and CI's toolchain matches production's. - Trigger the privileged converge by launching a controller job template from the pipeline, never by running
ansible-playbookwith prod secrets in the CI environment. - Make a merge to
mainthe only path to a configuration change, and have the controller sync the playbook from git each run, so what runs is exactly what was reviewed. - Put a manual approval gate in front of the prod converge, so a named person authorizes the production blast even when everything upstream is automated.
- Ensure every trigger — webhook, schedule, or API call — is recorded, so the audit trail has no runs of unknown origin to explain after an incident.
Knowledge Check
Why does the privileged converge belong in the controller rather than the CI runner?
- The controller injects encrypted credentials and records the run under RBAC and audit, instead of secrets living in the CI environment and logs
- CI runners are sandboxed and simply cannot open an outbound SSH connection to any managed production host at all, even when they are handed a perfectly valid key
- The controller runs the playbook measurably faster because it keeps the execution environment image cached in memory between jobs
- The
ansible-playbookcommand only works inside the controller and refuses to execute from a plain CI runner
What does "git as the source of truth" require of the controller's project?
- The project syncs from git on each run, so the playbook that executes is exactly what was reviewed and merged, with no out-of-band edits
- The controller keeps its own private copy of the playbook that engineers edit directly in the web UI, kept entirely separate from the git repository
- Every run automatically commits its task results back to
mainso the repository always reflects the live host state - The controller ignores git entirely and reads the playbook straight from whatever the CI runner happened to check out
What does an approval gate in front of the prod converge separate?
- Preparing a change (the merge) from authorizing the production blast (a named human saying go)
- The staging inventory from the prod inventory, which would otherwise collide and let a run hit both fleets at once
- The lint stage from the test stage, so the two are forced to run one after another instead of in parallel
- The EE build step from the EE push step, so the container image is always tagged before the converge run
Why is --check in CI not a full test of a prod deploy?
- Check mode catches much, but a custom module that mishandles check_mode or an order-dependent rolling deploy can pass check and still fail live
- Check mode only parses and validates the playbook's YAML syntax, and never evaluates any of the actual task behavior
- Check mode quietly runs against an empty inventory, so the predicted changes never reach any of the real target hosts
- Check mode is disabled outright inside execution environments, so a containerized CI pipeline silently skips the prediction step entirely without any warning
You got correct