Free & MIT licensed

Learn real CI/CD by reading production pipelines.

Seven reusable GitHub Actions workflows for security scanning, Terraform, Docker, Kubernetes and compliance — written for new DevOps engineers, with the reasoning left in the comments instead of stripped out.

7 reusable workflows 6 copy-paste examples Self-linting CI No account needed

iampopye/devops-workflows
.github/workflows/
├── security/
│   └── reusable-security-scanning.yml
├── terraform/
│   └── reusable-terraform-infra.yml
├── docker/
│   └── reusable-docker-build.yml
├── kubernetes/
│   └── reusable-kubernetes-deploy.yml
├── observability/
│   └── reusable-compliance-validation.yml
├── ai/          # optional
│   ├── reusable-ai-pr-review.yml
│   └── reusable-ai-incident-analysis.yml
└── ci.yml       # lints this repo's own YAML

examples/       # copy these into your repo
└── consume-reusable-workflows.yml
What's inside

Seven workflows, one job each

Every file is a workflow_call workflow: it does nothing on its own and runs only when another repository calls it. Five are the core pipeline. Two are optional AI extras that nothing else depends on.

Security scanning

CodeQL SAST, a Trivy filesystem scan uploaded as SARIF, and a Gitleaks secret scan over the full commit history. An OWASP ZAP baseline DAST scan runs only if you pass a target URL.

Trivy is set to exit-code: 0 on purpose — failing there would skip the secret scan, so one dependency CVE could hide a leaked credential. The pass/fail gate is a separate final step.

security/reusable-security-scanning.yml

Terraform infrastructure

fmt, init, validate, plan, then an optional apply gated behind a GitHub environment so a human can approve it.

The plan is posted back as a pull-request comment, tail-truncated to fit GitHub's 65,536 character comment limit. It requests id-token: write so you can federate to AWS, GCP or Azure with OIDC instead of storing long-lived cloud keys.

terraform/reusable-terraform-infra.yml

Docker build & push

Buildx with GitHub Actions layer caching, multi-platform builds via QEMU, and semantic tags derived from the Git ref. :latest is applied only on the default branch.

Publishes an SBOM and a signed provenance attestation, scans the built image with Trivy before it ships, and returns the immutable image digest as a workflow output.

docker/reusable-docker-build.yml

Kubernetes deploy

A server-side --dry-run=server validation first, then an apply, then a rollout status wait for every Deployment, StatefulSet and DaemonSet it touched.

That wait matters: an apply returning zero only means the API server accepted the object. Without it, a CrashLoopBackOff still reports a green build. The kubeconfig is written with umask 077 and deleted in an always() step.

kubernetes/reusable-kubernetes-deploy.yml

Compliance validation

A Trivy infrastructure-as-code misconfiguration scan plus Open Policy Agent tests against your own Rego policies, summarised as an evidence table in the job summary.

It is deliberately honest about scope: this produces evidence toward a control set, not a compliance attestation. Mapping the results to HIPAA or GDPR Article 32 is work only your organisation can do.

observability/reusable-compliance-validation.yml

AI pull-request review Optional

Sends the PR diff to a model you choose and posts the response as a comment. Vendor-neutral by design: seven built-in presets, plus openai_compatible with your own api_url for Ollama, vLLM or anything self-hosted.

There is no default model, so no vendor is implied. The review is advisory and never blocks a merge; if the endpoint fails, the job warns instead of failing your build.

ai/reusable-ai-pr-review.yml

AI incident analysis Optional

Collects your recent failed workflow runs, asks a model for probable root cause, blast radius, mitigations and prevention, then opens a labelled issue with the write-up.

Meant for a schedule or a manual dispatch, not every push. Set dry_run: true to write the analysis to the job summary instead of filing an issue.

ai/reusable-ai-incident-analysis.yml

Examples you can copy

examples/consume-reusable-workflows.yml is the main entry point: one calling workflow that wires security, compliance, Terraform, Docker and Kubernetes together with the right needs: ordering.

Standalone examples cover ZAP baseline scanning, k6 load testing, Go plus SonarQube, Godog BDD tests, and AI review with one provider block per vendor.

examples/

The repo lints itself. ci.yml runs actionlint over every YAML file here and then runs a pin-check job that fails the build if any third-party action is referenced by anything other than a full 40-character commit SHA. A collection of best-practice workflows that is never itself linted is just a collection of untested YAML.

The teaching core

Why these are built this way

Three decisions show up in every file. They are the difference between YAML that works and YAML you can hand to someone else. If you read nothing else here, read this.

01 Pin actions to a commit SHA, not a tag

When you write uses: some-org/some-action@v1, you are trusting a Git tag. A tag is just a movable label pointing at a commit, and the person who owns that repository can move it whenever they like. Nothing stops them from repointing v1 at completely different code tomorrow.

That matters because an action does not run in a sandbox. It runs inside your job, on your runner, with access to whatever secrets and tokens that job was granted. So "the author can silently change the code" really means "the author can silently change what reads your production credentials on your next push."

A commit SHA is content-addressed: it is a hash of the commit itself. You cannot repoint it, because different code produces a different SHA. Pin to the SHA and keep the version in a trailing comment so humans can still read it — Dependabot updates both together when a real new version ships.

Mutable — the author can change this under you
- name: Trivy filesystem scan
  uses: aquasecurity/trivy-action@v0.36.0
  with:
    scan-type: fs
Immutable — this is what the repo actually ships
- name: Trivy filesystem scan
  uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
  with:
    scan-type: fs

The exception: actions/* and github/* are first-party GitHub actions and may use major-version tags — that is GitHub's own documented guidance. The pin-check job in ci.yml allows exactly those two namespaces and fails on everything else.

02 Start at zero permissions and grant upward

Every job gets a GITHUB_TOKEN automatically. In older repositories that token is broadly writable by default, which means a workflow that only needed to read your code could also push commits, edit issues or publish packages — and so could any action running inside it.

Every workflow in this repo opens with permissions: {}. That is an explicit deny-everything default. Each job then re-grants only the scopes it actually needs. Note that this is set per job, not once at the top: a workflow-level grant would hand every job the union of all permissions, which quietly defeats the point.

security/reusable-security-scanning.yml
# Deny by default, then grant the minimum each job needs.
permissions: {}

jobs:
  codeql:
    runs-on: ubuntu-latest
    permissions:
      contents: read           # check out the code
      security-events: write   # upload results to the Security tab
      actions: read            # CodeQL reads workflow metadata

  dast-zap-baseline:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      issues: write            # a different job, a different grant

The habit to build: when a job fails with a permissions error, add the one scope named in the error. Do not reach for write-all. The failure is the system telling you exactly what it needs.

03 Call a workflow instead of copying it

The usual way pipelines spread is copy-paste. You write a good build workflow, the next team copies it, and six months later thirty repositories each hold a slightly different mutation of the same file. Fixing a bug means thirty pull requests, and you will miss some.

A workflow whose trigger is on: workflow_call becomes callable from other repositories. It declares typed inputs, secrets and outputs — a real interface — and consumers reference it in one line. Fix the bug once here, and every repo pinned to the new tag gets the fix.

The reusable workflow declares its interface
on:
  workflow_call:
    inputs:
      image_name:
        required: true
        type: string
      push:
        type: boolean
        default: false
    outputs:
      digest:
        value: ${{ jobs.build.outputs.digest }}
Your repo calls it in three lines
jobs:
  docker-build:
    uses: iampopye/devops-workflows/.github/workflows/docker/reusable-docker-build.yml@v1
    permissions:
      contents: read
      packages: write
      id-token: write
    with:
      image_name: ghcr.io/${{ github.repository }}
      push: ${{ github.ref == 'refs/heads/main' }}

Pin your call too. Reference @v1, not @main. The same mutability argument from section 01 applies to the workflow you are calling: with @main, an upstream commit changes your pipeline without you choosing it. A tag moves only when you bump it.

Start here

A learning path, in order

Do not wire up all five at once. Each step below gives you something that fails safely and teaches one idea. Add the next only when the previous one is green.

  1. Security scanning

    Start here because it changes nothing. It reads your code and reports; it cannot deploy, push or delete anything. That makes it the safest place to get comfortable with workflow_call, typed inputs and the permissions block.

    Set fail_on_findings: false on your first run so you see the whole report rather than stopping at the first CRITICAL. Then read the results in your repository's Security tab, and turn the gate back on.

  2. Docker build

    Next, because building is still reversible — leave push: false and no image ever leaves the runner. This is where tagging strategy clicks: watch how the same commit produces a branch tag, a SHA tag, and :latest only on the default branch.

    When the build is stable, turn on push for your default branch only, and look at the SBOM and provenance attestation the workflow produced alongside the image.

  3. Terraform

    Now you are touching real infrastructure, so the workflow keeps apply off by default. Run plan-only on pull requests first and read the plan comment it posts back. Learning to read a plan before trusting one is the entire skill.

    Then set an environment with required reviewers before you ever set apply: true, and use OIDC federation rather than pasting cloud keys into repo secrets.

  4. Kubernetes deploy

    Last, because this one changes running systems. It ships with dry_run: true, which validates your manifests against the live API server without applying them — catching schema errors and missing CRDs that a local lint cannot see.

    Flip dry_run to false only against a non-production namespace first, and watch the rollout wait do its job. Then add the compliance workflow to keep your IaC and policies honest as the pipeline grows.

Quick start

Working in about two minutes

Create .github/workflows/platform.yml in your own repository and paste this. No fork, no install, no account — a reusable workflow is referenced by path, not vendored.

.github/workflows/platform.yml (in YOUR repo)
name: Platform CI/CD

on:
  pull_request:
  push:
    branches: [main]

# Deny by default at the top level; each job grants its own scopes.
permissions: {}

jobs:
  security:
    uses: iampopye/devops-workflows/.github/workflows/security/reusable-security-scanning.yml@v1
    permissions:
      contents: read
      security-events: write
      actions: read
    with:
      language: go            # javascript-typescript, python, java-kotlin, ...
      trivy_severity: CRITICAL,HIGH
      zap_target_url: ""       # empty = skip the DAST scan

  docker-build:
    uses: iampopye/devops-workflows/.github/workflows/docker/reusable-docker-build.yml@v1
    permissions:
      contents: read
      packages: write
      id-token: write
    with:
      image_name: ghcr.io/${{ github.repository }}
      platforms: linux/amd64
      # Only publish from the default branch; PR builds stay local.
      push: ${{ github.ref == 'refs/heads/main' }}

Two things to check before your first run. The @v1 at the end of each uses: line is the version you are pinning to — keep it rather than swapping in @main. And set language: to match your codebase, or pass run_codeql: false if CodeQL does not support it.

For the full pipeline — compliance, Terraform plan, and a Kubernetes deploy gated behind needs: [docker-build, security] — copy examples/consume-reusable-workflows.yml and delete the jobs you do not need.

FAQ

Common questions

What is a reusable GitHub Actions workflow?

It is a workflow file whose trigger is on: workflow_call, which means it never runs by itself — it runs only when another workflow calls it with uses:. It declares typed inputs, secrets and outputs, so it behaves like a function you can call from any repository. The practical benefit is that pipeline logic lives in exactly one place: fix a bug once and every repository that references it gets the fix, instead of hunting the same copy-pasted YAML across thirty repos.

Why pin actions to a commit SHA instead of a version tag?

A Git tag is a movable label. Whoever owns the action repository can repoint @v1 at different code at any time, and that code then runs inside your job with your secrets on the next push. A commit SHA is a hash of the commit contents, so it cannot be repointed — different code always means a different SHA. Every third-party action here is pinned to a full 40-character SHA with the human-readable version in a trailing comment, and the repo's own CI fails the build if that rule is broken. First-party actions/* and github/* actions are allowed to use major-version tags, per GitHub's documented guidance.

Do I need an AI API key to use these workflows?

No. The two AI workflows are entirely optional and nothing else in the repository depends on them — the security, Terraform, Docker, Kubernetes and compliance workflows work with no AI involved at all. If you do want them, they are vendor-neutral: there is no default model, so no vendor is assumed. Seven providers have built-in presets, and openai_compatible plus your own api_url points them at Ollama, vLLM, llama.cpp, LM Studio or any self-hosted model, so no third party is involved.

What does permissions: {} actually do?

It sets the automatically-provided GITHUB_TOKEN to have no scopes at all, so nothing in that workflow can read or write anything until you explicitly grant it. Each job then adds only what it needs — contents: read to check out code, packages: write to publish an image, and so on. The grants are per job rather than workflow-wide on purpose: a single top-level grant would hand every job the union of all permissions, which is the situation you were trying to avoid.

Do I have to fork the repository to use it?

No. Reusable workflows are referenced by path, so you add one uses: line to a workflow in your own repository and GitHub fetches it at run time. Fork it only if you want to change the defaults, add jobs, or control your own release tags — which is a reasonable thing to do for an internal platform team. It is MIT licensed either way.

Does the compliance workflow make my system HIPAA or GDPR compliant?

No, and it says so in its own job summary. It checks a technical baseline — infrastructure-as-code misconfigurations via Trivy, plus your own Open Policy Agent rules — that supports some of the controls those frameworks require. That is evidence toward a control set, not an attestation. Mapping the results to specific controls such as the HIPAA Security Rule or GDPR Article 32 depends on your actual architecture, and is work only your organisation can do.