One-time setup
One-time setup is a single Terraform module,modules/app-db-prereqs, recommended for every deployment. It creates IAM roles, policies, and an S3 bucket for storing OpenTofu state.
agents map so they share one state bucket. For the walkthrough that wires its outputs into Fargate or Helm, see Setup: Step 1.
Resources created
Module options
Per-data-plane options
Each entry in theagents map takes these options.
Multiple data planes in one account and region
Superblocks recommends one data plane per AWS account and VPC, so most deployments never need this. When you do run more than one in the same account and region, declare them all in oneapp_db_prereqs invocation. They share the state bucket, but not IAM roles or VPCs.
Additional AWS regions
Each region needs its ownapp_db_prereqs invocation with an AWS provider alias, and gets its own S3 state bucket. The Enhanced Monitoring role is the exception: IAM role names are account-global, so designate the first region as the role’s durable home and pass its ARN to every additional region through existing_monitoring_role_arn.
terraform_remote_state or supply the concrete ARN (arn:aws:iam::<ACCOUNT_ID>:role/<iam_name_prefix>-enhanced-monitoring) as existing_monitoring_role_arn.
The secondary prerequisite stack re-exports the supplied role through its own enhanced_monitoring_role_arn output. Use module.app_db_prereqs_secondary.enhanced_monitoring_role_arn as monitoring_role_arn in that region’s Fargate physical_module_inputs or EKS physicalModuleInputs.
If that role was deleted or changed accidentally, first check for <iam_name_prefix>-enhanced-monitoring in IAM. Restore the original owning prerequisite stack and run terraform apply to recreate its trust policy and managed-policy attachment. Because the recreated role has the same ARN, existing regional consumers can resume using it. To move ownership deliberately, create a replacement role, update every regional prerequisite stack and physical database input to the replacement ARN, apply those changes, and only then remove the old role.
Immutable setup inputs
There is no automated migration path, so changing any of these values requires manual recovery.Agent name
This is a unique value represented in the Terraform inputs asagents = { "<AGENT_NAME>" = { ... } }. The agent name must be passed as an input into Fargate or Helm. The name is used to derive these outputs:
- The IAM role and policy names, one set per data plane
- The
AgentNametag that every resource must carry before the lifecycle worker can change it - The OpenTofu state prefix, default
app-db/<AGENT_NAME>, which also scopes the worker’s S3 permissions
AccessDenied errors.
Data tag names
Database and role names are derived from a hash of the tag:sbndb_<profile-token>_<application-token> for the database, sbndb_<profile-token>_<application-token>_runtime for its role. <profile-token> is the first 16 hex characters of SHA-256(lowercase(tag)); <application-token> is a 24-hex hash of the application ID. The connector role’s permissions are scoped to that pattern, so renaming a tag orphans every existing database and role under the old name. Tag names must be non-empty and unique across all agents in the module invocation. Settle your tags before any database exists; see Plan your data planes.
VPC
Every IAM policy created for a data plane is scoped to its VPC. Changing it means none of the existing roles can act on resources in the new VPC.Cluster autoscaling
Capacity is configured per data plane. Superblocks recommends the same always-on shape for every data tag, including edit and preview: two instances at 2-32 ACUs each. Choose it when you provision the data plane. After the data plane registers serverless scaling changes (ACU range and instance count), Superblocks automatically queues updates for existing clusters. See Apply configuration changes to existing clusters.deployment block gives you this same shape: the released Aurora module defaults to two instances at 2-32 ACUs each, for an aggregate idle floor of 4 ACUs. Set the block explicitly anyway so the capacity is visible in your configuration. With a nonzero min_acu, the cluster never pauses.
The default min_acu is 2. Setting it to 0 lets an idle cluster pause, which costs less but adds a cold start to the next connection (not recommended). A cluster that has been paused for about 24 hours can take over a minute to resume.
Provisioning the cluster pool
Your data plane provisions the first Aurora cluster when an admin clicks Provision, and adds another whenever remaining pool capacity hits the pool settings from the data plane configuration in Setup: Step 1 (max_databases on Fargate; that cap plus minAvailableCapacityPercent on Helm). This usually takes 5 to 15 minutes, depending on AWS latency. It runs tofu apply with the configuration it already has, so nothing is re-entered in the admin panel.
Each cluster added to the pool arrives with:
- Aurora PostgreSQL cluster and its instances: named
sb-{hash}. Physical modules enable IAM database authentication, keep the endpoint private, encrypt storage, and manage the master password in AWS Secrets Manager. The lifecycle worker reads that secret by ref, so there is no plaintext master credential in Superblocks systems. - CloudWatch log group: a per-cluster
/aws/rds/cluster/<cluster-id>/postgresqlgroup that retains exported PostgreSQL logs for 30 days - DB cluster parameter group: carries the logging parameters described under Observing
- DB subnet group: scoped to the subnets you specified in
physical_module_inputs - Security group and its ingress/egress rules: controls which sources (security groups or CIDRs) can reach the database on port 5432. The security group and each rule are separate AWS resources, and both carry the tags described under How everything is tagged
publicly_accessible = false, storage encryption, and an AWS Secrets Manager master password, so a modified module cannot opt out of those. IAM database authentication is enabled by the physical modules (iam_database_authentication_enabled = true); there is no corresponding IAM condition. See Raw IAM policy JSON.
Pool options
Loweringmax_databases spreads apps across more clusters, which shrinks the blast radius of a failover or an engine patch and raises your baseline cost; raising it does the reverse.
Fargate takes these under pool; Helm takes them under databaseLifecycle.pool in lowerCamelCase.
Physical database options
Fargate takes these underphysical_module_inputs; Helm takes the same keys under databaseLifecycle.physicalModuleInputs. New clusters use these settings when they are provisioned. After the data plane registers changes to supported settings, Superblocks automatically queues updates for existing clusters. See Apply configuration changes to existing clusters.
publicly_accessible is not an option. It is always false, enforced by IAM rather than module default.
Additional options allocated_storage, instance_class, and multi_az exist for alternative database configurations within AWS.
Creating an app database
A builder with database creation permission asks Clark for a feature that needs persistent data. Superblocks queues one task to create an isolated logical database on a cluster that already has capacity under your pool settings; if none does, it provisions a new cluster first. Your data plane claims the task, runstofu apply in your AWS account, and reports back when the database is ready. Superblocks only queues the work. Your data plane is the only thing that calls AWS.
The result is a PostgreSQL database and a matching role inside an existing cluster, named sbndb_<profile-token>_<application-token> and sbndb_<profile-token>_<application-token>_runtime (see Data tag names for how the tokens are derived). Each app gets one database per data tag the data plane serves.
The lifecycle worker creates both and grants the role rds_iam. The connector role stays out of it until the app runs a query.
Applying schema migrations
Clark writes the schema as.sql files in the application’s own code, so it is versioned alongside the app. Once a database reports ready, Superblocks queues a migration task and your data plane applies those files to that one database. No AWS resources change.
Edit-time and deploy-time databases use the same path.
Connecting
- ECS Fargate
- EKS (Helm)
- Provisioning creates the app’s PostgreSQL role (
sbndb_<profile-token>_<application-token>_runtime) and grants itrds_iam. That grant makes PostgreSQL delegate authentication for this role to IAM instead of checking a stored password. No password is ever generated for it. - The data plane checks the target against the connector role ARNs in its own configuration, and refuses the connection if they disagree.
- The data plane assumes the connector role, attaching an inline session policy that allows exactly one action on exactly one resource:
rds-db:connecton this cluster, as this one PostgreSQL role. - It signs an RDS IAM auth token for that PostgreSQL role with those session credentials, and connects using the token as the password, over TLS with full certificate and hostname verification.
sbndb_<profile-token>_*_runtime users for the declared data tags (see Raw IAM policy JSON), and the per-connection session policy narrows that to the single user for the single app being served. One app’s credentials cannot reach another app’s database, even though both use the same connector role.
Deploying
When a builder publishes an app, the deploy-time data tag gets its own database. If the app does not have one there yet, Superblocks queues a create task and then a migration task, producing the same database and role as at edit time under a different tag. The deploy does not proceed until both finish. No data is copied. The deploy-time database gets the schema and none of the development rows.Observing
- ECS Fargate
- EKS (Helm)
- Standard RDS CloudWatch metrics
- Enhanced Monitoring at 60-second resolution through the account- and region-level
RDSOSMetricsCloudWatch Logs group, with one log stream per database instance. Its retention is independent of the per-cluster PostgreSQL log group’s 30-day setting - Database Insights Standard, with Performance Insights retained for 7 days
- PostgreSQL logs exported to CloudWatch Logs with 30-day retention
- PostgreSQL diagnostic logs for lock waits and deadlocks, temporary files larger than 64 MiB, checkpoints, and autovacuum operations running at least 5 seconds
- Slow-query logging for every statement running at least 5 seconds and a 10% sample of statements running between 1 and 5 seconds. Statements under 1 second are not duration-logged. Bound parameter values are excluded, although the parameterized statement text is retained
pg_stat_statementstracking for top-level statements. Its storage is bounded and shared across the cluster, so older or less-used entries can be evicted; the module does not fix a universal entry limit
How everything is tagged
Every App Database AWS resource carries two kinds of tags. IAM scoping tags (required for the lifecycle worker to mutate the resource):AgentName={agents-map-key}, the agent name for this data plane (Terraformagentsmap key, Fargateagent_name, or Helmsuperblocks.agentName)ManagedBy=superblocks-app-database-lifecycleVpc={your-vpc-id}
AgentName itself; physical modules merge ManagedBy; Vpc comes from your networking inputs.
Ownership and partnership tags (required on every Superblocks-created resource, including runtime Aurora):
superblocks:owned=trueaws-apn-id=pc:ctelqp437y3cvjkv5rv0z2w4f
aws-apn-id is identical in every deployment rather than per-account.
app-db-prereqs enforces both keys on setup resources. The physical OpenTofu modules stamp them on runtime Aurora, RDS, subnet groups, and security groups, overwriting conflicting caller values. The Fargate app-db module also merges the same pair into runtime module inputs. On EKS, databaseLifecycle.physicalModuleTags is for optional inventory tags only (cost center, team, and similar). Do not set ManagedBy, AgentName, Vpc, superblocks:owned, or aws-apn-id there.
The lifecycle worker IAM policy also denies removing any of the five keys above (AgentName, ManagedBy, Vpc, superblocks:owned, and aws-apn-id), including via named Deny Sids (DenyRemoveProtectedTags / DenyDeleteProtectedTags / DenyUntagProtectedTags) so AccessDenied is attributable. EC2 DenyDeleteProtectedTags is further scoped with AgentName / ManagedBy / Vpc ResourceTags so attaching policies via existing_role_name cannot Deny DeleteTags on unrelated security groups.
A Resource Group or the CLI lists everything the feature manages:
S3 state bucket properties
The Terraform module creates the state bucket, so these properties only matter if your organization creates it by hand instead.Required S3 state bucket properties
Required S3 state bucket properties
Raw IAM policy JSON
The Terraform module creates the following IAM resources per data plane. If your organization requires policies to be pre-approved or manually provisioned before Terraform can attach them, use the JSON below.iam_name_prefix prefixes the IAM role and policy names below, and s3_name_prefix prefixes the state bucket name. Both default to sb-app-db.
Placeholders to substitute before use:
Lifecycle worker role: trust policy (ECS Fargate)
Lifecycle worker role: trust policy (ECS Fargate)
Lifecycle worker role: trust policy (EKS with IRSA)
Lifecycle worker role: trust policy (EKS with IRSA)
<OIDC_PROVIDER_URL> is the OIDC provider URL without the ARN prefix (everything after oidc-provider/). The sub condition scopes trust to the specific Kubernetes service account. Adjust namespace and service_account_name if you deviate from the Superblocks defaults.Policy 1: Assume connector (sb-app-db-<AGENT_NAME>-assume-connector)
Policy 1: Assume connector (sb-app-db-<AGENT_NAME>-assume-connector)
Policy 2: State backend (sb-app-db-<AGENT_NAME>-state-bucket-<REGION>)
Policy 2: State backend (sb-app-db-<AGENT_NAME>-state-bucket-<REGION>)
key_prefix only (default app-db/<AGENT_NAME>; override via agents["<AGENT_NAME>"].key_prefix). Replace <KEY_PREFIX> with the same string you wire into the data plane (see Agent name).GetBucketLocation / GetBucketVersioning live in a separate statement from ListBucket because those metadata APIs do not send s3:prefix and would fail closed if they shared the list condition.The default bucket uses SSE-S3. The released module emits no StateBucketKms statement in that case. Do not add a Resource: "*" KMS grant to match older examples; that widens decrypt for brownfield roles that already have broader S3 permissions.StateBucketKms is present only when you set kms_key_arn (SSE-KMS). It names that key ARN and has no CalledVia condition:Policy 3: RDS provisioning (sb-app-db-<AGENT_NAME>-rds-provisioning-<REGION>)
Policy 3: RDS provisioning (sb-app-db-<AGENT_NAME>-rds-provisioning-<REGION>)
sb-* namespace and the declared VPC. All create actions enforce tagging at request time, publicly_accessible = false, and manage_master_user_password = true. The RdsCreateDbInstance and RdsCreateAuroraClusterInstance statements both cover rds:CreateDBInstance but are cleanly discriminated: standalone RDS creates pass rds:ManageMasterUserPassword = true and Aurora member creates do not, so each request matches exactly one statement. iam:CreateServiceLinkedRole is scoped to rds.amazonaws.com; it runs once, and only if your account does not already have the RDS service-linked role.Policy 4: RDS mutation (sb-app-db-<AGENT_NAME>-rds-mutation-<REGION>)
Policy 4: RDS mutation (sb-app-db-<AGENT_NAME>-rds-mutation-<REGION>)
AgentName, ManagedBy, and Vpc tags, so the lifecycle worker cannot modify any RDS resource it did not create. RdsRemoveTagsExceptProtectedTags only Allows removing non-protected tags. DenyRemoveProtectedTags is an explicit Deny that blocks removing AgentName, ManagedBy, Vpc, superblocks:owned, or aws-apn-id, so AccessDenied is attributable to that Sid.Policy 5: EC2 networking (sb-app-db-<AGENT_NAME>-ec2-provisioning-<REGION>)
Policy 5: EC2 networking (sb-app-db-<AGENT_NAME>-ec2-provisioning-<REGION>)
Ec2CreateSecurityGroupVpcScope statement additionally restricts CreateSecurityGroup to the specific VPC resource via ec2:VpcID, so the lifecycle worker cannot create security groups in any other VPC. Ec2CreateTagsOnCreateSecurityGroup authorizes create-time tagging for CreateSecurityGroup and for AuthorizeSecurityGroupIngress / AuthorizeSecurityGroupEgress, so ownership tags can land on both the security group and its individual rules when they are created. Ec2DeleteTagsExceptProtectedTags only Allows removing non-protected tags from managed security groups and rules when aws:TagKeys is present; DenyDeleteProtectedTags Denies removing the scoping and ownership keys; DenyDeleteTagsWhenTagKeysAbsent Denies the delete-all request that omits Tags / aws:TagKeys.Policy 6: Secrets Manager (sb-app-db-<AGENT_NAME>-secrets-<REGION>)
Policy 6: Secrets Manager (sb-app-db-<AGENT_NAME>-secrets-<REGION>)
aws:CalledVia: rds.amazonaws.com, so the lifecycle worker cannot directly create Secrets Manager secrets; only RDS can, on its behalf. The worker reads these secrets by ref when provisioning logical databases, so there is no plaintext master credential in Superblocks systems.The last statement (DecryptRdsManagedSecretKmsKey) handles KMS decryption for the RDS-managed master secret. Without a customer KMS key (rds_secret_kms_key_arn not set), Resource is "*" and access is constrained via kms:ViaService and kms:EncryptionContext as shown below. With a customer KMS key, replace "Resource": "*" with the specific key ARN.Connector role: trust policy
Connector role: trust policy
Connector role: policy (superblocks-app-db-connector-<AGENT_NAME>)
Connector role: policy (superblocks-app-db-connector-<AGENT_NAME>)
rds-db:connect for each declared data tag, scoped to DB users matching sbndb_<profile-token>_*_runtime on both RDS instances and Aurora clusters. <profile-token> is the first 16 hex characters of SHA-256(lowercase(tag)), which you can compute with printf '%s' '<DATA_TAG>' | shasum -a 256 | cut -c1-16. Add or remove statements to match your declared agent_tags. The example below shows two data tags (<PROFILE_TOKEN_1> and <PROFILE_TOKEN_2>).This is the outer bound, not the effective permission on a connection. See Connecting.Policy 7: Observability (sb-app-db-<AGENT_NAME>-observability-<REGION>)
Policy 7: Observability (sb-app-db-<AGENT_NAME>-observability-<REGION>)
logs:DescribeLogGroups must be scoped to "Resource": "*" because AWS does not support resource-level restrictions on describe log group APIs.logs:UntagResource is split into an Allow that excludes protected keys (CloudWatchUntagExceptProtectedTags) and an explicit Deny (DenyUntagProtectedTags), matching the RDS/EC2 protected-tag pattern. logs:TagResource is similarly split (CloudWatchTagResourceWithCanonicalOwnership) so ownership request-tag values cannot be overwritten.iam:PassRole is conditioned on iam:PassedToService: rds.amazonaws.com, which is the service principal that RDS uses when calling CreateDBInstance. It is not monitoring.rds.amazonaws.com, which is what the monitoring agent assumes later.Enhanced Monitoring role: trust and permissions
Enhanced Monitoring role: trust and permissions
app_db_prereqs creates one account-level role named sb-app-db-enhanced-monitoring (or <iam_name_prefix>-enhanced-monitoring) with the following trust policy. Additional regional prerequisite stacks reuse this same role through existing_monitoring_role_arn. If your organization must create IAM roles out of band, create the role with this policy and pass its ARN as existing_monitoring_role_arn to every prerequisite invocation.arn:aws:iam::aws:policy/service-role/AmazonRDSEnhancedMonitoringRole policy to this role. If you create the role out of band, attach the same policy. The trust policy uses monitoring.rds.amazonaws.com, while the lifecycle worker’s iam:PassRole condition above intentionally uses rds.amazonaws.com; these principals serve different parts of the Enhanced Monitoring flow.
