DB to project account + VPC — Implementation Plan

Spec: .project/docs/specs/20260804115358_db_project_account_vpc.md Branch: feat/db-project-account-vpc

Goal: Recreate the nectar-charges Aurora cluster in the project’s own AWS account inside a dedicated private VPC peered to shared, with dynamic instance names, and delete the old cluster in the shared account.

Architecture: Bootstrap a project AWS account (operator, in the infrastructure repo). Convert modules/backend/.infra/terraform to the two-provider pattern (default = project account, aws.shared = shared, EKS-only). Add a private VPC per env (staging 10.1.0.0/16, production 10.2.0.0/16) peered to shared with routes both sides. Move the Aurora SG + subnet group + cluster into the project VPC, name the instance via random_pet. Validate connectivity from an EKS pod over the peering, then destroy the leftover shared-VPC resources.

Tech stack: Terraform (AWS + Kubernetes + Cloudflare providers), Aurora PostgreSQL Serverless v2, AWS VPC peering, ward, make/terraform wrapper.

Global constraints

  • App resources live in the PROJECT account + PROJECT VPC; shared reached only via aws.shared (EKS data source) and VPC peering — wehive:infra golden rule.
  • Unique non-overlapping CIDRs: staging 10.1.0.0/16, production 10.2.0.0/16.
  • RDS instance name is dynamic (random_pet) — never -writer/-reader/ordinal.
  • Private-only VPC (no IGW, egress via NAT); everything reachable over peering.
  • No inline route {} mixed with standalone aws_route on the same route table.
  • Born-correct, zero leftovers: same work deletes the old shared-VPC cluster/SG/subnet group and reconciles state.
  • Always plan before apply; both workspaces (staging, production) must end clean.
  • Commits: one line, ≤60 chars, no AI mention.

Task 0: Bootstrap the project AWS account (OPERATOR — blocking)

Not done in this repo. Run in the infrastructure repo; every task below is blocked until the account_id lands in the project ward vault.

  • [ ] Step 1: Create the account (infrastructure repo)

bash # inside the infrastructure repo (one account for the whole project) ACCOUNT_NAME=nectar-charges \ ACCOUNT_EMAIL=aws+nectar-charges@wehive.tech \ make aws.account.create

Expected: prints the new account_id and terraform_role_arn; idempotent (imports if it already exists).

  • [ ] Step 2: Store the id in the PROJECT ward vault

bash # inside nectar-charges ward set nectar-charges.infra.TF_VAR_aws_account_id <account_id>

  • [ ] Step 3: Verify the project can assume its role

bash wehive terraform -chdir=modules/backend/.infra/terraform providers

Expected: no auth error resolving the default aws provider against the project account.


Task 1: Two-provider data.tf + aws_account_id variable

Files: - Modify: modules/backend/.infra/terraform/variables.tf - Modify: modules/backend/.infra/terraform/data.tf

Interfaces: - Produces: default aws provider (project account), aws.shared alias (shared, EKS only), var.aws_account_id.

  • [ ] Step 1: Add the variable

hcl variable "aws_account_id" { type = string description = "This project's AWS account ID" }

  • [ ] Step 2: Rewrite data.tf to two providers

```hcl provider “aws” { region = var.aws_region access_key = var.aws_terraform_access_key_id secret_key = var.aws_terraform_secret_access_key assume_role { role_arn = “arn:aws:iam::${var.aws_account_id}:role/TerraformRole” } }

provider “aws” { alias = “shared” region = var.aws_region access_key = var.aws_terraform_access_key_id secret_key = var.aws_terraform_secret_access_key assume_role { role_arn = “arn:aws:iam::${var.aws_shared_account_id}:role/TerraformRole” } }

data “aws_eks_cluster” “shared” { provider = aws.shared name = “shared-kubernetes” }

data “aws_eks_cluster_auth” “shared” { provider = aws.shared name = “shared-kubernetes” }

provider “kubernetes” { host = data.aws_eks_cluster.shared.endpoint cluster_ca_certificate = base64decode(data.aws_eks_cluster.shared.certificate_authority[0].data) token = data.aws_eks_cluster_auth.shared.token }

provider “cloudflare” { api_token = var.cloudflare_api_token } ```

Note: data.aws_subnets.shared is removed here (Task 3 stops using it).

  • [ ] Step 3: Verify

bash make terraform.staging.plan

Expected: providers resolve; EKS data sources read via aws.shared. Plan may still reference shared VPC until Task 3 — that’s fine at this step.

  • [ ] Step 4: Commit

bash git add modules/backend/.infra/terraform/variables.tf modules/backend/.infra/terraform/data.tf git commit -m "infra: two-provider setup for project account"


Task 2: Project VPC + peering to shared

Files: - Create: modules/backend/.infra/terraform/network.tf - Modify: modules/backend/.infra/terraform/main.tf (add CIDR to locals)

Interfaces: - Consumes: aws.shared, var.aws_shared_vpc_id, var.aws_shared_vpc_cidr. - Produces: aws_vpc.project, aws_subnet.private[*], peering + routes both sides.

  • [ ] Step 1: Add CIDR to locals in main.tf

hcl vpc_cidr = local.environment == "production" ? "10.2.0.0/16" : "10.1.0.0/16"

  • [ ] Step 2: Create network.tf

```hcl data “aws_availability_zones” “available” { state = “available” }

resource “aws_vpc” “project” { cidr_block = local.vpc_cidr enable_dns_support = true enable_dns_hostnames = true tags = { Name = “nectar-charges-${local.environment}” Environment = local.environment ManagedBy = “terraform” } }

resource “aws_subnet” “private” { count = 2 vpc_id = aws_vpc.project.id cidr_block = cidrsubnet(local.vpc_cidr, 8, count.index) availability_zone = data.aws_availability_zones.available.names[count.index] tags = { Name = “nectar-charges-${local.environment}-private-${count.index}” Environment = local.environment ManagedBy = “terraform” } }

resource “aws_eip” “nat” { domain = “vpc” tags = { Name = “nectar-charges-${local.environment}-nat” } }

resource “aws_subnet” “nat_public” { vpc_id = aws_vpc.project.id cidr_block = cidrsubnet(local.vpc_cidr, 8, 100) availability_zone = data.aws_availability_zones.available.names[0] tags = { Name = “nectar-charges-${local.environment}-nat-public” } }

resource “aws_internet_gateway” “project” { vpc_id = aws_vpc.project.id tags = { Name = “nectar-charges-${local.environment}-igw” } }

resource “aws_nat_gateway” “project” { allocation_id = aws_eip.nat.id subnet_id = aws_subnet.nat_public.id tags = { Name = “nectar-charges-${local.environment}-nat” } depends_on = [aws_internet_gateway.project] }

resource “aws_route_table” “public” { vpc_id = aws_vpc.project.id tags = { Name = “nectar-charges-${local.environment}-public” } }

resource “aws_route_table” “private” { vpc_id = aws_vpc.project.id tags = { Name = “nectar-charges-${local.environment}-private” } }

resource “aws_route_table_association” “nat_public” { subnet_id = aws_subnet.nat_public.id route_table_id = aws_route_table.public.id }

resource “aws_route_table_association” “private” { count = length(aws_subnet.private) subnet_id = aws_subnet.private[count.index].id route_table_id = aws_route_table.private.id }

resource “aws_route” “public_igw” { route_table_id = aws_route_table.public.id destination_cidr_block = “0.0.0.0/0” gateway_id = aws_internet_gateway.project.id }

resource “aws_route” “private_nat” { route_table_id = aws_route_table.private.id destination_cidr_block = “0.0.0.0/0” nat_gateway_id = aws_nat_gateway.project.id }

resource “aws_vpc_peering_connection” “to_shared” { vpc_id = aws_vpc.project.id peer_vpc_id = var.aws_shared_vpc_id auto_accept = true tags = { Name = “nectar-charges-${local.environment}-to-shared” } }

resource “aws_route” “private_to_shared” { route_table_id = aws_route_table.private.id destination_cidr_block = var.aws_shared_vpc_cidr vpc_peering_connection_id = aws_vpc_peering_connection.to_shared.id } ```

Note: the return route on the shared route table (local.vpc_cidr → this peering) is added on the shared side. If the shared route table is managed in the infrastructure repo, that return route is an operator step there; capture it in Task 2 Step 4 verification and flag if missing.

  • [ ] Step 3: Apply staging

bash make terraform.staging.plan make terraform.staging.apply

Expected: VPC, subnets, NAT, peering created; peering status active.

  • [ ] Step 4: Verify peering + return route

bash wehive terraform state show aws_vpc_peering_connection.to_shared # confirm shared route table has local.vpc_cidr -> this peering (add on shared side if missing)

  • [ ] Step 5: Commit

bash git add modules/backend/.infra/terraform/network.tf modules/backend/.infra/terraform/main.tf git commit -m "infra: project vpc peered to shared"


Task 3: Move Aurora into the project VPC + dynamic instance name

Files: - Modify: modules/backend/.infra/terraform/database.tf

Interfaces: - Consumes: aws_vpc.project, aws_subnet.private, var.aws_shared_vpc_cidr. - Produces: cluster in project VPC, random_pet-named instance.

  • [ ] Step 1: Point SG + subnet group at the project VPC and add random_pet

```hcl resource “random_pet” “db” { length = 2 }

resource “aws_security_group” “aurora” { name = “nectar-charges-aurora-sg-${local.environment}” description = “Aurora access from peered shared VPC” vpc_id = aws_vpc.project.id

ingress { from_port = 5432 to_port = 5432 protocol = “tcp” cidr_blocks = [var.aws_shared_vpc_cidr] }

egress { from_port = 0 to_port = 0 protocol = “-1” cidr_blocks = [“0.0.0.0/0”] }

tags = { Name = “nectar-charges-aurora-sg-${local.environment}” Environment = local.environment ManagedBy = “terraform” } }

resource “aws_db_subnet_group” “aurora” { name = “nectar-charges-aurora-subnets-${local.environment}” subnet_ids = aws_subnet.private[*].id

tags = { Name = “nectar-charges-aurora-subnets-${local.environment}” Environment = local.environment ManagedBy = “terraform” } } ```

  • [ ] Step 2: Rename the instance resource to main with a dynamic identifier

```hcl resource “aws_rds_cluster_instance” “main” { identifier = “nectar-charges-${local.environment}-${random_pet.db.id}” cluster_identifier = aws_rds_cluster.nectar_charges.id instance_class = “db.serverless” engine = aws_rds_cluster.nectar_charges.engine engine_version = aws_rds_cluster.nectar_charges.engine_version db_subnet_group_name = aws_db_subnet_group.aurora.name publicly_accessible = false

tags = { Name = “nectar-charges-${local.environment}-${random_pet.db.id}” Environment = local.environment ManagedBy = “terraform” } } ```

  • [ ] Step 3: Plan — expect the cluster + instance to be replaced (moving VPC)

bash make terraform.staging.plan

Expected: aws_rds_cluster.nectar_charges, aws_rds_cluster_instance replaced (new SG/subnet group in project VPC); no data to preserve (recreate empty).

  • [ ] Step 4: Apply

bash make terraform.staging.apply

Expected: cluster + instance created in the project VPC; instance name is nectar-charges-staging-<pet>.

  • [ ] Step 5: Commit

bash git add modules/backend/.infra/terraform/database.tf git commit -m "infra: aurora in project vpc, dynamic name"


Task 4: Verify connectivity over peering + secret

Files: none (verification only).

  • [ ] Step 1: Confirm the k8s secret has the new endpoints

bash kubectl -n nectar-charges--staging get secret nectar-charges-secrets -o jsonpath='{.data.DATABASE_URL_FULLACCESS}' | base64 -d

Expected: host is the new cluster writer endpoint; DB has no public IP.

  • [ ] Step 2: Reach the DB from an EKS pod over the peering

bash make shell.staging # inside the pod: nc -zv <writer-endpoint> 5432

Expected: connection succeeds on the private IP via peering.

  • [ ] Step 3: Run the account/VPC checklist from wehive:infra — all boxes pass.

Task 5: Delete the old shared-VPC resources (zero leftovers)

Files: none (cleanup + state reconcile).

  • [ ] Step 1: Confirm nothing named nectar-charges-* remains in the shared account/VPC

The Task 3 replace already destroyed the old cluster/SG/subnet group via terraform. Verify no residue:

bash AWS_PROFILE=wehive-shared aws rds describe-db-clusters \ --query "DBClusters[?contains(DBClusterIdentifier, 'nectar-charges')].DBClusterIdentifier" AWS_PROFILE=wehive-shared aws rds describe-db-subnet-groups \ --query "DBSubnetGroups[?contains(DBSubnetGroupName, 'nectar-charges')].DBSubnetGroupName"

Expected: empty results. If any residue exists, delete via CLI and terraform state rm to reconcile.

  • [ ] Step 2: Both workspaces clean

bash make terraform.staging.plan make terraform.production.plan

Expected: no unexpected changes in either.


Task 6: Production apply

  • [ ] Step 1: Plan + apply production (same flow, CIDR 10.2.0.0/16)

bash make terraform.production.plan make terraform.production.apply

  • [ ] Step 2: Repeat Task 4 verification against production.

Task 7: Documentation learning

Files: - Create: .project/docs/learnings/db_project_account_vpc.md

  • [ ] Step 1: Write the learning — app DB belongs in the project account/VPC, shared reached only via peering; instance names are dynamic (random_pet); account created via make aws.account.create in the infrastructure repo.

  • [ ] Step 2: Commit

bash git add .project/docs/learnings/db_project_account_vpc.md git commit -m "docs: learning db project account vpc"


Self-review

  • Spec coverage: account bootstrap (T0) ✓; two providers (T1) ✓; project VPC + peering + unique CIDR (T2) ✓; Aurora in project VPC private subnets + random_pet (T3) ✓; connectivity verify (T4) ✓; delete old shared resources + clean plan (T5) ✓; production (T6) ✓; docs learning + skill fixes (T7 + already-committed skill changes) ✓. Non-goals respected (no public reader, no data migration, no frontend change).
  • Placeholder scan: none — every step has concrete code/commands.
  • Type consistency: aws_rds_cluster_instance.main and random_pet.db used consistently across T3–T5; aws_vpc.project/aws_subnet.private produced in T2 and consumed in T3.
  • Open dependency: the shared-side return route (T2) may live in the infrastructure repo — flagged as an operator step, not silently assumed.