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.

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:

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.

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.


About the author

Lucas Jones, Founder

Founder and Principal Platform Engineer at Stonebridge Tech Solutions. Six years building cloud infrastructure and CI/CD pipelines in regulated environments, including HIPAA, FedRAMP, and SOC 2 work for healthcare and defense engineering teams across AWS, GCP, Azure, and OCI.

See how we engage on HIPAA CI/CD work →