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 --> Job3

The 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. The lint job doesn’t care which environment the deploy happens in — it only validates code. The deploy-staging job 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.

TriggerWhen to UseExampleNotes
pull_requestValidate code before merginglint, syntax check, unit tests, moleculeRuns in forks without secret access; use pull_request_target carefully
push to a specific branchAutomatic deploy per branchpush to main → deploy stagingAvoid push to ** — could trigger thousands of workflows
push to a tagRelease deploymentstag v*.*.* → deploy productionSuitable for semantic versioning
workflow_runChain to another workflowCD workflow waits for CI to finishThe official way to integrate CI → CD without direct coupling
schedulePeriodic maintenancenightly security scans, dependency updatesUses cron expressions; runs on the last main commit
workflow_dispatchManual trigger from UI/CLIemergency deploy, re-run with inputCan take custom inputs for flexibility
repository_dispatchTrigger from external APIsevents from monitoring tools, alert managersNeeds 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_request for lint + molecule, push to main for staging deploys, and push to tag v*.*.* for production deploys (beyond the approval gate). Weekly schedule for dependency update scans. Add workflow_dispatch for 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 the main branch 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 #

AspectGitHub-HostedSelf-Hosted
CostFree for public repos, minute quotas for privateOnly our own infrastructure costs (VM, network)
Network accessInternet only, can’t reach private VPCsFull access to our internal network
Pre-installed toolsComplete (Docker, Node, Python, etc. standard versions)Only what we install ourselves
PerformanceStandard, sometimes queued at peakConsistent, no queue
CustomizationLimited (hosted runner image customization exists for Enterprise)Full: OS, tools, hardware
Security boundaryFull isolation from our infrastructureOur runner = trusted compute; don’t run jobs from fork PRs without filtering
MaintenanceGitHub maintains itWe do the maintenance (OS updates, security patches)
Suitable forPublic repos, testing, open source, workflows without internal accessDeploys 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.yml for PRs, molecule.yml for role testing, deploy.yml for deployments — one workflow focused on one purpose.
  • environment with required reviewers in GitHub Settings is the easiest way to require approval before production deploys.
  • Use matrix to run Molecule tests for all roles in parallel — much faster than testing one by one. fail-fast: false so 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 via needs.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 -f and if: always() in every job using secrets — ensure no credentials are left behind even if the job fails.

← Previous: Pipeline Design Next: GitLab CI →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact