Skip to content

Ansible + GitHub Actions: Validation and Controlled Execution

Lesson 7 of 8Advanced15 min readGit for DevOps & Infrastructure · AnsibleVerified: GitHub Actions documentation, September 2026

Running Ansible from CI is convenient and is the point at which your automation platform gains SSH access to your fleet.

That is a legitimate architecture with specific requirements. This lesson is about the requirements — and about the separation that has to exist before any of it is safe.

Validation and execution, separated

A vertical sequence: a pull request triggers validation with no credentials; merge; a manually dispatched execution workflow gated by an environment approval, holding host credentials, running against a named inventory.

Pull requestUntrusted inputValidationLint, syntax, Molecule — no credentialsReview and mergeA human approves the changeManual dispatchSomebody chooses to run itEnvironment approvalA named person, recordedExecutionHost credentials, named inventory

ansible-validate.yml runs on pull requests. It has the repository and nothing else — no SSH key, no vault password, no inventory naming a real machine.

ansible-run.yml runs on workflow_dispatch, or on a schedule for a genuinely routine job. It holds credentials and is gated by an environment.

Keeping them in separate files makes the separation visible to anybody reading the repository, and makes it obvious in review when somebody proposes blurring it.

name: Ansible run
on:
workflow_dispatch:
inputs:
environment:
description: Target environment
required: true
type: choice
options: [development, staging, production]
playbook:
description: Playbook to run
required: true
type: choice
options: [site.yml, webservers.yml, database.yml]
limit:
description: Limit to hosts or a group (recommended)
required: false
type: string
check_mode:
description: Run in check mode first
required: false
type: boolean
default: true
permissions:
contents: read
id-token: write
concurrency:
group: ansible-${{ inputs.environment }}
cancel-in-progress: false
jobs:
run:
runs-on: ubuntu-latest
environment: ${{ inputs.environment }}
timeout-minutes: 60
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v7
with:
python-version: "3.14"
- name: Install
run: |
python -m pip install --upgrade pip
pip install "ansible-core==${{ vars.ANSIBLE_CORE_VERSION }}"
ansible-galaxy install -r requirements.yml
- name: Configure SSH
run: |
install -m 0700 -d ~/.ssh
printf '%s\n' "${{ secrets.ANSIBLE_SSH_KEY }}" > ~/.ssh/id_ed25519
chmod 0600 ~/.ssh/id_ed25519
printf '%s\n' "${{ vars.SSH_KNOWN_HOSTS }}" > ~/.ssh/known_hosts
- name: Check mode
if: inputs.check_mode
env:
ANSIBLE_VAULT_PASSWORD_FILE: /tmp/vault_pass
run: |
printf '%s' "${{ secrets.VAULT_PASSWORD }}" > /tmp/vault_pass
ansible-playbook \
-i "inventories/${{ inputs.environment }}/hosts.yml" \
${{ inputs.limit && format('--limit {0}', inputs.limit) || '' }} \
--check --diff \
"playbooks/${{ inputs.playbook }}"
- name: Run
env:
ANSIBLE_VAULT_PASSWORD_FILE: /tmp/vault_pass
run: |
ansible-playbook \
-i "inventories/${{ inputs.environment }}/hosts.yml" \
${{ inputs.limit && format('--limit {0}', inputs.limit) || '' }} \
--diff \
"playbooks/${{ inputs.playbook }}"
- name: Clean up
if: always()
run: rm -f /tmp/vault_pass ~/.ssh/id_ed25519

The parts carrying the design:

workflow_dispatch with typed inputs. Somebody chooses to run this, and chooses the environment and playbook from a list rather than typing a path.

environment: ${{ inputs.environment }} scopes secrets per environment and applies that environment’s protection rules. Production requires a named approver; development may not.

concurrency with cancel-in-progress: false. Two runs against the same environment queue rather than interleaving. Cancelling a playbook midway leaves hosts in a partial state.

Check mode first, by default. --check --diff shows what would change without changing it. Its limits are real — modules implement check individually, and any task depending on a previous task’s actual effect reports inaccurately — but it catches the obvious, and defaulting the input to true means somebody has to deliberately turn it off.

--limit as an input, with the description recommending it. Running against one host first is the difference between one broken machine and all of them.

Known hosts from a variable, so host key verification is not disabled. ANSIBLE_HOST_KEY_CHECKING=False is common in tutorials and removes protection against connecting to the wrong machine.

Cleanup with if: always(), so a failed run does not leave the key and vault password on the runner.

A timeout, so a hung playbook does not hold a runner and its credentials indefinitely.

Three kinds, with different handling.

SSH keys to managed hosts. A dedicated key per environment, stored as an environment secret so the development job cannot read production’s. Use a key with a limited command restriction where the target supports it, and rotate on a schedule.

Vault passwords. Environment secrets, written to a file for the run and removed afterwards. Never echoed, never in a command line where it appears in process listings.

Cloud credentials, where playbooks touch cloud APIs. OIDC rather than a stored key — no standing credential to leak, and the trust policy can require a specific repository, ref and environment.

The one you cannot avoid is the SSH key. Ansible connects over SSH, and there is no OIDC equivalent for a plain host — nothing exchanges a short-lived workflow token for host access the way a cloud provider does. That makes it the highest-value secret in the repository and the one whose scope matters most: a key that can reach every production host is a key whose exposure is a fleet-wide incident rather than a single-service one.

Mitigations worth having, in rough order of strength:

Short-lived certificates from an SSH certificate authority. The workflow obtains a certificate valid for minutes rather than holding a key valid indefinitely. This is the strongest option, it removes the standing credential entirely, and it needs infrastructure most teams do not have yet.

A bastion the runner must connect through, so the key alone is insufficient and the bastion’s logs record every session.

Network restrictions so the key is only usable from expected addresses.

A key restricted to a specific command where the target supports it, limiting what a stolen key can do.

A key per environment, so exposure of one does not reach the others. The minimum, and the one everybody can do today.

The mechanism that makes production execution a decision.

Required reviewers on the production environment mean the job waits for a named person, and the approval is recorded.

Scoped secrets mean the production SSH key is a production-environment secret and a development run cannot read it. This is the part people miss: a repository-level secret is visible to every job in every workflow in the repository, including one added by a pull request if that workflow is ever triggered with access to secrets. Moving credentials from repository secrets to environment secrets is a ten-minute change and one of the highest-value ones in this lesson.

Branch restrictions limit deployments to the default branch.

Wait timers give somebody a window to cancel.

What the approval is actually for: somebody who understands the change and the current state of the fleet says yes. Not a formality — the approver should know whether now is a good time, which nothing automated does. An approver clicking through without reading is a worse position than no approval at all, because the process now claims a check that is not happening.

Frequently necessary, because managed hosts sit on private networks.

The runner needs network access to the hosts. That is the requirement, and it means the runner is inside your network with credentials.

Ephemeral runners. A runner handling one job and being destroyed cannot carry a modified tool or a planted credential to the next job. The single most valuable control here.

Never use self-hosted runners with public repositories. GitHub’s own documentation is unambiguous, and the reason is that anybody’s pull request code would run on a machine inside your network.

Separate runner pools by trust level. The pool running pull request validation and the pool running production playbooks must not be the same machines. If a validation job can compromise a runner that later executes against production, the separation this lesson is built on is gone.

Network scope, not machine scope. The runner should reach the hosts it manages and nothing else. “It is inside the VPC” is not a boundary.

No standing credentials on the host. Secrets come from the workflow, not from files on the runner.

Some Ansible work is genuinely routine — configuration drift correction, certificate renewal, a compliance sweep.

A schedule is safer than a webhook, because nobody can trigger it. A workflow an outsider can cause to run is a workflow they can aim.

Run in check mode on a schedule, and act on the result. A nightly --check --diff against production reports what has drifted without changing anything. That is drift detection for hosts, and it is the closest Ansible equivalent to a scheduled Terraform plan.

Do not schedule unattended changes to production unless the change is genuinely safe to apply at any moment without a human present. That is a much smaller set of playbooks than teams assume: a playbook that restarts anything, changes anything a running request might depend on, or has ever surprised somebody does not qualify. The honest test is whether you would be comfortable with it running at 04:00 on a Sunday during an unrelated incident, because eventually it will.

Alert on the result, not on every run.

Include the same --limit discipline. A scheduled job with no limit is a scheduled job that touches everything.

The setting that makes a playbook a deployment rather than an outage.

serial: in the play processes hosts in batches. serial: 1 is one at a time; serial: "25%" is a quarter of the group.

max_fail_percentage stops the run if too many hosts fail, rather than continuing through the fleet breaking each one.

any_errors_fatal: true stops immediately on the first failure, which is right when hosts must stay consistent.

Health checks between batches. A task that waits for the service to be responding — a uri check, or waiting for a port — before the next batch proceeds. Without one, serial paces the restarts and does not confirm that any of them succeeded, so a broken change rolls through the whole group one host at a time.

Without serial, a playbook restarting a service restarts it everywhere at once. That is the single most consequential Ansible setting for availability, and its absence is invisible until the first time it matters — which is typically the first time the playbook runs against a group larger than the one it was developed against.

Ansible does not keep a history. The workflow run is the record, and it should be a good one.

The run’s inputs are in the workflow log — environment, playbook, limit, check mode.

The commit SHA is in the run context. Which version of the playbook executed.

The approver is in the environment’s deployment record.

Post a summary to $GITHUB_STEP_SUMMARY: what ran, against what, with what limit, and the recap counts. That is the thing somebody reads during an incident review.

Keep --diff output, which is the closest thing to a record of what actually changed on the hosts.

Do not log secrets. --diff on a template containing a credential prints it. no_log: true on tasks handling secrets is what prevents that, and it is worth checking before enabling diff output on a run that touches vault variables.

A question worth asking before building any of this.

The case for it: a recorded, repeatable execution with an approval trail, run from a consistent environment rather than from whichever laptop somebody happened to be at. That is a genuine improvement over ad-hoc runs, and the audit trail is frequently the actual requirement.

The case against: it puts SSH access to your fleet in your CI system. That is a concentration of privilege, and CI systems are a well-understood target precisely because of what they hold.

The middle position most teams should consider: validation in CI, execution from a controlled place that is not CI — a bastion, a dedicated automation host, or a purpose-built platform. The execution host holds the credentials, the audit trail comes from its own logging, and CI never has SSH access at all.

What tips the decision toward CI: a small team without a controlled automation host, a need for the approval workflow environments provide, and playbooks that are routine rather than dangerous.

What tips it away: a large fleet, regulated access requirements, or playbooks whose failure mode is severe. In those cases the concentration of privilege is the dominant consideration.

The answer that is usually wrong is running Ansible from CI because it was convenient, without anybody having weighed what the CI system now holds. If you are building this, be able to say why — and be able to say what an attacker who compromised the workflow could reach.

Which inventory a run targets is the single most consequential input, and the mechanisms for getting it wrong are worth enumerating.

A choice input is the right shape. A free-text path lets somebody type inventories/production into a job labelled development.

Validate that the environment and the inventory agree. A one-line check that the inventory path contains the environment name catches a mismatched pair before anything connects.

Dynamic inventory changes the picture. Where hosts come from a cloud provider’s API at run time, the workflow needs credentials to query it, and the inventory’s contents depend on tags rather than on a committed file. That is better in most ways — no stale host list — and it means a mistagged instance is in scope for a playbook, so tagging becomes a safety control.

Never construct an inventory in the workflow from an input. A step that writes a hosts file from a string parameter is a step that will eventually target something unintended.

Print what was targeted. --list-hosts before the run, in the log, so the record shows which machines were in scope rather than which inventory file was named.

Execution triggered by pull_request. Proposed code against real hosts.

One workflow doing both. The separation becomes an if: expression.

Repository-level secrets rather than environment secrets. Every job can read the production key.

ANSIBLE_HOST_KEY_CHECKING=False. No protection against connecting to the wrong host.

cancel-in-progress: true. A cancelled playbook leaves hosts partially configured.

No --limit and no serial. Everything at once.

Not cleaning up the key and vault password. Left on the runner.

Self-hosted runners on a public repository. Anybody’s code inside your network.

Sharing runner pools between validation and execution. Undoes the separation.

--diff on tasks handling secrets without no_log. Credentials in the run log.

Scheduled unattended production changes. Applied at a moment nobody chose.

Ansible is not transactional, and a failed run leaves the fleet in a mixed state. Knowing what that means is the difference between a calm recovery and a confused one.

Some hosts are done, some are not. By default Ansible continues to other hosts when one fails, so a run that reports three failures out of twenty means seventeen are configured and three are in whatever state they reached.

A host that failed mid-play is partially configured. Tasks up to the failure ran; the rest did not. Handlers may or may not have fired, depending on whether the failure came before or after flush_handlers.

Re-running is usually the right response, and only if the role is idempotent. That is the practical reason idempotency testing matters: a non-idempotent role cannot safely be re-run after a partial failure, which is the exact moment you need to.

--limit @retry-file targets only the hosts that failed, which is faster and narrower than re-running everything. The retry file is written on failure and is one of the few Ansible artefacts worth keeping from a run.

Do not re-run blindly on production. Read what failed first. A task failing because a package repository was unreachable is a re-run; a task failing because the host is in an unexpected state is an investigation.

The workflow properties that make this manageable: a timeout so a hung run does not hold credentials, cancel-in-progress: false so a second run does not start on top of the first, and the --diff output preserved so you can see what had already changed.

A run that nobody knows finished is a run whose failure is discovered later.

Notify on failure, always. Where the team looks — a channel, an on-call system for production.

Notify on success for production runs. A production change that completed is something somebody should see, and it is how an unexpected run gets noticed.

Do not notify on every development run. Noise, and it trains people to ignore the channel that carries the production ones.

Include what matters in the message: environment, playbook, limit, the recap counts, and who approved it. A notification saying “Ansible run completed” tells nobody anything.

Link to the run. The log is where somebody goes next.

Post a step summary as well, using $GITHUB_STEP_SUMMARY. It survives longer than a chat message and is attached to the run itself.

The state somebody should find when reviewing an execution afterwards.

A dispatch with explicit inputs. Environment, playbook, and a limit that is narrower than everything unless there is a reason it is not.

An approval, by a named person, on anything touching production.

A check-mode step that ran first and whose output somebody looked at.

--list-hosts output in the log, showing which machines were actually in scope.

A recap with counts — ok, changed, unreachable, failed — per host.

--diff output for what changed, with no_log on anything handling secrets.

A summary posted to the run, so it is legible without scrolling.

No credentials in the log. Worth actually checking on the first few runs, because a task printing a variable is easy to miss until it appears.

Cleanup that ran, even on failure.

Nine things, most of them automatic once the workflow is right. The two that are not — an appropriate limit and somebody reading the check output — are the ones that matter most, and they are the reason this is a manual dispatch rather than an automatic one.

Validation runs on proposed code with no credentials. Execution runs on merged code, with credentials, when somebody decides. The two must not be the same workflow, must not share secrets, and only one of them can be triggered by a pull request.

Everything else — check mode, limits, serial, timeouts — reduces the blast radius of the half that can change things.

  • Two workflows: validation on pull requests with no credentials, execution on dispatch with an environment gate
  • workflow_dispatch with typed inputs makes environment and playbook a choice rather than a path
  • Environment secrets scope the SSH key per environment; repository secrets do not
  • cancel-in-progress: false — a cancelled playbook leaves hosts partially configured
  • Keep host key checking enabled and supply known hosts
  • The SSH key is the highest-value secret and has no OIDC equivalent; scope and rotate it
  • serial is what makes a playbook a rolling deployment rather than a simultaneous restart
  • Scheduled --check --diff is drift detection for hosts

Use a disposable repository and containers as targets. No production hosts, no real credentials.

  1. Create a validation workflow on pull_request with lint and syntax check only. Confirm it has no secrets configured.

  2. Create an execution workflow on workflow_dispatch with environment, playbook and limit inputs, targeting a container over SSH.

  3. Add an environment with yourself as a required reviewer. Dispatch it. Predict: does the job wait?

  4. Store a dummy key as a repository secret, then move it to an environment secret. Try to read it from a job using a different environment. Predict: what happens?

  5. Add --check --diff as a first step. Run it against a container that is already configured. Predict: what does it report?

  6. Add serial: 1 to a play targeting three containers and watch the ordering.

  7. Remove the cleanup step, run, and inspect what the runner would have been left holding.

  8. Try adding a pull_request trigger to the execution workflow. Before running it, write down what a malicious pull request could do. Then remove it.

  9. Delete the repository and the containers.

Lab: ship an infrastructure change through a pull requestPractise the review discipline that makes an infrastructure change reviewable before it is applied.

The GitOps and infrastructure repository templates are in the Professional Toolkit.