DEV Community

Cover image for Part 2: AWS Foundation
Matthew
Matthew

Posted on

Part 2: AWS Foundation

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)              │
└─────────────────────────────────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 is you@gmail.com, use you+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

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)
Enter fullscreen mode Exit fullscreen mode

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"
          ]
        }
      }
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Create two Permission Sets:

AdministratorAccess (for platform engineers):

Name: AdministratorAccess
Session duration: 8 hours
Managed policy: AdministratorAccess
Enter fullscreen mode Exit fullscreen mode

ReadOnlyAccess (for developers / auditors):

Name: ReadOnlyAccess
Session duration: 8 hours
Managed policy: ReadOnlyAccess
Enter fullscreen mode Exit fullscreen mode

Assign to accounts:

myapp-dev:         you → AdministratorAccess
myapp-staging:     you → AdministratorAccess
myapp-production:  you → AdministratorAccess
Management:        you → AdministratorAccess
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

SCREENSHOT: IAM Identity Center showing SSO portal with all accounts and permission sets
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)              │
└─────────────────────────────────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

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/*"
      }
    ]
  })
}
Enter fullscreen mode Exit fullscreen mode

Key security insight: The Condition block in the trust policy is critical. It restricts role assumption to:

  1. Only your specific repository (repo:MatthewDipo/myapp)
  2. Only the main branch

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"
        ]
      }
    ]
  })
}
Enter fullscreen mode Exit fullscreen mode

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
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
}
Enter fullscreen mode Exit fullscreen mode

Lesson learned: The AWSServiceRoleForAutoScaling must 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 with MalformedPolicyDocumentException. 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"
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)