Multi-Account AWS Infrastructure with Terraform: Cross-Account Database Access Patterns

Most organizations outgrow single-account AWS setups quickly. Security wants workload isolation.
Finance wants separate billing. Compliance wants boundaries between environments. Soon you're managing dev, staging, production, and shared-services accounts - each needing to communicate with the others securely.
Terraform handles this elegantly, but the patterns aren't obvious. I've spent more time than I'd like debugging cross-account access issues, and I want to save you that pain.
This guide covers the cross-account patterns I've used in production, specifically for database access scenarios.
______
The Multi-Account Challenge
Consider a common scenario:
Dev Account Prod Account Shared Data
(Workloads) (Workloads) Account (RDS)
| | |
v v v
Cross-Account Access
to Shared Database
Your application runs in workload accounts but needs to access a centralized database in a shared-data account. How do you:
- Grant secure access without exposing credentials?
- Manage networking across account boundaries?
- Keep Terraform state organized and accessible?
Let me walk through the patterns that actually work.
______
Pattern 1: Cross-Account IAM Roles (AssumeRole)
This is the foundation of all cross-account access in AWS. Applications in one account assume a role in another account to gain permissions.
Step 1: Create the Trust Role (in Shared-Data Account)
1# shared-data-account/iam.tf
2data "aws_caller_identity" "dev" {
3 provider = aws.dev_account
4}
5
6data "aws_caller_identity" "prod" {
7 provider = aws.prod_account
8}
9
10resource "aws_iam_role" "cross_account_rds_access" {
11 name = "cross-account-rds-access"
12
13 assume_role_policy = jsonencode({
14 Version = "2012-10-17"
15 Statement = [
16 {
17 Effect = "Allow"
18 Principal = {
19 AWS = [
20 "arn:aws:iam::${data.aws_caller_identity.dev.account_id}:root",
21 "arn:aws:iam::${data.aws_caller_identity.prod.account_id}:root"
22 ]
23 }
24 Action = "sts:AssumeRole"
25 Condition = {
26 StringEquals = {
27 "sts:ExternalId" = var.external_id # Extra security layer
28 }
29 }
30 }
31 ]
32 })
33}
34
35resource "aws_iam_role_policy" "rds_access" {
36 name = "rds-access-policy"
37 role = aws_iam_role.cross_account_rds_access.id
38
39 policy = jsonencode({
40 Version = "2012-10-17"
41 Statement = [
42 {
43 Effect = "Allow"
44 Action = [
45 "rds-db:connect"
46 ]
47 Resource = "arn:aws:rds-db:${var.region}:${data.aws_caller_identity.current.account_id}:dbuser:${aws_db_instance.main.resource_id}/*"
48 }
49 ]
50 })
51}Step 2: Configure Multiple Providers
This is where Terraform shines for multi-account work:
1# providers.tf
2provider "aws" {
3 alias = "shared_data"
4 region = "ap-south-1"
5
6 assume_role {
7 role_arn = "arn:aws:iam::SHARED_ACCOUNT_ID:role/TerraformExecutionRole"
8 session_name = "terraform-shared"
9 }
10}
11
12provider "aws" {
13 alias = "dev"
14 region = "ap-south-1"
15
16 assume_role {
17 role_arn = "arn:aws:iam::DEV_ACCOUNT_ID:role/TerraformExecutionRole"
18 session_name = "terraform-dev"
19 }
20}
21
22provider "aws" {
23 alias = "prod"
24 region = "ap-south-1"
25
26 assume_role {
27 role_arn = "arn:aws:iam::PROD_ACCOUNT_ID:role/TerraformExecutionRole"
28 session_name = "terraform-prod"
29 }
30}Step 3: Grant AssumeRole Permission (in Workload Accounts)
1# dev-account/iam.tf
2resource "aws_iam_role" "app_role" {
3 name = "application-role"
4
5 assume_role_policy = jsonencode({
6 Version = "2012-10-17"
7 Statement = [
8 {
9 Effect = "Allow"
10 Principal = {
11 Service = "ecs-tasks.amazonaws.com"
12 }
13 Action = "sts:AssumeRole"
14 }
15 ]
16 })
17}
18
19resource "aws_iam_role_policy" "assume_cross_account" {
20 name = "assume-cross-account-rds"
21 role = aws_iam_role.app_role.id
22
23 policy = jsonencode({
24 Version = "2012-10-17"
25 Statement = [
26 {
27 Effect = "Allow"
28 Action = "sts:AssumeRole"
29 Resource = "arn:aws:iam::SHARED_ACCOUNT_ID:role/cross-account-rds-access"
30 }
31 ]
32 })
33}______
Pattern 2: Cross-Account VPC Peering for RDS
IAM handles authorization, but you still need network connectivity. Without it, your application can have all the right permissions but can't actually reach the database.
VPC Peering Setup
1# In shared-data account: Create the peering connection
2resource "aws_vpc_peering_connection" "dev_to_shared" {
3 provider = aws.shared_data
4
5 vpc_id = aws_vpc.shared_data.id
6 peer_vpc_id = data.aws_vpc.dev.id
7 peer_owner_id = data.aws_caller_identity.dev.account_id
8 auto_accept = false
9
10 tags = {
11 Name = "dev-to-shared-data"
12 }
13}
14
15# In dev account: Accept the peering connection
16resource "aws_vpc_peering_connection_accepter" "dev_accept" {
17 provider = aws.dev
18
19 vpc_peering_connection_id = aws_vpc_peering_connection.dev_to_shared.id
20 auto_accept = true
21
22 tags = {
23 Name = "dev-to-shared-data"
24 }
25}
26
27# Route table entries (both sides - don't forget this!)
28resource "aws_route" "dev_to_shared" {
29 provider = aws.dev
30
31 route_table_id = data.aws_route_table.dev_private.id
32 destination_cidr_block = aws_vpc.shared_data.cidr_block
33 vpc_peering_connection_id = aws_vpc_peering_connection.dev_to_shared.id
34}
35
36resource "aws_route" "shared_to_dev" {
37 provider = aws.shared_data
38
39 route_table_id = aws_route_table.shared_private.id
40 destination_cidr_block = data.aws_vpc.dev.cidr_block
41 vpc_peering_connection_id = aws_vpc_peering_connection.dev_to_shared.id
42}Security Group for Cross-Account Access
1# shared-data-account/security-groups.tf
2resource "aws_security_group" "rds" {
3 provider = aws.shared_data
4
5 name = "rds-cross-account"
6 description = "Allow cross-account access to RDS"
7 vpc_id = aws_vpc.shared_data.id
8
9 ingress {
10 description = "PostgreSQL from Dev VPC"
11 from_port = 5432
12 to_port = 5432
13 protocol = "tcp"
14 cidr_blocks = [data.aws_vpc.dev.cidr_block]
15 }
16
17 ingress {
18 description = "PostgreSQL from Prod VPC"
19 from_port = 5432
20 to_port = 5432
21 protocol = "tcp"
22 cidr_blocks = [data.aws_vpc.prod.cidr_block]
23 }
24
25 egress {
26 from_port = 0
27 to_port = 0
28 protocol = "-1"
29 cidr_blocks = ["0.0.0.0/0"]
30 }
31}_______
Pattern 3: Sharing Secrets Across Accounts
Database credentials need to be accessible from workload accounts without being hardcoded anywhere.
Using AWS Secrets Manager with Resource Policy
1# shared-data-account/secrets.tf
2resource "aws_secretsmanager_secret" "db_credentials" {
3 provider = aws.shared_data
4 name = "shared-rds/credentials"
5}
6
7resource "aws_secretsmanager_secret_version" "db_credentials" {
8 provider = aws.shared_data
9 secret_id = aws_secretsmanager_secret.db_credentials.id
10
11 secret_string = jsonencode({
12 username = aws_db_instance.main.username
13 password = random_password.db_password.result
14 host = aws_db_instance.main.address
15 port = aws_db_instance.main.port
16 dbname = aws_db_instance.main.db_name
17 })
18}
19
20# Allow cross-account access to the secret
21resource "aws_secretsmanager_secret_policy" "cross_account" {
22 provider = aws.shared_data
23 secret_arn = aws_secretsmanager_secret.db_credentials.arn
24
25 policy = jsonencode({
26 Version = "2012-10-17"
27 Statement = [
28 {
29 Sid = "AllowCrossAccountAccess"
30 Effect = "Allow"
31 Principal = {
32 AWS = [
33 "arn:aws:iam::${data.aws_caller_identity.dev.account_id}:role/application-role",
34 "arn:aws:iam::${data.aws_caller_identity.prod.account_id}:role/application-role"
35 ]
36 }
37 Action = "secretsmanager:GetSecretValue"
38 Resource = "*"
39 }
40 ]
41 })
42}_______
Pattern 4: Remote State for Cross-Account References
When accounts are managed separately, use remote state to share outputs between them.
Exposing Outputs from Shared-Data Account
1# shared-data-account/outputs.tf
2output "rds_endpoint" {
3 value = aws_db_instance.main.address
4}
5
6output "rds_port" {
7 value = aws_db_instance.main.port
8}
9
10output "vpc_cidr" {
11 value = aws_vpc.shared_data.cidr_block
12}
13
14output "cross_account_role_arn" {
15 value = aws_iam_role.cross_account_rds_access.arn
16}Consuming Remote State in Workload Accounts
1# dev-account/data.tf
2data "terraform_remote_state" "shared_data" {
3 backend = "s3"
4
5 config = {
6 bucket = "terraform-state-shared-data"
7 key = "infrastructure/terraform.tfstate"
8 region = "ap-south-1"
9 role_arn = "arn:aws:iam::SHARED_ACCOUNT_ID:role/TerraformStateReader"
10 }
11}
12
13# Usage
14locals {
15 rds_endpoint = data.terraform_remote_state.shared_data.outputs.rds_endpoint
16 cross_account_role = data.terraform_remote_state.shared_data.outputs.cross_account_role_arn
17}_______
Project Structure
Organize your multi-account Terraform like this:
1infrastructure/
2├── modules/
3│ ├── vpc-peering/
4│ ├── cross-account-role/
5│ └── rds/
6├── accounts/
7│ ├── shared-data/
8│ │ ├── main.tf
9│ │ ├── rds.tf
10│ │ ├── iam.tf
11│ │ └── backend.tf
12│ ├── dev/
13│ │ ├── main.tf
14│ │ ├── iam.tf
15│ │ └── backend.tf
16│ └── prod/
17│ ├── main.tf
18│ ├── iam.tf
19│ └── backend.tf
20└── terragrunt.hcl # Optional: for DRY configurations
_______
Common Pitfalls
Circular dependencies - Account A needs Account B's VPC ID, but B needs A's. Solution: Use data sources and apply in stages, or use Terragrunt dependencies.
State file permissions - Ensure the Terraform execution role can read remote state from other accounts. Create a dedicated TerraformStateReader role.
CIDR conflicts - Plan your VPC CIDRs upfront. Overlapping ranges break peering. I use a convention like:
- Shared: 10.0.0.0/16
- Dev: 10.1.0.0/16
- Prod: 10.2.0.0/16
Forgetting route tables - VPC peering requires routes on both sides. Miss one and you'll spend hours debugging connectivity issues that look like security group problems.
_______
Wrapping Up
Multi-account AWS with Terraform is about mastering a few key patterns: IAM AssumeRole for authorization, VPC peering for connectivity, Secrets Manager for credentials, and remote state for cross-referencing.
Start with clear account boundaries, plan your network topology upfront, and use consistent naming conventions. The initial setup takes effort, but you end up with infrastructure that's secure by default and scales with your organization.
The patterns in this guide have worked in production environments handling real traffic. They're not theoretical - they're battle-tested. Use them as a starting point, and adapt them to your specific requirements.
