DEV Community

John  Ajera
John Ajera

Posted on

Terraform member accounts for a Control Tower sandbox OU and a custom dev OU

Terraform member accounts for a Control Tower sandbox OU and a custom dev OU

If you already run a landing zone where the sandbox organizational unit (OU) exists under AWS Control Tower, you may still want additional member accounts created and updated with Infrastructure as Code instead of only the console. This guide walks through a small Terraform root module pattern: one account in the existing sandbox OU (by id) and one in a Terraform-managed Development OU, with remote state in S3. The real repository may add optional guards or integrations beyond Organizations account creation; this post focuses on that core path. Continuous delivery (for example GitHub Actions and OIDC) is left for a separate article.


1. Overview

  • Goal: Repeatable AWS Organizations member accounts for sandbox and development, tagged and placed under the right OUs.
  • Where it runs: The management account (or another principal with Organizations APIs and trust to create accounts).
  • State: Encrypted S3 backend with a lock compatible with modern Terraform S3 native state locking.
  • Credentials: Use whatever your team standardizes on for interactive or manual applies (for example an IAM role in the management account via SSO or assume role). The snippets below do not depend on a specific client tool beyond the Terraform CLI.

2. Prerequisites

  • An AWS Organization with permission to create accounts and OUs from the account where you run Terraform.
  • The organizational unit id for the sandbox OU where the first account should live (in a Control Tower setup this OU already exists; you copy its id from the console or CLI).
  • Two unique root user email addresses that are not already used as the root email of any AWS account (plus-addressing on a single mailbox is fine).
  • An S3 bucket for Terraform state (create it out of band or in another stack); the IAM principal you use for terraform apply needs read/write to that bucket and key (and any KMS key policy if you use SSE-KMS).
  • Terraform 1.5+ and the hashicorp/aws provider (5.x in the example below).

Useful background: AWS Organizations User Guide and the Terraform resources aws_organizations_account and aws_organizations_organizational_unit.


3. Architecture

Terraform runs in the management account (from your workstation or any runner with the right credentials). It reads the organization root, creates (or refreshes) a Development OU, creates two member accounts, and persists state in S3.

Flow (ASCII):

  Operator / runner                    Management account              AWS Organizations
  ----------------                     ------------------              -----------------

  Terraform CLI  ------------------>  Terraform run  ------------->  S3 (remote state)
  (plan / apply)                           |
                                           (Organizations API)
                                                |
                                                v
                                         Organization root
                                              |
                      +-----------------------+-----------------------+
                      |                                               |
                Sandbox OU                                     Development OU
              (already exists,                         (created by Terraform)
               e.g. Control Tower)
                      |                                               |
                      v                                               v
              Sandbox member account                         Dev member account
Enter fullscreen mode Exit fullscreen mode

4. Terraform building blocks

4.1 Provider and versions

Pin Terraform and the AWS provider; set the region your team uses for the provider (Organizations APIs are global in behavior, but the provider still needs a region).

provider "aws" {
  region = var.region
}

terraform {
  required_version = ">= 1.5.0"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.98.0"
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

4.2 Data sources

Read the current account and the organization so you can anchor the root id for new OUs.

data "aws_caller_identity" "current" {}

data "aws_organizations_organization" "current" {}
Enter fullscreen mode Exit fullscreen mode

4.3 Locals: sandbox OU id and tags

The sandbox account is placed under an OU that already exists (for example one created by Control Tower). That OU’s id is environment-specific; store it in locals (or a variable) instead of hard-coding in multiple resources.

locals {
  # Example: existing sandbox OU — replace with your OU id from Organizations or Control Tower.
  sandbox_ou_id = "ou-xxxx-xxxxxxxx"

  tags = {
    owner     = "example"
    service   = "core"
    terraform = "true"
  }
}
Enter fullscreen mode Exit fullscreen mode

The Development OU, by contrast, is created by Terraform in the next subsection.

4.3.1 Reusing the Control Tower sandbox OU (tradeoffs)

There is no universal right answer; it is a tradeoff your team agrees on.

When reusing the existing sandbox OU tends to make sense:

  • Alignment with landing zone: Control Tower (or your design) already defined a sandbox OU for experimental workloads; putting Terraform-created members there keeps the same guardrails and enrollment model as other sandbox accounts.
  • Less OU sprawl: You avoid a parallel “sandbox” tree that confuses auditors or operators.
  • Speed: The OU already exists; Terraform only needs a stable id (locals.sandbox_ou_id), not another OU resource.

When a different OU might be a better fit:

  • Isolation: You want a dedicated OU for platform or CI-owned sandboxes so Service Catalog / Account Factory sandboxes stay separate from Terraform-vended ones.
  • Different SCPs: The Control Tower sandbox OU carries service control policies you do not want on these accounts; a separate OU can carry different SCPs.
  • Operational clarity: You prefer one automation path per OU so it is obvious how an account was created.

Neither choice is inherently wrong. The existing sandbox OU is a reasonable default for consistency and shared sandbox policy; another OU is equally valid when separation or policy needs differ. Document the choice in your runbook so future readers know why the id is what it is.

4.4 Development organizational unit

Create an OU under the organization root (first element of roots).

resource "aws_organizations_organizational_unit" "development" {
  name      = "Development"
  parent_id = data.aws_organizations_organization.current.roots[0].id

  tags = merge(local.tags, {
    Name = "Development"
  })
}
Enter fullscreen mode Exit fullscreen mode

4.5 Member accounts

Each member account needs a unique root email, the standard OrganizationAccountAccessRole name (so administrators can assume into the account from the org), a parent OU, and tags. Ignore iam user access to billing if your org policy manages that outside Terraform.

Below, sandbox_1 uses local.sandbox_ou_id; dev_1 uses the Development OU resource id. The lifecycle block here is the minimal pattern for teaching; a production repo might add other lifecycle rules.

resource "aws_organizations_account" "sandbox_1" {
  name      = "example-sandbox-1"
  email     = var.sandbox_account_email
  role_name = "OrganizationAccountAccessRole"
  parent_id = local.sandbox_ou_id

  tags = merge(local.tags, {
    Name        = "example-sandbox-1"
    environment = "sandbox"
  })

  lifecycle {
    ignore_changes = [iam_user_access_to_billing]
  }
}

resource "aws_organizations_account" "dev_1" {
  name      = "example-dev-1"
  email     = var.dev_account_email
  role_name = "OrganizationAccountAccessRole"
  parent_id = aws_organizations_organizational_unit.development.id

  tags = merge(local.tags, {
    Name        = "example-dev-1"
    environment = "dev"
  })

  lifecycle {
    ignore_changes = [iam_user_access_to_billing]
  }
}
Enter fullscreen mode Exit fullscreen mode

4.6 Remote state backend

Point the backend at your bucket, key, and region. use_lockfile enables S3-native locking in supported Terraform versions.

terraform {
  backend "s3" {
    bucket       = "your-tf-state-bucket"
    key          = "account-factory/terraform.tfstate"
    region       = "ap-southeast-2"
    encrypt      = true
    use_lockfile = true
  }
}
Enter fullscreen mode Exit fullscreen mode

4.7 Variables and tfvars

At minimum, pass the two root emails. Optionally override region.

variable "region" {
  description = "AWS region for the provider."
  type        = string
  default     = "ap-southeast-2"
}

variable "sandbox_account_email" {
  description = "Root email address for the sandbox AWS account."
  type        = string
}

variable "dev_account_email" {
  description = "Root email address for the development AWS account."
  type        = string
}
Enter fullscreen mode Exit fullscreen mode

Example terraform.tfvars (keep out of version control if it is sensitive):

sandbox_account_email = "you+sandbox@example.com"
dev_account_email     = "you+dev@example.com"
# region = "ap-southeast-2"
Enter fullscreen mode Exit fullscreen mode

4.8 Outputs

Expose organization metadata, OU ids, and account ids and ARNs for downstream stacks or runbooks.

output "organization_id" {
  value = data.aws_organizations_organization.current.id
}

output "development_ou_id" {
  value = aws_organizations_organizational_unit.development.id
}

output "sandbox_account_id" {
  value = aws_organizations_account.sandbox_1.id
}

output "dev_account_id" {
  value = aws_organizations_account.dev_1.id
}
Enter fullscreen mode Exit fullscreen mode

5. Summary: Copy-paste

From the module root, with credentials for the management account (for example AWS_PROFILE):

export AWS_PROFILE=YourManagementProfile
terraform init
terraform plan
terraform apply
Enter fullscreen mode Exit fullscreen mode

Keep root emails stable; changing an account’s root email later is a manual process. Review terraform plan output before apply, and use your team’s normal change control for production organizations.


6. Troubleshooting

  • EmailAlreadyExists / account creation fails: The root email is already tied to another AWS account. Pick a new unique address (plus-addressing helps).
  • AccessDenied on Organizations: The caller is not in the management account or the principal lacks Organizations permissions for the operations in your plan.
  • S3 backend errors: Bucket name, key, region, or KMS policy mismatch; the principal needs list/get/put on the state prefix and lock object if used.
  • Wrong sandbox OU: The sandbox account lands in the wrong OU when local.sandbox_ou_id is incorrect; re-read it from Organizations or your landing zone console.

7. References

Top comments (0)