Most "GitHub Actions for HIPAA" content reads like generic CI security with HIPAA labels pasted on top. This one is platform-specific.
A healthcare SaaS team I worked with earlier this year had six weeks to make their GitHub Actions pipeline audit-ready. They were a clinical workflow platform on AWS, four squads, roughly 90 active workflow files spread across a primary application repo and three sibling repos for infrastructure, data, and integrations. They had passed SOC 2 Type II the prior year. They had just closed their first hospital system contract and the BAA addendum had landed on engineering with a familiar one-line note from legal: "should be fine."
It wasn't fine. Their GitHub Actions pipeline was clean by SOC 2 standards. By HIPAA standards the auditor could ask three questions that the pipeline couldn't answer in seconds. Which named human approved this production deploy? Which signing key produced the artifact running in the prod cluster? What happens if a critical CVE shows up during a deploy? None of those answers were structurally encoded in the workflows. They were tribal knowledge spread across Slack and a shared Notion page.
Three GitHub-specific decisions separate a HIPAA-aligned GitHub Actions pipeline from a SOC 2 one. The OIDC trust scope. The runner labeling discipline. The reusable workflow boundary as the compliance contract. The rest of this post is each of those, with the AWS code that makes them work, plus the policy gate that ties them together.
If you've read the broader HIPAA CI/CD implementation guide, this is the GitHub Actions-specific deep dive on the same architectural pattern. The parent/child concept ports cleanly; GitHub calls it reusable workflows. The runner isolation pattern ports cleanly; GitHub calls it self-hosted runner labels. The cloud examples here cover AWS specifically because that is where most US healthcare SaaS GitHub Actions deployments live.
Section 01What HIPAA actually needs from a GitHub Actions pipeline
HIPAA's Security Rule doesn't mention GitHub Actions. It also doesn't mention pipelines. It specifies safeguards. A correctly built GitHub Actions pipeline satisfies those safeguards continuously, instead of producing them as quarterly evidence runs before audit windows.
Five controls touch the pipeline most directly. The wording matters; auditors quote the regulation back at you, not the vendor checklist.
- § 164.308(a)(5)(ii)(C) Log-in monitoring. Every deployment is attributable to an authenticated identity with audited access. GitHub Actions translates this to OIDC tokens issued to specific workflow paths, not long-lived secrets in repository settings.
- § 164.308(a)(8) Periodic evaluation. Security evaluations happen on every change. Scanners and policy evaluations run on every push, not on a cron the platform team forgets to maintain.
- § 164.312(b) Audit controls. The pipeline records who deployed what, when, against which approval chain. GitHub's `workflow_run` history is not the audit log; it's a UI on top of one. The HIPAA audit log lives in S3 with Object Lock.
- § 164.312(c)(1) Integrity controls. Artifacts are signed, signatures are verified before deployment, and tampering is structurally detectable. Cosign signing inside a reusable workflow that the application repo cannot override.
- § 164.312(e)(1) Transmission security. Deploys to PHI-bearing environments use mutually authenticated, encrypted channels. No bearer tokens to production, no plain HTTP anywhere on the path.
None of these are exotic. All of them are routinely missed in pipelines built without HIPAA in mind from day one. The framing that helps most: HIPAA tells you what evidence the pipeline must produce. Once you accept that, the architecture follows. The full control mapping at the pillar page covers each Security Rule section against a specific pipeline touchpoint.
Section 02Three GitHub-specific gaps
The architecture maps cleanly across CI platforms. The gaps don't. Three places GitHub Actions makes HIPAA harder than GitLab or Argo CD, and what to do about each.
OIDC trust scope. The default GitHub OIDC subject claim is `repo:org/name:ref:refs/heads/main` or similar. The default sample IAM trust policy that everyone copies trusts the entire org. A misconfigured trust policy gives any workflow in your org a credential path to production. Scope the trust to a specific repo, a specific workflow file, and a specific environment.
Runner labels as the only deploy boundary. Self-hosted runners are addressed by label. There is no platform-enforced separation between a runner labeled `prod` and a runner labeled `prod-staging` if both are registered to the same org. The compliance boundary is the label, plus the runner's IAM trust, plus the workflow's `runs-on:` clause. Get any one of those wrong and a dev pipeline can target a prod runner.
Reusable workflow drift. Reusable workflows (`workflow_call`) are the GitHub-native parent/child pattern. They work well. The drift mode is teams write reusable workflows, then let application repos copy the YAML inline "just this once" because the reusable workflow doesn't yet support some new step. After three of those, the compliance contract has rotted. Lock the reusable workflow into a repo the application teams cannot write to.
Each of these has a fix. The next three sections walk them with code.
Section 03OIDC federation, scoped to one workflow
OIDC federation between GitHub Actions and AWS removes the entire category of long-lived credentials in repository secrets. No more `AWS_ACCESS_KEY_ID` rotated quarterly by a developer who remembers. The runner exchanges its GitHub-issued JWT for an STS session every time the workflow runs. The session is short-lived, attributable, and audit-logged in CloudTrail with the workflow identity intact.
The trust policy is the load-bearing piece. Most sample policies you'll find trust the entire org, which is too broad for HIPAA. The auditor will look at the trust policy and ask "what stops a dev branch from assuming this role?" The honest answer needs to be a `StringEquals` on the subject claim, scoped to one repo, one workflow file, and one environment.
# Terraform: HIPAA-aligned GitHub OIDC trust for AWS
# One OIDC provider per AWS account. Hosted by GitHub; the
# thumbprint changes rarely. Keep this in the security account.
resource "aws_iam_openid_connect_provider" "github" {
url = "https://token.actions.githubusercontent.com"
client_id_list = ["sts.amazonaws.com"]
thumbprint_list = ["6938fd4d98bab03faadb97b34396831e3780aea1"]
}
# Production deploy role. Scoped to ONE repo, ONE workflow file,
# ONE environment. A dev branch cannot assume this role no matter
# what YAML it writes.
resource "aws_iam_role" "hipaa_prod_deploy" {
name = "hipaa-prod-deploy"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Principal = {
Federated = aws_iam_openid_connect_provider.github.arn
}
Action = "sts:AssumeRoleWithWebIdentity"
Condition = {
StringEquals = {
# Audience must match the OIDC token's aud claim
"token.actions.githubusercontent.com:aud" = "sts.amazonaws.com"
# Subject is the load-bearing scope. Pin to:
# - exact repo
# - exact environment (GitHub environment, not branch)
# No wildcards. A wildcard here is the audit finding.
"token.actions.githubusercontent.com:sub" =
"repo:hipaa-app-org/clinical-platform:environment:production"
}
StringLike = {
# Belt and suspenders: explicit job_workflow_ref check
"token.actions.githubusercontent.com:job_workflow_ref" =
"hipaa-app-org/compliance-workflows/.github/workflows/deploy-prod.yml@refs/tags/v*"
}
}
}]
})
}
# Minimum permissions to deploy to one EKS cluster in one region
resource "aws_iam_role_policy" "hipaa_prod_deploy" {
role = aws_iam_role.hipaa_prod_deploy.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = ["eks:DescribeCluster", "eks:ListClusters"]
Resource = "arn:aws:eks:us-east-1:111122223333:cluster/hipaa-prod-*"
}]
})
}
The two pieces that matter. First, the subject claim pins the role to a single repo and a single GitHub environment (which is itself behind required reviewers; we'll get to that). Second, the `job_workflow_ref` condition pins to a specific tagged version of a reusable workflow living in a separate `compliance-workflows` repo. That second condition is what stops a developer from writing inline deploy steps in the application repo and bypassing the reusable workflow.
The workflow side is short. The role assumption happens once per job, with no static credentials anywhere:
# .github/workflows/deploy-prod.yml (excerpt)
# Lives in the compliance-workflows repo, pinned by tag in the
# application repo's caller workflow. Application teams cannot
# modify this file.
name: HIPAA production deploy
on:
workflow_call:
inputs:
image_digest: { type: string, required: true }
target_cluster: { type: string, required: true }
permissions:
id-token: write # required for OIDC token request
contents: read
jobs:
deploy:
runs-on: [self-hosted, hipaa-prod, linux, x64]
environment: production
steps:
- name: Assume scoped prod role
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::111122223333:role/hipaa-prod-deploy
aws-region: us-east-1
# Session name lands in CloudTrail with the workflow run id
role-session-name: gha-${{ github.run_id }}
- name: Verify image signature
run: |
cosign verify \
--certificate-identity-regexp \
"https://github.com/hipaa-app-org/compliance-workflows/.github/workflows/build-sign.yml@.*" \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
${{ inputs.image_digest }}
- name: Deploy by digest, not tag
run: |
aws eks update-kubeconfig --name ${{ inputs.target_cluster }}
kubectl set image deployment/hipaa-app \
hipaa-app=${{ inputs.image_digest }}
The deploy uses the image digest, not the tag, so a race between tag-and-deploy can't substitute a different image. The cosign verification is keyless, using Sigstore's Fulcio identity tied to the building workflow's OIDC subject. The certificate identity regex restricts trusted signers to the `compliance-workflows` repo's build workflow. A signature from any other workflow fails verification.
Section 04Self-hosted runners on hardened infrastructure
Take the strong position here: GitHub-hosted runners are not appropriate for the deploy stage of a HIPAA pipeline. They're acceptable for build and scan, where no production credentials are in play. They are not acceptable for the job that holds the production OIDC role.
The reasoning isn't about GitHub's security posture. It's about evidence shape. A GitHub-hosted runner is shared infrastructure outside your BAA scope. CloudTrail records the AWS API calls. CloudTrail does not record what else ran on that runner in the same job. The host is ephemeral, the logs are GitHub's, the network egress is GitHub's. If an auditor asks "show me that the runner that performed this deploy was within your BAA-covered infrastructure," you have no answer for GitHub-hosted.
For PHI-touching deploys, run on self-hosted runners in your own AWS account, on EKS, with IRSA scoping the runner pods to specific IAM roles. The runner labels become the deploy boundary. The `runs-on:` clause in the workflow becomes the contract that says "this job runs only on a runner labeled hipaa-prod, which only exists in the prod runner pool."
# Terraform: HIPAA prod runner pool on EKS
resource "aws_eks_node_group" "hipaa_prod_runners" {
cluster_name = aws_eks_cluster.hipaa.name
node_group_name = "hipaa-prod-runners"
node_role_arn = aws_iam_role.runner_node.arn
subnet_ids = var.private_subnet_ids
scaling_config {
desired_size = 2
min_size = 2
max_size = 6
}
# Taints keep general workloads off the runner pool
taint {
key = "workload"
value = "hipaa-runner"
effect = "NO_SCHEDULE"
}
labels = {
"stonebridge.io/runner-pool" = "hipaa-prod"
}
# CIS-aligned base AMI, auto-rotated
ami_type = "BOTTLEROCKET_x86_64"
tags = {
Compliance = "HIPAA"
Boundary = "production"
}
}
# IRSA: the runner pod's SA can assume a runner role.
# The runner role can in turn assume the deploy role,
# but only from this specific namespace + SA.
resource "aws_iam_role" "runner_irsa" {
name = "hipaa-prod-runner-irsa"
assume_role_policy = jsonencode({
Statement = [{
Effect = "Allow"
Principal = {
Federated = aws_iam_openid_connect_provider.eks.arn
}
Action = "sts:AssumeRoleWithWebIdentity"
Condition = {
StringEquals = {
"${replace(aws_iam_openid_connect_provider.eks.url, "https://", "")}:sub" =
"system:serviceaccount:gha-runners:hipaa-prod-runner"
}
}
}]
})
}
# Runner pod runs the actions-runner-controller. Pods register
# themselves with the GitHub org under the "hipaa-prod" label.
# Self-hosted runner registration is gated on org policy: only
# repos in the "clinical-platform" team can target this label.
Two things matter. The taint keeps general workloads off the runner pool, so the prod-runner host isn't co-tenanted with dev jobs. The IRSA trust policy scopes the runner pod's IAM to one namespace and one service account. A dev workflow that tries to schedule onto these nodes via a forged `runs-on:` value fails because the dev runner pool has a different label and a different IRSA chain.
The runner registration itself is gated at the GitHub org level: in `Org Settings → Actions → Runner groups`, the `hipaa-prod` runner group is restricted to a specific GitHub team's repos. A repo outside the team cannot target the label even if a developer reads it from a Slack screenshot and tries.
Section 05Reusable workflows as the compliance contract
Reusable workflows are GitHub's native parent/child pattern. The application repo's caller workflow handles build-time decisions; the reusable workflow in `compliance-workflows` handles every compliance-relevant step: signing, scanning, evidence emission, deploy.
The compliance contract is the input shape. The reusable workflow defines exactly which inputs it accepts. The caller workflow provides them. Anything else, including any inline shell that wants to bypass a gate, is structurally invisible to the reusable workflow.
# Caller in the application repo: .github/workflows/release.yml
# Application teams own this file. They cannot bypass the gates;
# the gates live in the reusable workflow they invoke.
name: Release to prod
on:
push:
tags: ['v*.*.*']
jobs:
build_and_sign:
uses: hipaa-app-org/compliance-workflows/.github/workflows/build-sign.yml@v3.2.1
permissions:
id-token: write
contents: read
packages: write
secrets: inherit
deploy_prod:
needs: build_and_sign
uses: hipaa-app-org/compliance-workflows/.github/workflows/deploy-prod.yml@v3.2.1
with:
image_digest: ${{ needs.build_and_sign.outputs.image_digest }}
target_cluster: hipaa-prod-east
permissions:
id-token: write
contents: read
secrets: inherit
The reusable workflow is in a separate repo with branch protection: the compliance team approves every merge, the application teams have no write access. Tagged versions are immutable. The application repo pins by tag (`@v3.2.1`), not by branch (`@main`). Pinning by tag is the audit-grade choice; pinning by branch is how reusable workflows drift out of compliance after they were originally written correctly.
Two organization-level controls hold this together. First, an organization ruleset that requires all production deploys to use workflows from the `compliance-workflows` repo, enforced by the `required_workflows` policy. Second, the OIDC trust policy from Section 03 pins to the `job_workflow_ref` of the compliance-workflows file. Even if a developer copies the workflow inline and removes the `uses:` reference, the role assumption fails.
Section 06Environment protection and required reviewers
GitHub environments are the platform-native approval gate. They're underused outside enterprise accounts, but they're the cleanest way to satisfy § 164.308(a)(1)(ii)(B) sanction enforcement and § 164.308(a)(4) information access management.
Three settings on the `production` environment carry the compliance weight:
- Required reviewers from a separate team. The approver cannot be the deploy author. The HIPAA Security Rule wants a separation of duties; a self-approved deploy fails audit.
- Wait timer of 5 to 15 minutes. A minimum delay between approval and execution gives the platform team a chance to catch a click that shouldn't have happened. For PHI-bearing deploys, the cost of the delay is trivial; the cost of a misclick is not.
- Deployment branch restrictions. Production deploys allowed only from `refs/tags/v*`. A `main` branch push, even one that built successfully, cannot deploy to production without a tag. The tag is a positive action with provenance.
The configuration is managed in Terraform too, so it lives alongside the IAM trust policy and the runner infrastructure as code. The audit trail is git history on the `compliance-workflows` repo.
# Terraform: GitHub environment protection for production
# Provider: integrations/github
resource "github_repository_environment" "production" {
repository = "clinical-platform"
environment = "production"
# Approver must be from a separate team
reviewers {
teams = [data.github_team.hipaa_approvers.id]
# Important: empty users list. Approvers via team only,
# so adds/removes flow through team membership audit.
}
# 10-minute soak time between approval and execution
wait_timer = 10
deployment_branch_policy {
protected_branches = false
custom_branch_policies = true
}
}
# Production deploys only from version tags
resource "github_repository_environment_deployment_policy" "prod_tags" {
repository = "clinical-platform"
environment = github_repository_environment.production.environment
tag_pattern = "v*.*.*"
}
Belt-and-suspenders: the reviewer is a team, not a list of named users. Team membership is itself an audit artifact in GitHub's audit log, with adds and removals attributed to whoever performed them. The quarterly access review walks the team membership history and confirms it matches the current authorized list.
Section 07The policy gate that ties it together
Everything above is GitHub-native. The last piece is platform-agnostic: an OPA policy that evaluates the evidence bundle and returns allow or deny before the deploy job runs.
The policy receives a JSON document containing the signature attestation, the scanner outputs, the approver identity, and the target environment. It returns a boolean. The deploy job's first step is the policy check; if it returns deny, the job exits non-zero and the deploy never happens. The signature, the scans, and the approval all have to be valid for the policy to allow.
# policies/github_deploy.rego
# Policy gate for HIPAA GitHub Actions production deploys.
# Evaluated by the deploy job before any kubectl call.
package deploy.hipaa
import future.keywords.if
import future.keywords.in
default allow := false
# Allow only if all four conditions hold
allow if {
scans_passed
signature_valid
approver_authorized
workflow_ref_pinned
}
# All scanners produced evidence in the last hour and found
# zero critical findings. Stale scans are denied; HIPAA wants
# evaluation on every change, not last week's evaluation.
scans_passed if {
input.scans.container.timestamp_ns > time.now_ns() - 3600 * 1e9
input.scans.sast.timestamp_ns > time.now_ns() - 3600 * 1e9
input.scans.container.critical == 0
input.scans.sast.critical == 0
input.scans.secrets.findings == 0
}
# Cosign verified the image against the expected workflow identity
signature_valid if {
input.artifact.cosign_verified == true
startswith(
input.artifact.signed_by,
"https://github.com/hipaa-app-org/compliance-workflows/",
)
}
# Approver is on the production approver team, not the author
approver_authorized if {
input.approver != input.deploy_author
input.approver in data.approvers.production
}
# The caller workflow pinned the reusable workflow by tag, not branch
workflow_ref_pinned if {
regex.match(
`compliance-workflows/.github/workflows/.*\.yml@refs/tags/v\d+\.\d+\.\d+$`,
input.workflow_ref,
)
}
# Deny messages surface in the run log for the auditor
deny[msg] if {
not workflow_ref_pinned
msg := sprintf(
"workflow_ref %v is not pinned to a semver tag (HIPAA § 164.312(c)(1))",
[input.workflow_ref],
)
}
The policy is small on purpose. Forty lines that a third-party auditor can read in one sitting and a junior engineer can debug at 2am. Each condition is checkable, each is auditable, each fails closed. When the auditor asks "how do you stop a deploy if a scanner finds a critical CVE," the answer is a thirty-line Rego file plus the run log showing the deny message.
Section 08What this looked like at the 90-workflow team
Back to the clinical workflow platform from the lede. Six weeks, four engineers half-time on the engagement, 90 workflows down to the same compliance footprint via three changes.
Week one and two: stand up the `compliance-workflows` repo. Move the production deploy logic out of the application repo into a tagged reusable workflow. Pin the application repo's caller workflow at tag v1.0.0. Confirm OIDC federation works end-to-end against a temporary IAM role with broad permissions.
Week three: tighten the OIDC trust policy. Add the `job_workflow_ref` condition. Rotate the previous broad role out. The first time we tried this we broke a staging deploy because the staging caller workflow was pinned to `@main` instead of a tag; that surfaced the drift mode in real time and reinforced why pinning by tag is the discipline.
Week four: stand up the self-hosted runner pool on EKS. Move the deploy job's `runs-on:` from `ubuntu-latest` to `[self-hosted, hipaa-prod]`. Confirm the runner taints, the IRSA trust, and the org-level runner group restrictions all hold under attempts to deploy from a non-approved repo. (They did. The attempts failed with the right error messages, which is the audit-grade outcome.)
Week five: wire the OPA policy gate into the deploy workflow. Migrate the existing Slack-notification scanners to evidence-emitting scanners that write JSON into an S3 bucket with Object Lock. Add the cosign verification step. Add the environment protection rules in Terraform.
Week six: dry-run the 3PAO assessment internally. The platform lead walked the auditor's expected questions and answered each with a query. The audit cleared on first-party review two weeks later. More importantly, the same pipeline kept passing through the next quarterly internal review without remediation work.
The pattern is repeatable. Most healthcare SaaS teams I work with on GitHub Actions can hit this footprint in 4 to 8 weeks of focused effort, depending on how much existing inline workflow logic has to be migrated into the reusable workflow.
Section 09Tooling recommendations
Opinionated picks for GitHub Actions on HIPAA. Substitutions are fine; the architecture matters more than the tool.
| Stage | Recommended | Acceptable | Avoid |
|---|---|---|---|
| Identity | OIDC federation, scoped to repo + environment | OIDC scoped to repo only | Long-lived access keys in repo secrets |
| Runner (build/scan) | GitHub-hosted (acceptable for non-PHI stages) | Self-hosted on EKS | Self-hosted on a developer laptop |
| Runner (deploy) | Self-hosted on EKS with IRSA, labeled per env | Self-hosted on EC2 with instance profile | GitHub-hosted for PHI-touching deploys |
| Workflow boundary | Reusable workflows in a separate repo, tag-pinned | Reusable workflows in the same repo, tag-pinned | Inline workflow logic, branch-pinned |
| Signing | Cosign keyless, Fulcio identity from compliance repo | Cosign with KMS-backed keys | Docker Content Trust (Notary v1) |
| Policy gate | OPA evaluated in a deploy job step, fails closed | Conftest in a separate job, required check | Slack notification, advisory only |
| Audit logs | S3 Object Lock in compliance mode, 6-year retention | CloudWatch Logs to a logging account, retention locked | GitHub Actions run history alone |
Section 10Common mistakes to avoid
Five quick callouts from the field. Each fails audits more often than it should.
- Org-wide OIDC trust policy. The default sample IAM trust trusts the entire org. Scope to repo + environment + workflow ref. A wildcard in the subject claim is an audit finding.
- GitHub-hosted runners for the deploy job. Acceptable for build and scan. Not acceptable for the job that holds production credentials. Move PHI-touching deploys onto self-hosted runners in your BAA-covered infrastructure.
- Reusable workflows pinned to `@main`. Tag-pin everything that touches production. Branch pinning is how compliance contracts rot over time without anyone noticing.
- GitHub environment with `Required reviewers: anyone with write access`. The approver must be a separate team. Self-approval satisfies nothing.
- CI run history as the audit log. GitHub rotates run history aggressively. The audit log lives in S3 with Object Lock, written by the workflow at the time the event occurs.
For longer-form versions of these failure modes against GitLab as well, the broader writeup on five patterns that fail HIPAA audits walks each.
Section 11Conclusion
The team that ships well on GitHub Actions in regulated environments isn't the one with the most YAML. It's the one whose workflows make compliance violations structurally difficult.
OIDC federation scoped to a single workflow file removes the credential-rotation problem and pins the trust to a verifiable subject. Self-hosted runners on hardened EKS infrastructure keep the deploy job inside your BAA scope and make the audit story crisp. Reusable workflows in a separate repo, tag-pinned and protected, are the compliance contract that application teams cannot bypass. An OPA policy gate evaluating evidence at the moment of deploy turns checklists into enforcement.
Build the GitHub Actions architecture this way and the platform's primitives carry the weight of the Security Rule. Build it the other way and you spend the next audit cycle rebuilding what you should have built once.
If you're working through this on a healthcare SaaS team: Stonebridge runs two-week HIPAA CI/CD audits that map your existing GitHub Actions setup against the Security Rule and produce a written remediation roadmap. Fixed fee, founder-led, the report holds up under first-party review by your auditor.
Keep reading: the broader architecture pattern across CI platforms is in the HIPAA CI/CD implementation guide. The pre-audit walkthrough of every Security Rule control is in the HIPAA CI/CD audit checklist. Where this pattern diverges for teams coming from SOC 2 is mapped in HIPAA CI/CD vs SOC 2 CI/CD.