DEV Community

Cover image for Introducing the Three AWS Governance Pillars with Terraform
Lucas de Camargo
Lucas de Camargo

Posted on

Introducing the Three AWS Governance Pillars with Terraform

I've seen many excellent tech enthusiasts come up with great ideas to start a service, an app, or even an entire business. Most are familiar with what AWS offers, but few understand how to establish proper governance in their new organization, no matter how small. This gap often leads to painful consequences down the road — infrastructure that's difficult to audit, security vulnerabilities that grow with each new resource, and technical debt that becomes exponentially harder to fix as your startup scales.

Although some prefer to use backend as a service tools such as Supabase for hosting their website and building their user base, these tools not only come with high costs, but they're limited frameworks when compared to the 200+ global, on-demand services offered by AWS. More importantly, they abstract away the infrastructure knowledge that becomes critical as you grow.

On the other hand, starting up with AWS may feel overwhelming at first glance, not to mention that management and governance may get out of control when creating resources directly in the AWS Console. Without proper structure from day one, you'll find yourself manually clicking through the console, creating resources inconsistently across environments, with no clear audit trail of who changed what or when. I've seen startups spend weeks trying to reverse-engineer their own infrastructure just to understand what they've built.

The good news? Once you learn how to get started on AWS the right way—with Infrastructure as Code and proper governance from the beginning—it becomes intuitive to scale at very low prices, like $0.50 per month for hosting a website. More importantly, you'll have a reproducible, auditable, and secure foundation aligned with the AWS Well-Architected Framework. Every new project, environment, or team member can leverage the same proven patterns, eliminating weeks of setup time and preventing costly security mistakes before they happen.

This series will show you exactly how to build that foundation, starting with the governance primitives that AWS itself recommends: Organizations, Accounts, and Policies. Whether you're a solo founder or building with a small team, these patterns will save you countless hours and headaches as you grow.

The Three Governance Pillars

To get hands-on with your new infrastructure skills, let me introduce three AWS resources that are going to be the ground basis of our business: Organizations, Accounts, and Policies.

AWS Organization

AWS Organizations and Organization Units

Like the control tower for your entire cloud infrastructure, an Organization provides centralized management for multiple AWS accounts, allowing you to structure them hierarchically like a family tree. At the very top sits the Organization Root - this is the parent container for everything in your AWS account. Below the root, you create Organizational Units (OUs), which are logical groupings that help you organize accounts by function, environment, or business unit. For example, you might have separate OUs for "Production," "Development," and "Security." The key difference is that the Organization Root is singular and encompasses everything, while OUs are the building blocks you create to segment and organize your accounts.

Organizations work hand-in-hand with Accounts (which live inside OUs) and Policies (which can be attached at any level of the hierarchy to enforce governance rules).

AWS Organization Accounts

Individual AWS accounts are the fundamental security and resource boundaries in AWS. Each account acts as an isolated container for resources, with its own billing, access controls, and resource limits. Working with multiple accounts isn't just a nice-to-have - it's a critical best practice because it provides natural blast radius containment (if something goes wrong in one account, others remain unaffected), simplified compliance boundaries, and clearer cost allocation.

Within an Organization, accounts nest inside units and inherit any policies applied to their parent OU or the organization root, creating a powerful governance cascade from top to bottom.

Service Control Policies (SCPs)

Organization policies are your guardrails - they define the maximum permissions that any entity within an account can have, regardless of what their IAM permissions say. Think of them as a safety net that prevents accidental or intentional actions that could harm your organization. Common examples include preventing accounts from leaving the organization, restricting which AWS regions can be used, or blocking the deletion of critical resources like CloudTrail logs.

SCPs attach to the Organization Root, OUs, or individual accounts, and they flow downward through the hierarchy - a policy attached to an OU automatically applies to all accounts within that OU and any nested OUs below it.

The example above illustrates a simple but mature organizational structure. AWS Organizations is designed to evolve with your startup - you'll typically begin with just two or three accounts addressing immediate needs, then add new organizational units and accounts as real requirements emerge from your business operations. This iterative approach isn't a compromise; it's the recommended path because it ensures every piece of your infrastructure exists for a concrete purpose rather than speculative needs. Since you can freely reorganize accounts and OUs later, there's no penalty for starting simple.

Terraform Modules

In this section, we'll create reusable Terraform modules for our governance resources. These modules will live in a GitHub repository called terraform-aws-governance and will be designed to work together as building blocks for your AWS foundation.

Choosing the Right Granularity

When structuring Infrastructure as Code (IaC), there are three common approaches, each with trade-offs:

  1. Monolithic: One large Terraform configuration managing everything. Quick to start but becomes cubersome as you grow and risky to change.

  2. One-repo-per-module: Each module in its own repository. Maximum isolation but harder to maintain consistency and versioning across related modules.

  3. Grouped modules: Related modules in a single repository (our choice). This balances reusability with maintainability - our terraform-aws-governance repo will contain modules for organizations, organizational units, accounts, and eventually billing and audit configurations.

We're choosing the grouped approach because governance resources are inherently related and often change together. This structure also works excellently with Terragrunt, which we'll use later to orchestrate these modules into a complete infrastructure stack while maintaining clear separation of concerns.

Starting with the essentials, we'll create three foundational modules:

  • organization: Creates the AWS Organization with baseline security policies
  • organizational-unit: Creates OUs with customizable policies
  • account: Provisions new AWS accounts within specified OUs

Each module is designed to be stateless and idempotent, meaning you can safely run them multiple times without side effects. The folder structure is defined as below:

terraform-aws-governance/
├── organization/
│   ├── main.tf
│   ├── variables.tf
│   └── outputs.tf
├── organization-unit/
│   ├── main.tf
│   ├── variables.tf
│   └── outputs.tf
└── account/
    ├── main.tf
    ├── variables.tf
    └── outputs.tf
Enter fullscreen mode Exit fullscreen mode

The full implementation of this blog post is available in my GitHub repository.

GitHub logo lucasdecamargo / terraform-aws-governance

Terraform modules for AWS governance services, like Organizations, Identity and Budget.

Terraform AWS Governance Modules

A collection of Terraform modules that codify the governance pillars featured in the blog post IaC Startup on AWS: Governance Pillars.1

The modules help you bootstrap an AWS Organization with opinionated Service Control Policies (SCPs), create Organizational Units (OUs), and provision member accounts with consistent guardrails. They are designed to be composed together or orchestrated with tools such as Terragrunt.

Module Overview

  • organization: Creates the AWS Organization, enables key services, and attaches baseline SCPs that lock operations to approved regions, protect logging, and keep IAM Identity Center centralized.
  • organization-unit: Provisions an OU under any parent and allows you to attach existing SCPs or create a custom policy for that branch of the hierarchy.
  • account: Automates new AWS member accounts with optional billing console access, inherited SCP attachments, and per-account guardrails.

organization

  • Enables IAM and IAM Identity Center integration, sets the feature…




Let's begin with the organization module, which establishes our governance foundation.

Organization

This module creates the foundation of your AWS governance structure. Let's start with the core resource - the AWS Organization itself:

resource "aws_organizations_organization" "this" {
  aws_service_access_principals = [
    # Allows IAM roles to work across accounts
    "iam.amazonaws.com",
    # Enables centralized user management
    "sso.amazonaws.com"
  ]

  # Enables all organization features
  feature_set = "ALL"

  enabled_policy_types = [
    # Primary governance tool (SCPs)
    "SERVICE_CONTROL_POLICY"
  ]
}
Enter fullscreen mode Exit fullscreen mode

This resource can only be created once, from what will become your management account. The feature_set = "ALL" is crucial - it enables Service Control Policies, which are your primary governance tool. Without this, you'd only get consolidated billing.

Service Control Policies (SCPs) are preventive controls that define the maximum permissions for your organization. Even if someone has full administrator access in an account, they can't perform actions that SCPs deny. Let's implement three essential policies that every startup should have from day one.

SCP 1: Restricting AWS Regions

Many startups need to restrict where data can be stored for compliance or cost reasons. This policy ensures resources can only be created in your approved regions:

variable "aws_allowed_regions" {
  description = "The AWS regions allowed by the organization."
  type        = list(string)
  default     = ["us-east-1", "us-east-2"]
}

resource "aws_organizations_policy" "deny_non_allowed_regions" {
  name = "DenyNonAllowedRegions"
  type = "SERVICE_CONTROL_POLICY"

  # Ensure the policy is created after the organization is created
  depends_on = [aws_organizations_organization.this]

  content = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Sid      = "DenyAllOutsideAllowedRegions"
      Effect   = "Deny"
      Action   = "*"
      Resource = "*"
      Condition = {
        StringNotEquals = {
          "aws:RequestedRegion" = var.aws_allowed_regions
        }
      }
    }]
  })
}

resource "aws_organizations_policy_attachment" "deny_non_allowed_regions" {
  policy_id = aws_organizations_policy.deny_non_allowed_regions.id
  target_id = aws_organizations_organization.this.roots[0].id
}
Enter fullscreen mode Exit fullscreen mode

Notice how we use a variable for aws_allowed_regions. This makes it easy to adjust as your compliance requirements evolve. The region us-east-1 is always needed for AWS global resources that are hosted in the North Virginia region. If you are in western Europe, you'd use ["us-east-1", "eu-west-1"].

In this first deployment, we need to tell Terraform that the policies we are creating depend on the organization resource with the argument depends_on, otherwise it will try to create the policies before the organization completes, which causes an error.

The roots[0].id refers to the organization's root - the top of your organizational hierarchy. Policies attached here cascade down to every OU and account below.

SCP 2: Centralized Authentication

This policy prevents individual accounts from creating their own IAM Identity Center instances (Accounts), ensuring authentication stays centralized:

resource "aws_organizations_policy" "deny_member_account_sso" {
  name = "DenyMemberAccountSSO"
  type = "SERVICE_CONTROL_POLICY"

  # Ensure the policy is created after the organization is created
  depends_on = [aws_organizations_organization.this]

  content = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect = "Deny"
      Action = [
        "sso:CreateInstance",
        "sso:DeleteInstance"
      ]
      Resource = "*"
    }]
  })
}

resource "aws_organizations_policy_attachment" "deny_member_account_sso" {
  policy_id = aws_organizations_policy.deny_member_account_sso.id
  target_id = aws_organizations_organization.this.roots[0].id
}
Enter fullscreen mode Exit fullscreen mode

Without this policy, any account could set up its own identity provider, leading to a fragmented authentication landscape that's impossible to audit or secure properly.

SCP 3: Protecting Critical Resources

This policy prevents accidental or malicious deletion of your audit trail:

resource "aws_organizations_policy" "protect_organization_resources" {
  name        = "ProtectOrganizationResources"
  type        = "SERVICE_CONTROL_POLICY"

  # Ensure the policy is created after the organization is created
  depends_on = [aws_organizations_organization.this]

  content = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Sid    = "ProtectCloudTrail"
        Effect = "Deny"
        Action = [
          "cloudtrail:DeleteTrail",
          "cloudtrail:StopLogging"
        ]
        Resource = "*"
      },
      {
        Sid    = "PreventLeavingOrganization"
        Effect = "Deny"
        Action = [
          "organizations:LeaveOrganization"
        ]
        Resource = "*"
      }
    ]
  })
}

resource "aws_organizations_policy_attachment" "protect_organization_resources" {
  policy_id = aws_organizations_policy.protect_organization_resources.id
  target_id = aws_organizations_organization.this.roots[0].id
}
Enter fullscreen mode Exit fullscreen mode

These protections ensure that even if credentials are compromised, attackers can't cover their tracks by deleting logs or remove accounts from your organization's oversight.

Module Outputs

Finally, we export key information that other modules will need:

output "organization_root_id" {
  value       = aws_organizations_organization.this.roots[0].id
  description = "The ID of the root of the AWS Organization"
}

output "scp_policy_ids" {
  value = {
    deny_non_allowed_regions       = aws_organizations_policy.deny_non_allowed_regions.id
    protect_organization_resources = aws_organizations_policy.protect_organization_resources.id
    deny_member_account_sso        = aws_organizations_policy.deny_member_account_sso.id
  }
  description = "Map of Service Control Policy IDs"
}
Enter fullscreen mode Exit fullscreen mode

These outputs become the connection points for your next modules. The organization_root_id is where you'll attach OUs, and the scp_policy_ids map lets other modules reference these policies without hardcoding IDs.

Remember that this module must be run from your management account and can only be applied once. In practice, you'll run this before creating any other infrastructure. The account running this Terraform code becomes the permanent management account for your organization - choose wisely, as this can't be changed later without recreating the entire organization.

Organization Unit

Now that we have our Organization foundation, we need a way to create logical groupings for our accounts. Organizational Units (OUs) are containers that help you organize accounts based on their function, compliance requirements, or operational needs.

The core of our OU module is surprisingly simple - creating an OU requires just a name and a parent:

variable "name" {
  description = "Name of the Organizational Unit"
  type        = string

  validation {
    condition     = length(var.name) <= 128
    error_message = "OU name must be 128 characters or less"
  }
}

variable "parent_id" {
  description = "ID of the parent OU or Organization Root"
  type        = string
}

resource "aws_organizations_organizational_unit" "this" {
  name      = var.name
  parent_id = var.parent_id
}
Enter fullscreen mode Exit fullscreen mode

The parent_id is crucial here - it determines where in your organization hierarchy this OU sits. This could be the organization root (for top-level OUs like "Production" or "Development") or another OU (for nested structures like "Production/EU" or "Production/US").

What makes OUs powerful is their ability to have their own policies while inheriting from their parents. Our module supports two approaches to policy management.

Often, you'll want to reuse policies across multiple OUs. For example, you might have a standard "DevelopmentRestrictions" policy that applies to all development OUs:

variable "attach_policy_ids" {
  description = "List of existing SCP policy IDs to attach to this OU"
  type        = list(string)
  default     = []
}

resource "aws_organizations_policy_attachment" "existing" {
  for_each = toset(var.attach_policy_ids)

  policy_id = each.value
  target_id = aws_organizations_organizational_unit.this.id
}
Enter fullscreen mode Exit fullscreen mode

Using for_each here allows you to attach multiple policies in a single module call. The toset() function ensures Terraform treats the policy IDs as unique identifiers rather than an ordered list, which prevents unnecessary recreations if the order changes.

Creating OU-Specific Policies

Sometimes an OU needs its own specific restrictions. Our module allows creating a custom policy alongside the OU:

variable "create_policy" {
  description = "Optional custom SCP policy to create and attach to this OU"
  type = object({
    name        = string
    description = string
    content     = string
  })
  default = null
}

resource "aws_organizations_policy" "custom" {
  count = var.create_policy != null ? 1 : 0

  name        = var.create_policy.name
  description = var.create_policy.description
  type        = "SERVICE_CONTROL_POLICY"
  content     = var.create_policy.content
}
Enter fullscreen mode Exit fullscreen mode

The create_policy variable groups related attributes together and makes the module interface cleaner. When calling the module, you either provide all policy details or nothing - preventing partial configurations that could cause errors.

The count parameter makes this optional - the policy is only created if you provide the create_policy variable. This pattern keeps the module flexible without requiring complex policy definitions for simple OUs.

Module Outputs

The outputs are designed to support module composition - using this module's outputs as inputs to others:

output "id" {
  value       = aws_organizations_organizational_unit.this.id
  description = "The ID of the Organizational Unit"
}

output "arn" {
  value       = aws_organizations_organizational_unit.this.arn
  description = "The ARN of the Organizational Unit"
}

output "name" {
  value       = aws_organizations_organizational_unit.this.name
  description = "The name of the Organizational Unit"
}

output "parent_id" {
  value       = aws_organizations_organizational_unit.this.parent_id
  description = "The ID of the parent OU or Organization Root"
}

output "custom_policy_id" {
  value       = var.create_policy != null ? aws_organizations_policy.custom[0].id : null
  description = "The ID of the custom policy created for this OU (if any)"
}
Enter fullscreen mode Exit fullscreen mode

The id output becomes the parent_id input when creating nested OUs or the parent_id when creating accounts. The custom_policy_id makes it possible to re-use the same custom policy in other resources.

Account

With our organization and OUs in place, the next step is to create individual AWS accounts that will live under this governance structure.
Each account represents an isolated environment — for example, development, staging, or production. The goal of this module is to make account creation repeatable, compliant, and safe.

Creating accounts programmatically through AWS Organizations can be tricky: accounts are immutable in many ways and must always have a unique email address. Having a module for Accounts allow us to wrap this process with sensible defaults and governance best practices.

The main resource for provisioning new accounts is aws_organizations_account. This resource instructs AWS Organizations to create a new member account within your organization hierarchy:

variable "name" {
  description = "Name for the account to be created"
}

variable "email" {
  description = "Email address for this account, needs to be unique"
}

variable "billing_access" {
  description = "Whether the account should have access to the AWS Billing Console"
  type        = bool
  default     = false
}

resource "aws_organizations_account" "account" {
  name                       = var.name
  email                      = var.email
  iam_user_access_to_billing = var.billing_access ? "ALLOW" : "DENY"

  lifecycle {
    # Ignore changes to prevent the account from being deleted and recreated
    ignore_changes = [name, email]
  }
}
Enter fullscreen mode Exit fullscreen mode

The parameter iam_user_access_to_billing controls whether IAM users can access the Billing Console in that account. By default, it’s disabled (DENY), which aligns with AWS’s security best practices.

Once an AWS account is created, its name and email can’t be modified via Terraform — trying to do so would trigger a full recreation of the account (which is not allowed). Ignoring these attributes with ignore_changes prevents unnecessary drift and protects against accidental destruction of live accounts.

Just like OUs, accounts inherit policies from their parents but can also have additional Service Control Policies (SCPs) applied directly. You can pass one or more existing policy IDs — for example, those defined at the organization or OU level — and they’ll automatically attach to this account:

variable "attach_policy_ids" {
  description = "List of existing SCP policy IDs to attach to this account"
  type        = list(string)
  default     = []
}

resource "aws_organizations_policy_attachment" "existing" {
  for_each = toset(var.attach_policy_ids)

  policy_id = each.value
  target_id = aws_organizations_account.account.id
}
Enter fullscreen mode Exit fullscreen mode

The same way, if a specific account requires a unique restriction — for instance, limiting IAM role creation or disabling certain AWS services — you can create and attach a custom SCP directly:

variable "create_policy" {
  description = "Optional custom SCP policy to create and attach to this account"
  type = object({
    name        = string
    description = string
    content     = string
  })
  default = null
}

resource "aws_organizations_policy" "custom" {
  count = var.create_policy != null ? 1 : 0

  name        = var.create_policy.name
  description = var.create_policy.description
  type        = "SERVICE_CONTROL_POLICY"
  content     = var.create_policy.content
}

resource "aws_organizations_policy_attachment" "custom" {
  count = var.create_policy != null ? 1 : 0

  policy_id = aws_organizations_policy.custom[0].id
  target_id = aws_organizations_account.account.id
}
Enter fullscreen mode Exit fullscreen mode

The count condition makes this optional — the policy and its attachment are only created when you pass a create_policy object. This pattern gives the module flexibility while keeping the configuration concise.

Module Outputs

The module exposes a few key outputs to integrate with the rest of your infrastructure:

output "account_id" {
  value       = aws_organizations_account.account.id
  description = "ID of the account created"
}

output "email" {
  value       = var.email
  description = "Email of account"
}

output "custom_policy_id" {
  value       = var.create_policy != null ? aws_organizations_policy.custom[0].id : null
  description = "The ID of the custom policy created for this account (if any)"
}

output "all_attached_policy_ids" {
  value = concat(
    var.attach_policy_ids,
    var.create_policy != null ? [aws_organizations_policy.custom[0].id] : []
  )
  description = "List of all policy IDs attached to this account"
}
Enter fullscreen mode Exit fullscreen mode

These outputs allow other modules — such as those that handle IAM Identity Center assignments, logging, or baseline configurations — to reference the account and its governance settings programmatically.

What's Next?

We've built the foundational Terraform modules for AWS governance — Organizations, Organizational Units, and Accounts. Now we need to deploy them.

Follow me to get updates about my series IaC Startup on AWS. I'm writing new posts about configuring AWS named profiles, and deploying governance infrastructure with Terragrunt and Terraform.

Also, make sure to check my GitHub profile for new projects and updates.

Buy me a coffee if you like this post! :)

Buy Me A Coffee

Top comments (0)