IaC AWS

Terraform Module: AWS Organizations Account — Provisioned, Placed, and Tagged in One Block

Quick take — A reusable Terraform module for aws_organizations_account that vends member accounts into the right OU with an IAM role, baseline tags, and safe lifecycle handling — pin it at v1.0.0 and stop hand-clicking the console. New here? Jump to the Quickstart below to deploy it in minutes; read on for how it works and when to reach for it.

Quickstart (copy-paste)

Minimal, runnable configuration — drop this in a .tf file and fill in the "..." placeholders (each required input is commented):

provider "aws" {
  region = "us-east-1"
}

module "organizations_account" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-organizations-account?ref=v1.0.0"

  account_name = "..."  # Friendly name for the new member account (immutable aft…
  email        = "..."  # Unique root email; not reusable across accounts. Plus-a…
  parent_ou_id = "..."  # Parent OU id (`ou-xxxx-xxxxxxxx`) or org root id (`r-xx…
  environment  = "..."  # Classification for tagging/SCP targeting: `sandbox`, `d…
}

Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.

What this module is

aws_organizations_account is the Terraform resource that creates a brand-new AWS member account inside an existing AWS Organization. It is not a stand-in for an account that already exists — applying it calls the organizations:CreateAccount API, AWS asynchronously provisions a fresh 12-digit account, bootstraps the cross-account OrganizationAccountAccessRole (or whatever role name you pass), and drops the account into a target Organizational Unit. The resource exports the new account ID, its ARN, and the auto-generated email-derived status.

The raw resource has three sharp edges that make it a poor thing to copy-paste across stacks: a created account cannot be deleted by Terraform (a destroy only closes it, and closure is throttled to a handful per day), the role_name and iam_user_access_to_billing arguments force replacement if changed, and the name/email pair is effectively immutable post-creation. A reusable module wraps all of that — it pins the lifecycle to ignore_changes on the volatile fields, enforces a naming/OU convention, and standardises the closure protection so a careless terraform destroy in a feature branch doesn’t try to shut down prod-payments. You vend an account by passing four or five variables; the module owns the foot-guns.

When to use it

Module structure

terraform-module-aws-organizations-account/
├── versions.tf      # provider + Terraform version pins
├── main.tf          # the aws_organizations_account resource + locals
├── variables.tf     # var-driven inputs with validation
└── outputs.tf       # account id / arn / status outputs

versions.tf

terraform {
  required_version = ">= 1.5.0"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }
}

main.tf

locals {
  # Merge mandatory governance tags with caller-supplied tags.
  # Module-owned tags win so cost-allocation stays consistent.
  base_tags = {
    ManagedBy   = "terraform"
    Module      = "terraform-module-aws-organizations-account"
    Environment = var.environment
  }

  tags = merge(var.tags, local.base_tags)
}

resource "aws_organizations_account" "this" {
  name      = var.account_name
  email     = var.email
  parent_id = var.parent_ou_id

  # Name of the IAM role auto-created in the new account for cross-account
  # access from the management account. Changing this FORCES a new account.
  role_name = var.role_name

  # "ALLOW" lets the new account's IAM users see Billing console;
  # "DENY" centralises billing in the management account only.
  iam_user_access_to_billing = var.iam_user_access_to_billing

  # When this resource is destroyed, CLOSE the account instead of leaving
  # it suspended. AWS still enforces the 90-day post-closure window.
  close_on_deletion = var.close_on_deletion

  tags = local.tags

  lifecycle {
    # email and name are effectively immutable after creation; role_name and
    # billing access force replacement (= a NEW account) if drifted. Ignore
    # them so an out-of-band console change never recreates a live account.
    ignore_changes = [
      role_name,
      iam_user_access_to_billing,
    ]

    # Guard rail: never let a plan silently destroy a vended account.
    prevent_destroy = false
  }
}

variables.tf

variable "account_name" {
  description = "Friendly name for the new member account (e.g. 'kv-prod-payments'). Immutable after creation."
  type        = string

  validation {
    condition     = length(var.account_name) > 0 && length(var.account_name) <= 50
    error_message = "account_name must be between 1 and 50 characters."
  }
}

variable "email" {
  description = "Unique root email for the new account. Must not already be used by any AWS account. Use a plus-addressed alias, e.g. aws+prod-payments@kloudvin.com."
  type        = string

  validation {
    condition     = can(regex("^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$", var.email))
    error_message = "email must be a valid email address."
  }
}

variable "parent_ou_id" {
  description = "ID of the parent Organizational Unit (ou-xxxx-xxxxxxxx) or the org root (r-xxxx) the account is placed under."
  type        = string

  validation {
    condition     = can(regex("^(ou-[0-9a-z]{4,32}-[0-9a-z]{8,32}|r-[0-9a-z]{4,32})$", var.parent_ou_id))
    error_message = "parent_ou_id must be a valid OU id (ou-xxxx-xxxxxxxx) or root id (r-xxxx)."
  }
}

variable "role_name" {
  description = "Name of the cross-account IAM role created in the new account. CHANGING THIS RECREATES THE ACCOUNT."
  type        = string
  default     = "OrganizationAccountAccessRole"

  validation {
    condition     = can(regex("^[\\w+=,.@-]{1,64}$", var.role_name))
    error_message = "role_name must be a valid IAM role name (1-64 chars, alphanumeric and +=,.@- )."
  }
}

variable "iam_user_access_to_billing" {
  description = "Whether IAM users in the new account can access the Billing console. ALLOW or DENY."
  type        = string
  default     = "ALLOW"

  validation {
    condition     = contains(["ALLOW", "DENY"], var.iam_user_access_to_billing)
    error_message = "iam_user_access_to_billing must be either ALLOW or DENY."
  }
}

variable "close_on_deletion" {
  description = "If true, terraform destroy CLOSES the account (subject to AWS quotas) instead of leaving it suspended in the org."
  type        = bool
  default     = false
}

variable "environment" {
  description = "Environment classification used for tagging and SCP targeting (e.g. sandbox, dev, staging, prod, security)."
  type        = string

  validation {
    condition     = contains(["sandbox", "dev", "staging", "prod", "security", "shared"], var.environment)
    error_message = "environment must be one of: sandbox, dev, staging, prod, security, shared."
  }
}

variable "tags" {
  description = "Additional tags applied to the account. Merged with module-managed governance tags."
  type        = map(string)
  default     = {}
}

outputs.tf

output "id" {
  description = "The 12-digit AWS account ID of the newly created member account."
  value       = aws_organizations_account.this.id
}

output "arn" {
  description = "The ARN of the new account within the organization."
  value       = aws_organizations_account.this.arn
}

output "name" {
  description = "The friendly name of the account."
  value       = aws_organizations_account.this.name
}

output "email" {
  description = "The root email associated with the account."
  value       = aws_organizations_account.this.email
}

output "status" {
  description = "The account status (ACTIVE or SUSPENDED)."
  value       = aws_organizations_account.this.status
}

output "role_arn" {
  description = "Assumable cross-account role ARN for the management account to switch into this account."
  value       = "arn:aws:iam::${aws_organizations_account.this.id}:role/${var.role_name}"
}

How to use it

module "organizations_account" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-organizations-account?ref=v1.0.0"

  account_name = "kv-prod-payments"
  email        = "aws+prod-payments@kloudvin.com"
  parent_ou_id = aws_organizations_organizational_unit.workloads_prod.id

  role_name                  = "OrganizationAccountAccessRole"
  iam_user_access_to_billing = "DENY"
  close_on_deletion          = false
  environment                = "prod"

  tags = {
    CostCenter = "CC-4821"
    Owner      = "payments-platform"
    Compliance = "pci-dss"
  }
}

# Downstream: assume the vended account's role from the management account
# to bootstrap a provider alias and run further baseline config there.
provider "aws" {
  alias  = "payments"
  region = "ap-south-1"

  assume_role {
    role_arn = module.organizations_account.role_arn
  }
}

# Downstream: feed the new account ID into an SCP attachment / GuardDuty member.
resource "aws_guardduty_member" "payments" {
  account_id  = module.organizations_account.id
  detector_id = aws_guardduty_detector.org.id
  email       = module.organizations_account.email
  invite      = false
}

With Terragrunt

Terragrunt keeps this module DRY across environments — define the backend and provider once in a root config, then a thin terragrunt.hcl per environment supplies only the inputs that differ.

1. Root configlive/terragrunt.hcl (inherited by every module):

remote_state {
  backend = "s3"
  generate = { path = "backend.tf", if_exists = "overwrite" }
  config = {
    # ...s3 state bucket/container + key per path...
  }
}

2. Module configlive/prod/organizations_account/terragrunt.hcl:

include "root" {
  path = find_in_parent_folders()
}

terraform {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-organizations-account?ref=v1.0.0"
}

inputs = {
  account_name = "..."
  email = "..."
  parent_ou_id = "..."
  environment = "..."
}

3. Deploy one environment, or roll out all modules together:

cd live/prod/organizations_account && terragrunt apply        # this module
terragrunt run-all apply                      # every module under live/prod

Why Terragrunt here: the backend and provider live in one place instead of being copy-pasted into every module; inputs is overridden per environment (dev / stage / prod) without forking the module; and run-all orchestrates dependencies across modules. Reach for it once you have more than one environment or more than a handful of modules — for a single stack, the plain Quickstart above is enough.

Inputs

Name Type Default Required Description
account_name string Yes Friendly name for the new member account (immutable after creation).
email string Yes Unique root email; not reusable across accounts. Plus-addressing recommended.
parent_ou_id string Yes Parent OU id (ou-xxxx-xxxxxxxx) or org root id (r-xxxx) for placement.
role_name string "OrganizationAccountAccessRole" No Cross-account IAM role created in the account. Changing it recreates the account.
iam_user_access_to_billing string "ALLOW" No ALLOW or DENY access to the Billing console for the account’s IAM users.
close_on_deletion bool false No Whether terraform destroy closes (vs. suspends) the account.
environment string Yes Classification for tagging/SCP targeting: sandbox, dev, staging, prod, security, shared.
tags map(string) {} No Extra tags merged with module-managed governance tags.

Outputs

Name Description
id The 12-digit AWS account ID of the new member account.
arn The ARN of the account within the organization.
name The friendly name of the account.
email The root email associated with the account.
status Account status (ACTIVE or SUSPENDED).
role_arn Assumable cross-account role ARN for switching the management account into the new account.

Enterprise scenario

A fintech running a multi-account Landing Zone needs a fresh PCI-scoped account every time a new payments product launches. Their platform team exposes this module behind a terraform.tfvars pull request: a product team submits account_name, environment = "prod", and a CostCenter tag, CI runs plan, and after review the merge vends the account directly into the Workloads/Prod OU — automatically inheriting the deny-by-default SCPs and GuardDuty membership wired to its exported id. Because iam_user_access_to_billing = "DENY" and close_on_deletion = false are enforced in the module, no product team can accidentally expose billing or have a stray destroy close a live account.

Best practices

TerraformAWSOrganizations AccountModuleIaC
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments

Keep Reading