GitHub Actions #
GitHub Actions is a CI/CD platform integrated directly with GitHub repositories. For teams already using GitHub, this integration is very natural — workflows are defined as YAML files in the .github/workflows/ directory, triggered by Git events (push, pull_request, merge), and run on runners provided by GitHub or our own self-hosted runners. This article discusses comprehensive and secure Ansible integration patterns with GitHub Actions, focusing on scalable workflow structures for teams managing many roles and many environments.
GitHub Actions Anatomy for Ansible #
Before diving into specific workflows, first understand the basic structure. GitHub Actions has four concepts we must master: events (what triggers a workflow), jobs (units of work running on their own runner), steps (individual commands within a job), and actions (reusable pieces of code). For Ansible, most jobs are collections of steps calling ansible-playbook or ansible-lint.
flowchart LR
subgraph Trigger["Events"]
E1["push to main"]
E2["pull_request"]
E3["workflow_call"]
E4["schedule cron"]
end
subgraph Job1["Job: lint"]
J1S1["actions/checkout@v4"]
J1S2["setup-python@v5"]
J1S3["install ansible + ansible-lint"]
J1S4["ansible-lint --profile production"]
J1S1 --> J1S2 --> J1S3 --> J1S4
end
subgraph Job2["Job: molecule-test"]
J2S1["matrix: common, nginx, postgresql, docker"]
J2S2["molecule test for each role"]
J1S4 --> J2S1 --> J2S2
end
subgraph Job3["Job: build-image"]
J3S1["docker/metadata-action"]
J3S2["docker/build-push-action"]
J3S3["outputs: image_tag"]
J2S2 --> J3S1 --> J3S2 --> J3S3
end
subgraph Job4["Job: deploy-staging"]
J4S1["setup Ansible"]
J4S2["setup SSH key from secrets"]
J4S3["ansible-playbook deploy.yml"]
J4S4["staging smoke test"]
J3S3 --> J4S1 --> J4S2 --> J4S3 --> J4S4
end
subgraph Job5["Job: deploy-production"]
J5S1["approval gate"]
J5S2["setup SSH + vault"]
J5S3["ansible-playbook deploy.yml"]
J5S4["health check"]
J4S4 --> J5S1 --> J5S2 --> J5S3 --> J5S4
end
E1 --> Job1
E2 --> Job1
E3 --> Job1
E4 --> Job3The diagram above shows the pattern we’ll build: trigger events fire the lint job, its result gates the molecule test job, then build, then deploy-staging, and finally deploy-production which requires approval. Notice the arrow directions — every needs: in YAML is one dependency arrow. Jobs without needs: run in parallel; jobs with needs: wait for their predecessor to finish.
Always separate jobs by function, not by environment. Thelintjob doesn’t care which environment the deploy happens in — it only validates code. Thedeploy-stagingjob doesn’t care whether lint passed — it only needs the artifact. This separation makes workflows easier to reuse and debug.
Basic Workflow: Lint and Syntax Check #
Every pull request must pass validation before being merged. The lint workflow is the first, cheapest gate to run — if it fails, no need to continue to tests or builds.
# .github/workflows/validate.yml
name: Validate Ansible
on:
pull_request:
paths:
- '**.yml'
- '**.yaml'
- '**.j2'
- 'roles/**'
- 'playbooks/**'
- 'inventory/**'
jobs:
lint:
name: Ansible Lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Cache pip packages
uses: actions/cache@v4
with:
path: ~/.cache/pip
key: pip-${{ hashFiles('requirements.txt', 'requirements-dev.txt') }}
restore-keys: pip-
- name: Install Ansible and lint
run: |
pip install ansible ansible-lint
ansible-galaxy install -r requirements.yml
- name: Syntax check all playbooks
run: |
for playbook in playbooks/*.yml; do
echo "Checking: $playbook"
ansible-playbook "$playbook" --syntax-check \
-i inventory/staging/ \
-e @tests/test-vars.yml
done
- name: Run ansible-lint
run: ansible-lint --profile production
Three important things in this workflow: the paths filter ensures the workflow only triggers when Ansible files change (it doesn’t run if we only edit the README), pip caching speeds up iteration (installing Ansible + ansible-lint takes 1-2 minutes without cache, 10-15 seconds with it), and the manual syntax check for all playbooks ensures the YAML is valid before ansible-lint even runs.
Molecule Testing with a Parallel Matrix #
Molecule testing one role takes 3-5 minutes. If we have 10 roles, sequential testing takes 30-50 minutes. With a matrix, everything runs in parallel and finishes in 5-7 minutes total.
# .github/workflows/molecule.yml
name: Molecule Test
on:
pull_request:
paths:
- 'roles/**'
jobs:
molecule:
name: Test Role — ${{ matrix.role }}
runs-on: ubuntu-latest
strategy:
matrix:
role:
- common
- nginx
- postgresql
- docker
fail-fast: false # Continue testing other roles even if one fails
steps:
- uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Cache pip
uses: actions/cache@v4
with:
path: ~/.cache/pip
key: molecule-${{ matrix.role }}-${{ hashFiles('**/requirements*.txt') }}
- name: Install test dependencies
run: |
pip install ansible molecule molecule-plugins[docker] pytest-testinfra
ansible-galaxy install -r requirements.yml
- name: Run the Molecule test
run: |
cd roles/${{ matrix.role }}
molecule test
env:
PY_COLORS: '1'
ANSIBLE_FORCE_COLOR: '1'
MOLECULE_DISTRO: ubuntu2204
- name: Upload test results
uses: actions/upload-artifact@v4
if: failure()
with:
name: molecule-logs-${{ matrix.role }}
path: roles/${{ matrix.role }}/molecule/default/*.log
Notice fail-fast: false — without this flag, if one role fails, GitHub cancels all still-running matrix jobs. For Molecule, we want to still see the results of other roles to know whether the problem hits the whole system (all roles failing with the same error) or is specific (only this role has the issue). The unique per-role cache key prevents role A from using role B’s dependency cache which might have been updated.
Don’t forget the if: failure() on the upload-artifact step. Without this condition, the artifact uploads every time — flooding storage and adding job time. Only upload when tests fail, so developers can download logs without re-running the workflow to reproduce.Multi-Environment Deployment with Approval #
The deploy workflow is where all pipeline components meet. The build job produces an image, the deploy-staging job pulls that image to staging, and the deploy-production job promotes it to production — with manual approval in between.
# .github/workflows/deploy.yml
name: Deploy
on:
push:
branches: [main]
tags: ['v*.*.*']
jobs:
build:
name: Build and Push Image
runs-on: ubuntu-latest
outputs:
image_tag: ${{ steps.tag.outputs.tag }}
steps:
- uses: actions/checkout@v4
- name: Generate the tag
id: tag
run: echo "tag=${GITHUB_SHA::8}" >> $GITHUB_OUTPUT
- name: Login to the registry
uses: docker/login-action@v3
with:
registry: registry.company.com
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_PASSWORD }}
- name: Build and push
uses: docker/build-push-action@v5
with:
push: true
tags: |
registry.company.com/myapp:${{ steps.tag.outputs.tag }}
registry.company.com/myapp:latest
deploy-staging:
name: Deploy to Staging
needs: build
runs-on: ubuntu-latest
environment: staging
steps:
- uses: actions/checkout@v4
- name: Setup Ansible
run: pip install ansible && ansible-galaxy install -r requirements.yml
- name: Setup credentials
run: |
install -d -m 700 ~/.ssh
echo "${{ secrets.STAGING_SSH_KEY }}" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
echo "${{ secrets.VAULT_PASS_STAGING }}" > /tmp/.vault_pass
chmod 600 /tmp/.vault_pass
- name: Deploy to staging
run: |
ansible-playbook -i inventory/staging/ playbooks/deploy.yml \
-e "app_version=${{ needs.build.outputs.image_tag }}" \
--vault-password-file /tmp/.vault_pass \
--private-key ~/.ssh/deploy_key
- name: Smoke test staging
run: |
sleep 15
curl -f https://staging.company.com/health
curl -f https://staging.company.com/api/version
- name: Cleanup credentials
if: always()
run: rm -f ~/.ssh/deploy_key /tmp/.vault_pass
deploy-production:
name: Deploy to Production
needs: [build, deploy-staging]
runs-on: ubuntu-latest
environment:
name: production # Set required reviewers in GitHub Settings → Environments
url: https://app.company.com
if: github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
steps:
- uses: actions/checkout@v4
- name: Setup Ansible
run: pip install ansible && ansible-galaxy install -r requirements.yml
- name: Setup credentials
run: |
install -d -m 700 ~/.ssh
echo "${{ secrets.PROD_SSH_KEY }}" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
echo "${{ secrets.VAULT_PASS_PROD }}" > /tmp/.vault_pass
chmod 600 /tmp/.vault_pass
- name: Deploy to production
run: |
ansible-playbook -i inventory/production/ playbooks/deploy.yml \
-e "app_version=${{ needs.build.outputs.image_tag }}" \
--vault-password-file /tmp/.vault_pass \
--private-key ~/.ssh/deploy_key
- name: Verify production
run: |
sleep 30
curl -f https://app.company.com/health
- name: Cleanup
if: always()
run: rm -f ~/.ssh/deploy_key /tmp/.vault_pass
Three things often missed in the workflow above: outputs.image_tag in the build job is forwarded to the deploy job via needs.build.outputs.image_tag — this guarantees the image deployed to staging is exactly the same as the one deployed to production. The if: always() on the cleanup step ensures credentials are deleted even if the job fails (without this condition, the SSH key could be left on the runner). The production environment is set with required reviewers in GitHub Settings, so production deploys always wait for approval from designated people.
Approval in GitHub Environments has two modes: required reviewers (deploy waits for N people to approve) and wait timer (deploy happens automatically after X minutes). Combining both suits scenarios where production deploys should be deliberate but not blocked indefinitely — set a 60-minute wait timer; if nobody approves in that time, the pipeline dies and must be re-triggered.
Trigger Strategy: Push vs PR vs Schedule #
Choosing the right triggers determines the developer feedback loop. Triggers that are too frequent make runners compete and costs balloon; triggers that are too rare make bugs discovered late.
| Trigger | When to Use | Example | Notes |
|---|---|---|---|
pull_request | Validate code before merging | lint, syntax check, unit tests, molecule | Runs in forks without secret access; use pull_request_target carefully |
push to a specific branch | Automatic deploy per branch | push to main → deploy staging | Avoid push to ** — could trigger thousands of workflows |
push to a tag | Release deployments | tag v*.*.* → deploy production | Suitable for semantic versioning |
workflow_run | Chain to another workflow | CD workflow waits for CI to finish | The official way to integrate CI → CD without direct coupling |
schedule | Periodic maintenance | nightly security scans, dependency updates | Uses cron expressions; runs on the last main commit |
workflow_dispatch | Manual trigger from UI/CLI | emergency deploy, re-run with input | Can take custom inputs for flexibility |
repository_dispatch | Trigger from external APIs | events from monitoring tools, alert managers | Needs webhook configuration in repo settings |
flowchart TD
A{"Want to validate<br/>code?"} -- Yes --> B["pull_request"]
A -- No --> C{"Want to deploy<br/>automatically?"}
C -- Yes --> D{"Based on branch or tag?"}
D -- Branch --> E["push to a specific branch"]
D -- Tag --> F["push to tag v*"]
C -- No --> G{"Need a chain<br/>from another workflow?"}
G -- Yes --> H["workflow_run"]
G -- No --> I{"Is there a specific<br/>schedule?"}
I -- Yes --> J["schedule cron"]
I -- No --> K{"Trigger from<br/>external or UI?"}
K -- UI/CLI --> L["workflow_dispatch"]
K -- External API --> M["repository_dispatch"]For Ansible, the usually most effective combination:pull_requestfor lint + molecule,push to mainfor staging deploys, andpush to tag v*.*.*for production deploys (beyond the approval gate). Weekly schedule for dependency update scans. Addworkflow_dispatchfor emergency hotfix deployments that don’t wait for the automatic pipeline.
Reusable Workflows #
For organizations with many repositories using similar deployment patterns, reusable workflows are a game changer. Write one workflow, use it everywhere.
# .github/workflows/reusable-deploy.yml (in the shared repository)
name: Reusable Deploy
on:
workflow_call: # Can be called from other workflows
inputs:
environment:
required: true
type: string
image_tag:
required: true
type: string
playbook:
required: false
type: string
default: playbooks/deploy.yml
secrets:
ssh_key:
required: true
vault_password:
required: true
jobs:
deploy:
runs-on: ubuntu-latest
environment: ${{ inputs.environment }}
steps:
- uses: actions/checkout@v4
- name: Deploy
run: |
pip install ansible && ansible-galaxy install -r requirements.yml
echo "${{ secrets.ssh_key }}" > /tmp/deploy_key
echo "${{ secrets.vault_password }}" > /tmp/.vault_pass
chmod 600 /tmp/deploy_key /tmp/.vault_pass
ansible-playbook -i inventory/${{ inputs.environment }}/ \
${{ inputs.playbook }} \
-e "app_version=${{ inputs.image_tag }}" \
--vault-password-file /tmp/.vault_pass \
--private-key /tmp/deploy_key
rm -f /tmp/deploy_key /tmp/.vault_pass
# In the application repository — call the reusable workflow
jobs:
deploy-staging:
uses: company/shared-workflows/.github/workflows/reusable-deploy.yml@main
with:
environment: staging
image_tag: ${{ needs.build.outputs.tag }}
secrets:
ssh_key: ${{ secrets.STAGING_SSH_KEY }}
vault_password: ${{ secrets.VAULT_PASS_STAGING }}
Notice the secrets: inherit in the caller — all secrets from the caller are available in the reusable workflow as inputs. This avoids hardcoding secret names in the shared workflow (which would need to know every application’s secret names). The caller just maps: local secret STAGING_SSH_KEY → input ssh_key in the shared workflow. The @main tag (or a specific SHA for production-grade) determines which shared workflow version is used.
Don’t pin the reusable workflow to themainbranch in the shared repository if other teams will use it. A SHA tag is safer:company/shared-workflows/.github/workflows/reusable-deploy.yml@a1b2c3d4. With a SHA, changes in the shared workflow don’t immediately break all callers; the SHA must be updated explicitly.
Self-Hosted Runners for Private Network Access #
GitHub-hosted runners run on GitHub’s infrastructure which has no access to our internal network. To deploy to managed nodes in a private VPC, we need a self-hosted runner running inside the same network.
# roles/github-runner/tasks/main.yml
---
- name: Download the GitHub Actions runner
get_url:
url: "https://github.com/actions/runner/releases/download/v{{ runner_version }}/actions-runner-linux-x64-{{ runner_version }}.tar.gz"
dest: /home/github-runner/actions-runner.tar.gz
- name: Extract the runner
unarchive:
src: /home/github-runner/actions-runner.tar.gz
dest: /home/github-runner/
remote_src: true
- name: Configure the runner
command: >
/home/github-runner/config.sh
--url https://github.com/{{ github_org }}
--token {{ vault_runner_token }}
--name {{ inventory_hostname }}
--labels self-hosted,production-network
--unattended
become_user: github-runner
- name: Install and run the runner as a service
command: /home/github-runner/svc.sh install
become: true
Notice the self-hosted,production-network labels — these labels are how workflows target specific runners. In the workflow, add runs-on: [self-hosted, production-network] so jobs only run on runners with those labels. This prevents jobs from accidentally running on GitHub-hosted runners (which don’t have access to the internal network).
GitHub-Hosted vs Self-Hosted Runners #
| Aspect | GitHub-Hosted | Self-Hosted |
|---|---|---|
| Cost | Free for public repos, minute quotas for private | Only our own infrastructure costs (VM, network) |
| Network access | Internet only, can’t reach private VPCs | Full access to our internal network |
| Pre-installed tools | Complete (Docker, Node, Python, etc. standard versions) | Only what we install ourselves |
| Performance | Standard, sometimes queued at peak | Consistent, no queue |
| Customization | Limited (hosted runner image customization exists for Enterprise) | Full: OS, tools, hardware |
| Security boundary | Full isolation from our infrastructure | Our runner = trusted compute; don’t run jobs from fork PRs without filtering |
| Maintenance | GitHub maintains it | We do the maintenance (OS updates, security patches) |
| Suitable for | Public repos, testing, open source, workflows without internal access | Deploys to on-prem, private VPCs, internal tool integrations |
Anti-Patterns to Avoid #
Three anti-patterns most frequently appearing in GitHub Actions workflows for Ansible:
1. Secrets Echoed Directly into Logs #
# ANTI-PATTERN: secrets spread to the environment without filtering
jobs:
deploy:
steps:
- name: Setup credentials
run: |
echo "VAULT_PASS=${{ secrets.VAULT_PASS_PROD }}" >> $GITHUB_ENV
echo "SSH_KEY=${{ secrets.PROD_SSH_KEY }}" >> $GITHUB_ENV
ansible-playbook -i inventory/prod/ deploy.yml
# ANTI-PATTERN: secrets stored in GITHUB_ENV are automatically
# masked in the logs... if written correctly.
# But if a later step echoes "connecting with $SSH_KEY"
# or prints env, GitHub might not mask everything.
# CORRECT: secrets written directly to files, never echoed
jobs:
deploy:
steps:
- name: Setup credentials
run: |
mkdir -p ~/.ssh
printf '%s' "${{ secrets.PROD_SSH_KEY }}" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
printf '%s' "${{ secrets.VAULT_PASS_PROD }}" > /tmp/.vault_pass
chmod 600 /tmp/.vault_pass
- name: Deploy
run: |
ansible-playbook -i inventory/prod/ deploy.yml \
--vault-password-file /tmp/.vault_pass \
--private-key ~/.ssh/deploy_key
- name: Cleanup
if: always()
run: rm -f ~/.ssh/deploy_key /tmp/.vault_pass
# CORRECT: secrets are only written as files with 600 permissions.
# Never printed, never set as env vars that could leak through
# other steps. The if: always() cleanup ensures files are
# deleted even if the job fails.
2. Pinning Actions to the main Branch
#
# ANTI-PATTERN: use actions directly from the main branch
jobs:
build:
steps:
- uses: actions/checkout@main # Can change at any time
- uses: actions/setup-python@main # A breaking change could appear tomorrow
- uses: docker/build-push-action@main # A supply chain attack could go undetected
# CORRECT: pin actions to stable tags or immutable SHAs
jobs:
build:
steps:
- uses: actions/checkout@v4 # Stable tag
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # SHA
- uses: actions/setup-python@v5
- uses: docker/build-push-action@v5
# CORRECT: SHAs are the gold standard for security-sensitive
# workflows. Tags can be force-pushed or overridden by the
# maintainer; a SHA is a cryptographic commitment to specific
# code and can't change without GitHub noticing.
3. Non-Reusable Workflows #
# ANTI-PATTERN: copy-paste workflows in every repository
# The same file exists in 8 repositories with small differences
# in environment names, secret names, and playbook paths
# repo-a/.github/workflows/deploy.yml
on:
push:
branches: [main]
jobs:
deploy-prod:
runs-on: ubuntu-latest
environment: production
steps:
- run: |
echo "${{ secrets.PROD_SSH_KEY_A }}" > /tmp/key
ansible-playbook -i inventory/prod/ deploy.yml \
-e "app_version=$GITHUB_SHA" --private-key /tmp/key
rm -f /tmp/key
# CORRECT: reusable workflow + caller per repo
# shared-workflows/.github/workflows/deploy-ansible.yml
on:
workflow_call:
inputs:
environment: {required: true, type: string}
image_tag: {required: true, type: string}
secrets:
ssh_key: {required: true}
vault_password: {required: true}
jobs:
deploy:
runs-on: ubuntu-latest
environment: ${{ inputs.environment }}
steps:
- uses: actions/checkout@v4
- run: |
echo "${{ secrets.ssh_key }}" > /tmp/key
echo "${{ secrets.vault_password }}" > /tmp/.vault_pass
chmod 600 /tmp/key /tmp/.vault_pass
ansible-playbook -i inventory/${{ inputs.environment }}/ \
deploy.yml -e "app_version=${{ inputs.image_tag }}" \
--vault-password-file /tmp/.vault_pass \
--private-key /tmp/key
rm -f /tmp/key /tmp/.vault_pass
# repo-a/.github/workflows/deploy.yml — only 6 lines!
jobs:
deploy:
uses: org/shared-workflows/.github/workflows/deploy-ansible.yml@v1
with:
environment: production
image_tag: ${{ needs.build.outputs.tag }}
secrets:
ssh_key: ${{ secrets.PROD_SSH_KEY_A }}
vault_password: ${{ secrets.VAULT_PASS_A }}
# CORRECT: one place to update deployment logic. A bug fix in the
# shared workflow can be rolled out to all callers with a
# version tag update. No manual syncing of 8 repositories.
Summary #
- Separate workflows:
validate.ymlfor PRs,molecule.ymlfor role testing,deploy.ymlfor deployments — one workflow focused on one purpose.environmentwith required reviewers in GitHub Settings is the easiest way to require approval before production deploys.- Use
matrixto run Molecule tests for all roles in parallel — much faster than testing one by one.fail-fast: falseso all roles are still tested even if one fails.needs:+outputs:to pass values between jobs — the image tag produced by the build job must be available in the deploy job vianeeds.build.outputs.image_tag.- Reusable workflows (
workflow_call) to share deployment configuration across repositories — one change in the shared workflow immediately applies to all applications. Pin to a SHA for security.- Self-hosted runners for accessing managed nodes on private networks — the runner runs inside the same network as the target servers.
- Pin actions to SHAs for security-sensitive workflows — tags can change, SHAs are cryptographic commitments.
- Clean up credentials with
rm -fandif: always()in every job using secrets — ensure no credentials are left behind even if the job fails.