Part 2: AWS Foundation — Organizations, SSO, and Account Setup
Part of the series: Building a Production-Grade DevSecOps Pipeline on AWS
Introduction
The foundation of any serious AWS deployment is a well-structured multi-account setup. Running everything in one AWS account is the equivalent of putting all your files on a single server with no access controls — the blast radius of any mistake or breach is your entire infrastructure.
In this part we set up AWS Organizations with four accounts, configure AWS IAM Identity Center (SSO) for human access, and establish the IAM trust relationships that allow GitHub Actions to deploy to our clusters without static credentials.
Why Multi-Account?
┌─────────────────────────────────────────────────────────────────────┐
│ SINGLE ACCOUNT (anti-pattern) │
│ │
│ Dev workloads ─────────────────────────────┐ │
│ Staging workloads ─────────────────────────┤── Same IAM boundary │
│ Production workloads ──────────────────────┘ Same VPC space │
│ Same billing │
│ Risk: dev engineer accidentally deletes production RDS │
│ Risk: security incident in dev reaches production secrets │
└─────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────┐
│ MULTI-ACCOUNT (this guide) │
│ │
│ Dev Account: strong IAM isolation, cheap instance sizes │
│ Staging Account: production-like, but no real data │
│ Production Account: SCPs block destructive operations │
│ Management Account: no workloads, only ECR + SSO + billing │
│ │
│ Benefit: IAM permissions are account-scoped │
│ Benefit: Service Control Policies (SCPs) protect production │
│ Benefit: Separate billing per environment │
│ Benefit: VPC IP space per account (no CIDR conflicts) │
└─────────────────────────────────────────────────────────────────────┘
Step 1: Create AWS Organizations
Log in to your root AWS account (the one you used to sign up for AWS).
AWS Console → AWS Organizations → Create Organization
AWS Organizations gives you a single management pane for all accounts, consolidated billing, and — crucially — Service Control Policies (SCPs) that act as guardrails even for account root users.
After creating the organization, note your Organization ID (format: o-xxxxxxxxxx).
Step 2: Create Member Accounts
Navigate to AWS Organizations → AWS Accounts → Add an AWS Account.
Create three accounts:
| Account Name | Purpose | Example ID |
|---|---|---|
myapp-dev |
Development environment | 557702566877 |
myapp-staging |
Staging environment | (your value) |
myapp-production |
Production environment | 591120834781 |
Important: Use
+email aliases to reuse your existing email. If your email isyou@gmail.com, useyou+aws-dev@gmail.com,you+aws-staging@gmail.com, etc. Gmail (and most providers) deliver these to the same inbox.Tip: Record each account ID immediately. You will reference them throughout this series.
SCREENSHOT: AWS Organizations showing all 4 accounts in their OUs
![]()
Step 3: Organize Accounts into OUs
Organizational Units (OUs) let you apply different SCPs to groups of accounts.
AWS Console → AWS Organizations → AWS Accounts → Root
Create OUs:
Root
├── Management (leave root account here)
├── Workloads
│ ├── Dev (move myapp-dev here)
│ ├── Staging (move myapp-staging here)
│ └── Production (move myapp-production here)
Step 4: Apply Service Control Policies
SCPs are JSON IAM policies attached to OUs. They define the maximum permissions any principal in that OU can ever have — even the account root user cannot exceed them.
Apply this SCP to the Production OU to prevent accidental deletion of critical resources:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "DenyDangerousEKSOperations",
"Effect": "Deny",
"Action": [
"eks:DeleteCluster",
"eks:DeleteNodegroup"
],
"Resource": "*",
"Condition": {
"StringNotEquals": {
"aws:PrincipalTag/AllowDestructive": "true"
}
}
},
{
"Sid": "DenyRDSDelete",
"Effect": "Deny",
"Action": [
"rds:DeleteDBInstance",
"rds:DeleteDBCluster"
],
"Resource": "*"
},
{
"Sid": "RequireRegions",
"Effect": "Deny",
"NotAction": [
"iam:*",
"sts:*",
"route53:*",
"cloudfront:*",
"waf:*",
"acm:*",
"support:*",
"health:*"
],
"Resource": "*",
"Condition": {
"StringNotEquals": {
"aws:RequestedRegion": [
"us-east-1",
"us-west-2"
]
}
}
}
]
}
The region restriction SCP ensures no resources are accidentally created outside your approved regions. IAM, STS, Route53, and ACM are excluded because they are global services.
Step 5: Configure AWS IAM Identity Center (SSO)
AWS IAM Identity Center (formerly AWS SSO) lets your team log in with a single set of credentials across all accounts. It is far superior to creating IAM users in every account.
AWS Console → IAM Identity Center → Enable
Steps:
1. Choose identity source: "Identity Center directory" (built-in, no external IdP needed)
2. Create users for each team member
3. Create Permission Sets (these become IAM roles in each account)
4. Assign users to accounts with appropriate Permission Sets
Create two Permission Sets:
AdministratorAccess (for platform engineers):
Name: AdministratorAccess
Session duration: 8 hours
Managed policy: AdministratorAccess
ReadOnlyAccess (for developers / auditors):
Name: ReadOnlyAccess
Session duration: 8 hours
Managed policy: ReadOnlyAccess
Assign to accounts:
myapp-dev: you → AdministratorAccess
myapp-staging: you → AdministratorAccess
myapp-production: you → AdministratorAccess
Management: you → AdministratorAccess
Configure the AWS CLI for SSO:
# Run on your local machine
aws configure sso
# When prompted:
SSO session name: admin
SSO start URL: https://your-id.awsapps.com/start
SSO region: us-east-1
SSO registration scopes: sso:account:access
# After login, name each profile:
# Profile for dev/us-east-1: myapp-dev-use1
# Profile for dev/us-west-2: myapp-dev-usw2
# etc.
Your ~/.aws/config will look like:
[sso-session admin]
sso_start_url = https://your-id.awsapps.com/start
sso_region = us-east-1
sso_registration_scopes = sso:account:access
[profile myapp-prod-use1]
sso_session = admin
sso_account_id = 591120834781
sso_role_name = AdministratorAccess
region = us-east-1
output = json
[profile myapp-prod-usw2]
sso_session = admin
sso_account_id = 591120834781
sso_role_name = AdministratorAccess
region = us-west-2
output = json
[profile myapp-dev-use1]
sso_session = admin
sso_account_id = 557702566877
sso_role_name = AdministratorAccess
region = us-east-1
output = json
Authenticate:
aws sso login --sso-session admin
# Opens browser → log in → token saved locally for 8 hours
# Test:
aws sts get-caller-identity --profile myapp-prod-use1
SCREENSHOT: IAM Identity Center showing SSO portal with all accounts and permission sets
Step 6: GitHub OIDC — No Static AWS Keys in CI
This is one of the most important security decisions in the entire pipeline. Traditional CI/CD stores AWS access keys as GitHub Secrets. Those keys:
- Never expire automatically
- Are as powerful as the IAM user they belong to
- Can be exfiltrated from logs if misconfigured
OIDC (OpenID Connect) eliminates static keys. GitHub Actions generates a short-lived JWT token for each workflow run. AWS validates this token cryptographically and exchanges it for a temporary STS credential that expires when the job ends.
┌─────────────────────────────────────────────────────────────────────┐
│ OIDC TOKEN FLOW │
│ │
│ GitHub Actions Job starts │
│ │ │
│ ▼ │
│ GitHub generates JWT (signed by GitHub's OIDC provider) │
│ Claims include: │
│ sub: repo:MatthewDipo/myapp:ref:refs/heads/main │
│ aud: sts.amazonaws.com │
│ │ │
│ ▼ │
│ aws sts assume-role-with-web-identity │
│ --role-arn arn:aws:iam::ACCOUNT:role/ROLE │
│ --web-identity-token <JWT> │
│ │ │
│ ▼ │
│ AWS validates JWT against GitHub's OIDC endpoint │
│ (https://token.actions.githubusercontent.com) │
│ │ │
│ ▼ │
│ Returns: AccessKeyId + SecretAccessKey + SessionToken │
│ (valid for 1 hour maximum, then automatically expire) │
└─────────────────────────────────────────────────────────────────────┘
Terraform to create the OIDC provider (management account, us-east-1 only — it is global):
# _modules/iam/main.tf
# Fetch GitHub's OIDC thumbprint automatically
data "tls_certificate" "github" {
url = "https://token.actions.githubusercontent.com"
}
resource "aws_iam_openid_connect_provider" "github" {
count = var.create_github_oidc ? 1 : 0
url = "https://token.actions.githubusercontent.com"
client_id_list = ["sts.amazonaws.com"]
thumbprint_list = [
data.tls_certificate.github.certificates[0].sha1_fingerprint
]
}
# IAM role for GitHub Actions CI — one per cluster
resource "aws_iam_role" "github_ci" {
name = "${var.cluster_name}-github-ci"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Principal = {
Federated = var.github_oidc_provider_arn
}
Action = "sts:AssumeRoleWithWebIdentity"
Condition = {
StringEquals = {
"token.actions.githubusercontent.com:aud" = "sts.amazonaws.com"
}
StringLike = {
# Only main branch of your specific repo can assume this role
"token.actions.githubusercontent.com:sub" =
"repo:${var.github_user}/${var.github_repo}:ref:refs/heads/main"
}
}
}]
})
}
resource "aws_iam_role_policy" "github_ci" {
name = "github-ci-policy"
role = aws_iam_role.github_ci.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "ECRAuth"
Effect = "Allow"
Action = ["ecr:GetAuthorizationToken"]
Resource = "*"
},
{
Sid = "ECRPush"
Effect = "Allow"
Action = [
"ecr:BatchCheckLayerAvailability",
"ecr:GetDownloadUrlForLayer",
"ecr:BatchGetImage",
"ecr:InitiateLayerUpload",
"ecr:UploadLayerPart",
"ecr:CompleteLayerUpload",
"ecr:PutImage",
"ecr:DescribeImages",
"ecr:ListImages"
]
Resource = "arn:aws:ecr:*:${var.account_id}:repository/myapp"
},
{
Sid = "KMSCosignSign"
Effect = "Allow"
Action = [
"kms:Sign",
"kms:GetPublicKey",
"kms:DescribeKey"
]
Resource = var.cosign_kms_key_arn
},
{
Sid = "S3AuditWrite"
Effect = "Allow"
Action = ["s3:PutObject"]
Resource = "${var.audit_bucket_arn}/ci-push-audit/*"
}
]
})
}
Key security insight: The Condition block in the trust policy is critical. It restricts role assumption to:
- Only your specific repository (
repo:MatthewDipo/myapp) - Only the
mainbranch
A fork of your repository, or a branch other than main, cannot assume this role.
Step 7: ECR Repository in the Management Account
We store Docker images in the management account's ECR rather than per-environment accounts. This means one place to manage image lifecycle policies and one IAM policy for cross-account pull permissions.
# _modules/ecr/main.tf
resource "aws_ecr_repository" "app" {
name = var.name
image_tag_mutability = "IMMUTABLE" # Tags cannot be overwritten
image_scanning_configuration {
scan_on_push = true # ECR runs basic CVE scan on every push
}
encryption_configuration {
encryption_type = "KMS"
kms_key = var.kms_key_arn
}
}
resource "aws_ecr_lifecycle_policy" "app" {
repository = aws_ecr_repository.app.name
policy = jsonencode({
rules = [
{
rulePriority = 1
description = "Keep last 30 tagged images"
selection = {
tagStatus = "tagged"
tagPrefixList = ["sha-"]
countType = "imageCountMoreThan"
countNumber = 30
}
action = { type = "expire" }
},
{
rulePriority = 2
description = "Delete untagged images after 1 day"
selection = {
tagStatus = "untagged"
countType = "sinceImagePushed"
countUnit = "days"
countNumber = 1
}
action = { type = "expire" }
}
]
})
}
# Cross-account pull policy — allows dev/staging/prod accounts to pull images
resource "aws_ecr_repository_policy" "cross_account" {
repository = aws_ecr_repository.app.name
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "CrossAccountPull"
Effect = "Allow"
Principal = {
AWS = [
"arn:aws:iam::${var.dev_account_id}:root",
"arn:aws:iam::${var.staging_account_id}:root",
"arn:aws:iam::${var.prod_account_id}:root"
]
}
Action = [
"ecr:GetDownloadUrlForLayer",
"ecr:BatchGetImage",
"ecr:BatchCheckLayerAvailability"
]
}
]
})
}
IMMUTABLE tags mean that once you push sha-abc123, that tag forever points to that exact image digest. No one can silently overwrite an existing tag with a different image — a subtle but important supply chain security control.
SCREENSHOT: ECR repository showing images with sha- tags and scan results
Step 8: KMS Keys for Encryption at Rest
Each environment gets its own KMS key for encrypting:
- EKS Kubernetes secrets (etcd encryption)
- EBS volumes (Prometheus/Grafana/Velero PVCs)
- ECR images
- S3 buckets (Velero backups, CI audit logs)
# _modules/kms/main.tf
resource "aws_kms_key" "main" {
description = "${var.env}-${var.region}-main"
deletion_window_in_days = 30
enable_key_rotation = true # Rotate annually, automatically
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "Enable IAM User Permissions"
Effect = "Allow"
Principal = { AWS = "arn:aws:iam::${var.account_id}:root" }
Action = "kms:*"
Resource = "*"
},
{
Sid = "Allow EKS"
Effect = "Allow"
Principal = {
Service = "eks.amazonaws.com"
}
Action = ["kms:Encrypt", "kms:Decrypt", "kms:GenerateDataKey*", "kms:DescribeKey"]
Resource = "*"
},
{
Sid = "Allow AutoScaling"
Effect = "Allow"
Principal = {
AWS = "arn:aws:iam::${var.account_id}:role/aws-service-role/autoscaling.amazonaws.com/AWSServiceRoleForAutoScaling"
}
Action = ["kms:Encrypt", "kms:Decrypt", "kms:ReEncrypt*", "kms:GenerateDataKey*", "kms:DescribeKey", "kms:CreateGrant"]
Resource = "*"
}
]
})
}
resource "aws_kms_alias" "main" {
name = "alias/${var.env}-${var.region}-main"
target_key_id = aws_kms_key.main.key_id
}
Lesson learned: The
AWSServiceRoleForAutoScalingmust exist in the account before you can reference it in the KMS key policy. In fresh accounts, create this Service Linked Role first or AWS will reject the key policy withMalformedPolicyDocumentException. See Part 3 for the Terragrunt pattern that handles this.
Step 9: Terragrunt Root Configuration
Before writing any Terragrunt configs, establish the root terragrunt.hcl that all child configs inherit:
# live/terragrunt.hcl (root)
locals {
# Parse path to extract env and region
# e.g., live/production/us-east-1/eks → env=production, region=us-east-1
path_parts = split("/", path_relative_to_include())
env = local.path_parts[0]
region = local.path_parts[1]
account_ids = {
dev = "557702566877"
staging = "STAGING_ACCOUNT_ID"
production = "591120834781"
management = "MGMT_ACCOUNT_ID"
}
account_id = local.account_ids[local.env]
}
# Generate provider.tf in every child directory
generate "provider" {
path = "provider.tf"
if_exists = "overwrite_terragrunt"
contents = <<EOF
provider "aws" {
region = "${local.region}"
assume_role {
role_arn = "arn:aws:iam::${local.account_id}:role/OrganizationAccountAccessRole"
}
default_tags {
tags = {
Environment = "${local.env}"
ManagedBy = "Terraform"
Project = "myapp"
}
}
}
EOF
}
# Generate backend.tf — S3 state, DynamoDB lock table
remote_state {
backend = "s3"
generate = {
path = "backend.tf"
if_exists = "overwrite_terragrunt"
}
config = {
bucket = "myapp-terraform-state-${local.account_id}"
key = "${path_relative_to_include()}/terraform.tfstate"
region = "us-east-1"
encrypt = true
dynamodb_table = "myapp-terraform-locks"
role_arn = "arn:aws:iam::${local.account_id}:role/OrganizationAccountAccessRole"
}
}
The generate "provider" block means you never write a provider.tf by hand. Every module automatically gets the correct AWS account and region based purely on its directory path. This is the key DRY benefit of Terragrunt.
Understanding OrganizationAccountAccessRole
When you create a member account via AWS Organizations, AWS automatically creates a role called OrganizationAccountAccessRole in that account. This role trusts your management account, allowing management account principals to assume it and perform actions in the member account.
This is how Terraform (running with management account credentials) deploys infrastructure into dev, staging, and production without needing separate credentials per account.
Management Account (your terminal / CI)
│
│ sts:AssumeRole
▼
arn:aws:iam::591120834781:role/OrganizationAccountAccessRole
│
│ (full AdministratorAccess in production account)
▼
Production Account resources
Step 10: Bootstrap S3 State Buckets
Before running any Terragrunt, each account needs its S3 bucket and DynamoDB table for Terraform state.
# Run this once per account (adjust account ID and profile)
for PROFILE in myapp-dev-use1 myapp-staging-use1 myapp-prod-use1; do
ACCOUNT_ID=$(aws sts get-caller-identity --profile $PROFILE --query Account --output text)
REGION="us-east-1"
# Create state bucket
aws s3api create-bucket \
--bucket "myapp-terraform-state-${ACCOUNT_ID}" \
--region $REGION \
--profile $PROFILE
# Enable versioning (lets you recover from bad applies)
aws s3api put-bucket-versioning \
--bucket "myapp-terraform-state-${ACCOUNT_ID}" \
--versioning-configuration Status=Enabled \
--profile $PROFILE
# Enable encryption
aws s3api put-bucket-encryption \
--bucket "myapp-terraform-state-${ACCOUNT_ID}" \
--server-side-encryption-configuration '{
"Rules": [{"ApplyServerSideEncryptionByDefault": {"SSEAlgorithm": "AES256"}}]
}' \
--profile $PROFILE
# Block public access
aws s3api put-public-access-block \
--bucket "myapp-terraform-state-${ACCOUNT_ID}" \
--public-access-block-configuration \
"BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true" \
--profile $PROFILE
# Create DynamoDB lock table
aws dynamodb create-table \
--table-name myapp-terraform-locks \
--attribute-definitions AttributeName=LockID,AttributeType=S \
--key-schema AttributeName=LockID,KeyType=HASH \
--billing-mode PAY_PER_REQUEST \
--region $REGION \
--profile $PROFILE
echo "State backend ready for account $ACCOUNT_ID"
done
Summary
By the end of this part you have:
- ✅ AWS Organizations with four accounts (management, dev, staging, production)
- ✅ Service Control Policies protecting production from accidental destruction
- ✅ AWS SSO for human access (no IAM users with permanent credentials)
- ✅ GitHub OIDC provider enabling keyless CI authentication
- ✅ ECR repository with immutable tags, cross-account pull, and lifecycle policies
- ✅ KMS keys for encryption at rest in every environment
- ✅ Terragrunt root config that automatically derives account/region from directory path
- ✅ S3 + DynamoDB Terraform state backend per account
If this was useful, follow me on dev.to — I publish Part 3 next Wednesday covering the Infrastructure as Code — Terraform Modules + Terragrunt.
Questions? Drop them in the comments — I read and reply to every one.
Next: Part 3 — Infrastructure as Code: Terraform Modules + Terragrunt
Follow the series — next part publishes next Wednesday.
Live system: https://www.matthewoladipupo.dev/health
Runbook: Operations Guide
Source code: myapp-infra | myapp-gitops | myapp


Top comments (0)