DB to project account + VPC — Implementation Plan
Spec:
.project/docs/specs/20260804115358_db_project_account_vpc.mdBranch: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:infragolden rule. - Unique non-overlapping CIDRs: staging
10.1.0.0/16, production10.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 standaloneaws_routeon the same route table. - Born-correct, zero leftovers: same work deletes the old shared-VPC cluster/SG/subnet group and reconciles state.
- Always
planbeforeapply; 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.tfto 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
localsinmain.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
mainwith 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 viamake aws.account.createin 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.mainandrandom_pet.dbused consistently across T3–T5;aws_vpc.project/aws_subnet.privateproduced in T2 and consumed in T3. - Open dependency: the shared-side return route (T2) may live in the
infrastructurerepo — flagged as an operator step, not silently assumed.