Most HIPAA cloud environments are compliant on paper and improvised in practice. The policy binder says one thing. The console says another. Terraform is how you make them the same document.

A digital health team I worked with had a 40-page security policy and a production environment built by hand over two years. The policy described customer-managed encryption keys, segregated audit logs, and least-privilege access. The environment had a default-key S3 bucket holding exports of a PHI database, an IAM role named admin-temp that three services used, and CloudTrail writing to the same account engineers logged into every day. None of that was malice. It was drift. Two years of small console changes nobody codified, each one reasonable in isolation, that added up to an environment the policy no longer described.

That gap is the most common finding I see, and it is the one Terraform closes. When the infrastructure is code, the control is the code. There is no second document to fall out of sync, because the Terraform that builds the environment is the same artifact that proves how it is built. This post is the reference architecture: how to lay out HIPAA-aligned Terraform so the PHI boundary, encryption, identity, and audit logging are modules an auditor can read, not narratives an engineer has to defend.

The cloud examples here are AWS, because that is where most of this work lands. The pattern ports directly: AWS accounts become GCP projects or Azure subscriptions, AWS KMS becomes Cloud KMS or Key Vault, CloudTrail becomes Cloud Audit Logs or Azure Monitor. The architecture does not change. If you want the higher-level version of this argument, it is the PHI boundary pattern on the HIPAA cloud architecture page.

Section 01Why Terraform is the right tool for HIPAA

HIPAA's Security Rule names safeguards, not services. It does not tell you to use a particular bucket or key. It tells you to control access, encrypt data, and keep an audit trail. The job is to translate those safeguards into infrastructure that demonstrably enforces them. Three properties of Terraform make it the right translation layer.

It is reviewable. Every change to the boundary is a pull request. The diff shows exactly what changed, who approved it, and when. That review trail is itself evidence for § 164.308(a)(8), the evaluation safeguard, and for change management generally.

It is enforceable at plan time. A policy engine can reject a plan before it ever touches the account. A bucket without encryption, a service that is not covered by your cloud BAA, a security group open to the world: all of it can be denied at the gate instead of discovered in the next assessment.

It detects drift. Terraform knows the difference between the state it built and the state that exists. The manual console change that quietly broke a control shows up as a diff on the next plan. Drift stops being an invisible liability and becomes an alert.

Section 02The PHI boundary is the architecture

Before any module is written, you draw the boundary. Map where PHI enters the system, where it is stored, and where it leaves. Every account, network, and service that touches PHI is inside the boundary. Everything else is outside. That single decision drives the entire module layout, because the boundary is what the Terraform exists to protect.

In practice the boundary becomes a dedicated AWS account (or GCP project) that holds nothing but PHI-bearing workloads. Production PHI does not share an account with dev environments, corporate tooling, or the marketing site. The account is the strongest isolation primitive the cloud gives you, and account separation is the cheapest control you will ever buy. Everything below assumes this separation exists.

The rule that saves the most money

Scope is cost. Every system inside the boundary has to be encrypted, logged, access-controlled, and evidenced. Systems outside the boundary do not. The fastest way to cut a HIPAA bill is to push systems that do not need PHI out of the boundary, not to add controls to systems that should never have been in it.

Section 03Repository and module layout

The reference layout is a small set of focused modules composed by a per-environment root. Each module owns one safeguard area. The root wires them together and is the only place environment-specific values live.

hipaa-infrastructure/
├── modules/
│   ├── boundary-account/      # account baseline, guardrails, SCPs
│   ├── kms/                   # customer-managed keys + rotation
│   ├── iam/                   # least-privilege roles, federation
│   ├── audit-logging/         # CloudTrail to a locked log archive
│   ├── network/              # private subnets, endpoints, no IGW for PHI
│   └── data-store/           # encrypted RDS/S3 wired to CMKs
├── environments/
│   ├── prod/                 # the PHI boundary
│   │   ├── main.tf
│   │   ├── backend.tf        # remote state inside the boundary
│   │   └── terraform.tfvars
│   └── staging/
└── policy/
    └── hipaa.rego            # plan-time policy gate (Conftest/OPA)

The discipline that matters: modules are generic, roots are specific. A module never hardcodes an account ID or a CIDR block. It takes them as variables. That keeps the same audited module reusable across environments and keeps every environment-specific decision in one reviewable file per environment.

Section 04Customer-managed keys, with rotation

Default cloud-managed encryption satisfies the letter of § 164.312(a)(2)(iv), but it gives you no key policy, no usage log you control, and no rotation discipline. A HIPAA boundary uses customer-managed keys with an explicit key policy, automatic rotation, and logged usage. The kms module produces one key per data domain.

# modules/kms/main.tf
resource "aws_kms_key" "phi" {
  description             = "CMK for PHI data store: ${var.domain}"
  enable_key_rotation     = true
  rotation_period_in_days = 365
  deletion_window_in_days = 30

  policy = data.aws_iam_policy_document.key_policy.json
}

resource "aws_kms_alias" "phi" {
  name          = "alias/phi-${var.domain}"
  target_key_id = aws_kms_key.phi.key_id
}

# Key policy: only the named roles may use the key. No account-wide grant.
data "aws_iam_policy_document" "key_policy" {
  statement {
    sid       = "KeyAdministration"
    effect    = "Allow"
    actions   = ["kms:*"]
    resources = ["*"]
    principals {
      type        = "AWS"
      identifiers = [var.key_admin_role_arn]
    }
  }
  statement {
    sid       = "KeyUsage"
    effect    = "Allow"
    actions   = ["kms:Encrypt", "kms:Decrypt", "kms:GenerateDataKey"]
    resources = ["*"]
    principals {
      type        = "AWS"
      identifiers = var.key_user_role_arns
    }
  }
}

The control is in the key policy. Splitting administration from usage, and naming exactly which roles may decrypt, is what turns "the data is encrypted" into "we can show you who is able to read PHI and prove the list is short."

Section 05Least-privilege IAM, from a role catalog

§ 164.308(a)(4) and § 164.312(a)(1) are about who can reach PHI and under what identity. The pattern that survives assessment is a small catalog of roles with narrow, explicit policies, assumed through a central identity provider, with no long-lived human credentials inside the boundary.

# modules/iam/phi_reader.tf
# A role scoped to read one PHI data store and nothing else.
resource "aws_iam_role" "phi_reader" {
  name                 = "phi-reader-${var.domain}"
  max_session_duration = 3600
  assume_role_policy   = data.aws_iam_policy_document.federated_trust.json
}

resource "aws_iam_role_policy" "phi_reader" {
  role   = aws_iam_role.phi_reader.id
  policy = data.aws_iam_policy_document.phi_read.json
}

data "aws_iam_policy_document" "phi_read" {
  statement {
    effect    = "Allow"
    actions   = ["s3:GetObject"]
    resources = ["${var.phi_bucket_arn}/*"]
  }
  statement {
    effect    = "Allow"
    actions   = ["kms:Decrypt"]
    resources = [var.phi_kms_key_arn]
  }
}

Two details do the work. The session is time-bounded to one hour, so access is never standing. And the read role can decrypt exactly one key, so an over-broad role cannot quietly become a path to every PHI store in the account. The same pattern that gates deploys in the pipeline gates data access here; the deeper version of the pipeline side is in the HIPAA CI/CD implementation guide.

Section 06Centralized, tamper-evident audit logging

§ 164.312(b) requires audit controls. The failure mode is logs that live in the same account the engineers operate in, where the people being audited can alter the record. The fix is a separate log archive account, an organization trail, and object lock so the record cannot be changed or deleted, even by an account administrator.

# modules/audit-logging/main.tf
# Org-wide trail writing to a locked bucket in the log archive account.
resource "aws_cloudtrail" "org" {
  name                          = "hipaa-org-trail"
  s3_bucket_name                = var.log_archive_bucket
  is_organization_trail         = true
  is_multi_region_trail         = true
  enable_log_file_validation    = true
  kms_key_id                    = var.log_kms_key_arn
  include_global_service_events = true
}

# In the log archive account: WORM storage the engineers cannot edit.
resource "aws_s3_bucket_object_lock_configuration" "archive" {
  bucket = var.log_archive_bucket
  rule {
    default_retention {
      mode = "COMPLIANCE"
      days = 2190   # 6-year retention
    }
  }
}

enable_log_file_validation gives you a cryptographic guarantee that the log was not altered after the fact. Object lock in COMPLIANCE mode means not even the root user of the archive account can delete a record before its retention expires. That is the difference between a log and an audit control.

Section 07The plan-time policy gate

Everything above is enforced by a gate that runs before terraform apply. The gate reads the plan and denies anything that breaks a boundary rule. This is where the BAA-eligibility problem gets solved: the cloud BAA covers specific services, but Terraform will happily provision one that is not covered. A policy that rejects non-eligible services at plan time closes that gap before it becomes a finding.

# policy/hipaa.rego
package hipaa

# Deny any S3 bucket that is not encrypted with a customer-managed key.
deny[msg] {
  resource := input.resource_changes[_]
  resource.type == "aws_s3_bucket"
  not has_cmk_encryption(resource.address)
  msg := sprintf("bucket %s is not encrypted with a CMK", [resource.address])
}

# Deny services not covered by the cloud BAA.
deny[msg] {
  resource := input.resource_changes[_]
  not baa_eligible(resource.type)
  msg := sprintf("%s is not BAA-eligible and cannot enter the PHI boundary", [resource.type])
}

Run it with conftest test plan.json in the pipeline, between plan and apply. A failing policy fails the build. The gate is the same idea as the OPA gate in the pipeline post on GitLab parent/child pipelines: the control runs on every change, and drift toward a non-compliant state becomes impossible to merge.

Before you write the Terraform

Scope the boundary first. The module count follows from it.

The number of controls you have to build, encrypt, and evidence is set by what falls inside your PHI boundary. Our Compliance Scope and Audit-Cost Estimator maps your architecture to a HIPAA baseline and returns your control count, your first-cycle evidence cost, and the moves that pull systems out of scope before you start.

Estimate your scope →

Section 08Remote state belongs inside the boundary

Terraform state is the most sensitive artifact in this whole architecture. It contains resource identifiers, configuration, and sometimes secrets in plaintext. State for the PHI boundary lives inside the boundary: an encrypted bucket in the boundary account, with locking, versioning, and access scoped to the pipeline role that runs applies. It does not live in a shared "ops" account, and it never lands on a laptop.

# environments/prod/backend.tf
terraform {
  backend "s3" {
    bucket         = "hipaa-prod-tfstate"
    key            = "boundary/terraform.tfstate"
    region         = "us-east-1"
    encrypt        = true
    kms_key_id     = "alias/phi-tfstate"
    dynamodb_table = "hipaa-prod-tflock"
  }
}

Section 09Control-to-module mapping

The point of the layout is that an auditor's question maps to a module, and the module is the evidence. This is the crosswalk we hand over with the Terraform.

SafeguardTerraform moduleWhat proves it
§ 164.308(a)(4) Access managementiamRole catalog, scoped policies, federation trust
§ 164.312(a)(1) Access controliam + kmsTime-bounded roles, per-key decrypt scope
§ 164.312(a)(2)(iv) Encryption at restkms + data-storeCMKs, rotation, key policy, encrypted stores
§ 164.312(b) Audit controlsaudit-loggingOrg trail, log validation, object lock
§ 164.312(e)(1) Transmission securitynetworkPrivate subnets, TLS endpoints, no public path
§ 164.308(a)(8) EvaluationpolicyPlan-time gate, pull-request review trail

Section 10Common mistakes to avoid

Section 11Conclusion

A HIPAA environment built this way answers the auditor's questions by showing them code. Where is PHI encrypted? The kms module, here are the keys and the policy. Who can read it? The iam role catalog, here are the four roles and their scopes. How do you know the logs are intact? The audit-logging module, here is the org trail with validation and object lock. The policy binder stops being a separate thing you defend and becomes a description generated from the Terraform that actually runs.

That is the whole move. Make compliance a property of the infrastructure, expressed in code that runs on every change, and the audit becomes a query against your modules instead of a project that eats a quarter.

If you are standing up or remediating a HIPAA environment: Stonebridge runs fixed-fee HIPAA cloud audits and builds that deliver exactly this Terraform, with the control crosswalk, ready for your assessor. Founder-led, and the report holds up under first-party review.

Keep reading: the SOC 2 version of this same module pattern is in SOC 2 controls in Terraform. The pipeline that deploys into this environment is in the HIPAA CI/CD implementation guide. The patterns that fail assessments are in five patterns that fail HIPAA audits.


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 cloud work →