Fetching latest headlines…
GitHub Actions for HIPAA-compliant deployments
NORTH AMERICA
🇺🇸 United StatesMay 21, 2026

GitHub Actions for HIPAA-compliant deployments

0 views0 likes0 comments
Originally published byDev.to

OIDC trust scope, self-hosted runner discipline, reusable workflows as the compliance contract.

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 01 — What 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 02 — Three 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 03 — OIDC 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 = {
          "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*"
        }
      }
    }]
  })
}

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
  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
          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 04 — Self-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"
  }

  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"
        }
      }
    }]
  })
}

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 05 — Reusable 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/[email protected]
    permissions:
      id-token: write
      contents: read
      packages: write
    secrets: inherit

  deploy_prod:
    needs: build_and_sign
    uses: hipaa-app-org/compliance-workflows/.github/workflows/[email protected]
    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 06 — Environment 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.
# Terraform: GitHub environment protection for production

resource "github_repository_environment" "production" {
  repository  = "clinical-platform"
  environment = "production"

  reviewers {
    teams = [data.github_team.hipaa_approvers.id]
    # Important: empty users list. Approvers via team only,
    # so adds/removes flow through team membership audit.
  }

  wait_timer = 10

  deployment_branch_policy {
    protected_branches     = false
    custom_branch_policies = true
  }
}

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 07 — The 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.

package deploy.hipaa

import future.keywords.if
import future.keywords.in

default allow := false

allow if {
    scans_passed
    signature_valid
    approver_authorized
    workflow_ref_pinned
}

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
}

signature_valid if {
    input.artifact.cosign_verified == true
    startswith(
        input.artifact.signed_by,
        "https://github.com/hipaa-app-org/compliance-workflows/",
    )
}

approver_authorized if {
    input.approver != input.deploy_author
    input.approver in data.approvers.production
}

workflow_ref_pinned if {
    regex.match(
        `compliance-workflows/.github/workflows/.*\.yml@refs/tags/v\d+\.\d+\.\d+$`,
        input.workflow_ref,
    )
}

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 08 — What 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.

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 09 — Tooling 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 10 — Common 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 11 — Conclusion

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 is the 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. Based in Sacramento, California.

This post originally appeared on Stonebridge Tech Solutions.

Comments (0)

Sign in to join the discussion

Be the first to comment!