Running Ansible From a Pipeline

Scenario: Production is changed from engineers' laptops. There is no record of who ran what, two people once ran conflicting playbooks simultaneously, and the only person who can deploy is on holiday.

New words, in plain English

Running production automation from laptops fails in predictable ways: no audit trail, no concurrency control, inconsistent Ansible and collection versions, and credentials spread across personal machines.

A pipeline fixes all four, and the structure is straightforward:

  1. On every pull request: ansible-lint, --syntax-check, and --check --diff against a representative subset. Attach the diff to the review as generated evidence.
  2. On merge to main: deploy to staging, then run the verification play.
  3. To production: a manual approval gate, then the same playbook, with the run log kept as an artefact.

Three operational details matter as much as the structure. Pin everything - the Ansible version and requirements.yml collections - so the pipeline is reproducible. Serialise runs with the pipeline's concurrency controls, so two deployments cannot fight over the same hosts. And hold credentials in the pipeline's secret store, injected at run time, so no long-lived key sits on anyone's laptop.

AWX and Ansible Automation Platform provide this as a product - a web UI, role-based access, job templates, scheduling, surveys and a full audit log. A plain CI pipeline achieves most of it with more assembly and less licensing.

Analogy: The difference between a workshop where anyone can pick up a tool and start cutting, and a production line with a work order, an operator ticket, a log and a supervisor sign-off. The second is slower per change and is the only one you would trust with the safety-critical parts.

A worked example

# .gitlab-ci.yml (or the equivalent) - the shape that matters
stages: [lint, check, staging, production]

variables:
  ANSIBLE_FORCE_COLOR: "1"

.setup: &setup
  before_script:
    - pip install "ansible-core==2.17.7"          # PINNED
    - ansible-galaxy install -r requirements.yml  # PINNED

lint:
  stage: lint
  <<: *setup
  script:
    - ansible-lint
    - ansible-playbook site.yml --syntax-check

dry-run:
  stage: check
  <<: *setup
  script:
    - ansible-playbook site.yml --check --diff --limit canary | tee diff.txt
  artifacts:
    paths: [diff.txt]           # generated change evidence

staging:
  stage: staging
  <<: *setup
  resource_group: staging       # never two runs at once
  script:
    - ansible-playbook site.yml -i inventory/staging
    - ansible-playbook verify.yml -i inventory/staging

production:
  stage: production
  <<: *setup
  when: manual                  # explicit human approval
  resource_group: production
  script:
    - ansible-playbook site.yml -i inventory/production
    - ansible-playbook verify.yml -i inventory/production

resource_group (or your platform's equivalent) is the unglamorous line that prevents a genuinely nasty failure mode: two pipelines converging the same hosts with different code, interleaving their changes. It costs one line and removes a class of incident that is very hard to diagnose afterwards.

Keeping the --check --diff output as an artefact gives you something better than a change description: a record of exactly what each deployment intended to change, generated by the tooling, retained alongside the run log. When something goes wrong three weeks later, that record is the fastest route to the answer.

At that point Ansible has stopped being a tool someone runs and become part of the delivery system - which is the whole destination of this track.

Tip: Add resource_group (or equivalent concurrency control) before you add anything else. Two simultaneous production runs is the failure that is hardest to reason about afterwards.
Goal: Put this to work in the ansible-preflight-assertions lab. Open /labs/ansible, pick ansible-preflight-assertions, and fix the real broken project - Ansible really does SSH into four managed hosts and converge them.