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
- You run an Account Vending Machine / Landing Zone pattern and need new workload accounts (sandbox, dev, staging, prod, security, log-archive) created declaratively rather than through Control Tower’s UI or
CreateAccountscripts. - You want every vended account to land in a specific OU (e.g.
Workloads/Prod) with consistent tags (CostCenter,Environment,Owner) for SCP targeting and cost allocation. - You are migrating from manually-created accounts to IaC and want a single, reviewed code path for new-account requests via pull request.
- Do not use it to import and manage existing accounts you didn’t create with Terraform unless you
importcarefully — and never use it when AWS Control Tower owns account provisioning, because Control Tower’sprovisioned productlifecycle will fight Terraform over the same account.
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 config — live/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 config — live/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
- Always use a unique, plus-addressed root email (
aws+prod-payments@kloudvin.com) backed by a distribution list, never a personal mailbox — AWS rejects duplicate emails and you need the root mailbox to survive staff turnover for password recovery and account closure. - Never let Terraform manage
role_nameoriam_user_access_to_billingdrift — both force a new account. The module’signore_changeskeeps an out-of-band console tweak from triggering a catastrophic recreate; treat any required change to these as a deliberate, communicated replacement. - Keep
close_on_deletion = falseforprod/security/sharedaccounts and remember account closure is heavily rate-limited (only a few per day) and irreversible after 90 days — a closed log-archive account means lost audit history. - Place accounts under a leaf OU, not the org root, so Service Control Policies and tag policies apply by structure; pass
parent_ou_idfrom anaws_organizations_organizational_unitresource rather than a hard-coded id. - Standardise tags for cost allocation and SCP conditions — enforce
CostCenter,Owner, andEnvironmentvia the mergedtagsso Cost Explorer andaws:ResourceTagpolicy conditions work the day the account is born. - Run this module from the management account only, with state in a locked, encrypted backend, and gate applies behind PR review since every apply is a real, billable, hard-to-delete account.