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.
.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 YAMLexamples/# 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.
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 codesecurity-events: write # upload results to the Security tabactions: read # CodeQL reads workflow metadatadast-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.
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.
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.
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.
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.
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 scandocker-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.