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.
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.
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.
| Safeguard | Terraform module | What proves it |
|---|---|---|
| § 164.308(a)(4) Access management | iam | Role catalog, scoped policies, federation trust |
| § 164.312(a)(1) Access control | iam + kms | Time-bounded roles, per-key decrypt scope |
| § 164.312(a)(2)(iv) Encryption at rest | kms + data-store | CMKs, rotation, key policy, encrypted stores |
| § 164.312(b) Audit controls | audit-logging | Org trail, log validation, object lock |
| § 164.312(e)(1) Transmission security | network | Private subnets, TLS endpoints, no public path |
| § 164.308(a)(8) Evaluation | policy | Plan-time gate, pull-request review trail |
Section 10Common mistakes to avoid
- One state file for everything. A single state spanning PHI and non-PHI couples their blast radius. Split state at the boundary; the PHI boundary gets its own backend.
- Account-wide KMS grants. A key policy that allows
kms:Decryptto the whole account means everyone can read PHI. Name the roles. Keep the list short and reviewable. - Logs in the workload account. If the people you audit can edit the audit trail, it is not a control. Separate log archive account, object lock, no exceptions.
- Modules with hardcoded IDs. The moment a module hardcodes an account or CIDR, it stops being reusable and starts being copy-pasted, which is how drift gets reintroduced. Variables in, specifics in the root.
- A policy gate that only runs locally. If the gate is not in the pipeline between plan and apply, it is a suggestion. Wire it into CI so a non-compliant plan cannot merge.
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.