Loading...
AWS Series Part 2 of 5 Database
April 14, 2026

Payload CMS on AWS ECS Fargate

Part 2: RDS Aurora PostgreSQL and Secrets Manager

Production Payload CMS on AWS — 5-Part Series

Part 1: Architecture Overview  |  Part 2: RDS Aurora PostgreSQL (this post)  |  Part 3: Docker and the importMap  |  Part 4: ALB Path-Based Routing  |  Part 5: Lexical Rich Text on the Frontend

Prerequisites: Part 1 complete, AWS CLI configured, VPC with at least 2 subnets in different AZs

Time: 45 to 60 minutes

The data layer is the foundation everything else sits on. Getting security groups and secret references right here saves significant debugging time in every subsequent part. Do not skip steps or substitute values until you understand what each one does.

In this part we configure four things: the Aurora PostgreSQL cluster, a dedicated database user with least-privilege access, all credentials in AWS Secrets Manager, and the IAM role that allows ECS tasks to read those secrets at runtime.

Step 1: Create the Security Groups

Two security groups before anything else — one for the database, one for ECS tasks. The database security group's inbound rule will reference the ECS security group by ID, not by IP range.

# Database security group
DB_SG=$(aws ec2 create-security-group \
  --group-name payload-db-sg \
  --description "Payload CMS RDS access" \
  --vpc-id YOUR_VPC_ID \
  --query 'GroupId' \
  --output text)

echo "DB Security Group: $DB_SG"

# ECS task security group
ECS_SG=$(aws ec2 create-security-group \
  --group-name payload-ecs-sg \
  --description "Payload CMS ECS tasks" \
  --vpc-id YOUR_VPC_ID \
  --query 'GroupId' \
  --output text)

echo "ECS Security Group: $ECS_SG"

# Allow inbound HTTP on port 3000 (locked to ALB in Part 4)
aws ec2 authorize-security-group-ingress \
  --group-id $ECS_SG \
  --protocol tcp \
  --port 3000 \
  --cidr 0.0.0.0/0

# Allow ECS tasks to reach the database
aws ec2 authorize-security-group-ingress \
  --group-id $DB_SG \
  --protocol tcp \
  --port 5432 \
  --source-group $ECS_SG

The --source-group rule is more precise than a CIDR range — it allows connections from any network interface assigned the ECS security group, and updates automatically as tasks scale in and out.

Step 2: Create the RDS Subnet Group

Aurora requires subnets in at least two availability zones.

aws rds create-db-subnet-group \
  --db-subnet-group-name payload-db-subnets \
  --db-subnet-group-description "Payload CMS database subnets" \
  --subnet-ids subnet-XXXXXXXX subnet-YYYYYYYY

Step 3: Create the Aurora PostgreSQL Cluster

aws rds create-db-cluster \
  --db-cluster-identifier payload-cms-cluster \
  --engine aurora-postgresql \
  --engine-version 15.4 \
  --master-username payload_admin \
  --master-user-password YOUR_STRONG_PASSWORD_HERE \
  --db-subnet-group-name payload-db-subnets \
  --vpc-security-group-ids $DB_SG \
  --no-publicly-accessible \
  --database-name payload_cms \
  --region us-east-1

# Create the instance within the cluster
aws rds create-db-instance \
  --db-instance-identifier payload-cms-instance \
  --db-cluster-identifier payload-cms-cluster \
  --engine aurora-postgresql \
  --db-instance-class db.t3.medium \
  --region us-east-1

# Wait for availability (5 to 10 minutes)
aws rds wait db-cluster-available \
  --db-cluster-identifier payload-cms-cluster

# Get the cluster endpoint — save this
aws rds describe-db-clusters \
  --db-cluster-identifier payload-cms-cluster \
  --query 'DBClusters[0].Endpoint' \
  --output text

Step 4: Create a Dedicated Database User

Never use the master user for your application. Connect to the database via your bastion and create a dedicated user:

-- Connect as master user, then:
CREATE USER payload_app WITH PASSWORD 'your-app-password';
CREATE DATABASE payload_cms_app;
GRANT ALL PRIVILEGES ON DATABASE payload_cms_app TO payload_app;
ALTER DATABASE payload_cms_app OWNER TO payload_app;

-- Grant schema privileges (required for Payload's table creation)
\c payload_cms_app
GRANT ALL ON SCHEMA public TO payload_app;

The master user has superuser privileges. If your application credentials are compromised, a dedicated user with database-scoped permissions limits the blast radius.

Step 5: Store Credentials in Secrets Manager

Instead of pasting connection strings into environment variables — where they are visible in the console and in logs — we store them encrypted in Secrets Manager and reference them by ARN in ECS task definitions.

# Store the database URL
DB_SECRET_ARN=$(aws secretsmanager create-secret \
  --name "payload/database-url" \
  --description "Payload CMS PostgreSQL connection string" \
  --secret-string "postgresql://payload_app:your-app-password@YOUR_CLUSTER_ENDPOINT:5432/payload_cms_app" \
  --query 'ARN' \
  --output text)

echo "Database URL Secret ARN: $DB_SECRET_ARN"

# Generate and store the Payload secret key
PAYLOAD_SECRET=$(openssl rand -base64 48)

PAYLOAD_SECRET_ARN=$(aws secretsmanager create-secret \
  --name "payload/secret-key" \
  --description "Payload CMS JWT signing secret" \
  --secret-string "$PAYLOAD_SECRET" \
  --query 'ARN' \
  --output text)

echo "Payload Secret ARN: $PAYLOAD_SECRET_ARN"

# Internal URL placeholder — update after Part 4 when ALB exists
INTERNAL_URL_ARN=$(aws secretsmanager create-secret \
  --name "payload/internal-url" \
  --description "Internal ALB URL for frontend to reach Payload" \
  --secret-string "http://PLACEHOLDER:3000" \
  --query 'ARN' \
  --output text)

echo "Internal URL Secret ARN: $INTERNAL_URL_ARN"
Secret ARN suffix: AWS appends a random 6-character suffix to secret names (e.g., payload/database-url-W8F0U4). Always use the full ARN, not just the name, when referencing secrets in ECS task definitions. Using the name alone causes resolution failures that are difficult to diagnose.

Step 6: Create the ECS Task Execution Role

# Create the role
aws iam create-role \
  --role-name payload-ecs-task-execution-role \
  --assume-role-policy-document '{
    "Version": "2012-10-17",
    "Statement": [{
      "Effect": "Allow",
      "Principal": { "Service": "ecs-tasks.amazonaws.com" },
      "Action": "sts:AssumeRole"
    }]
  }'

# Attach the standard ECS execution policy (ECR pull, CloudWatch logs)
aws iam attach-role-policy \
  --role-name payload-ecs-task-execution-role \
  --policy-arn arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy

# Add Secrets Manager read permission scoped to the payload/ prefix
aws iam put-role-policy \
  --role-name payload-ecs-task-execution-role \
  --policy-name SecretsManagerReadPolicy \
  --policy-document '{
    "Version": "2012-10-17",
    "Statement": [{
      "Effect": "Allow",
      "Action": [
        "secretsmanager:GetSecretValue",
        "secretsmanager:DescribeSecret"
      ],
      "Resource": "arn:aws:secretsmanager:us-east-1:YOUR_ACCOUNT_ID:secret:payload/*"
    }]
  }'

Step 7: How Secrets Inject into ECS Tasks

When you reference secrets in an ECS task definition, they are injected as environment variables at container startup. The container sees them as normal environment variables — the actual values never appear in the task definition, the console, or CloudTrail logs. We will use this pattern fully in Part 3:

{
  "secrets": [
    {
      "name": "DATABASE_URL",
      "valueFrom": "arn:aws:secretsmanager:us-east-1:ACCOUNT:secret:payload/database-url-SUFFIX"
    },
    {
      "name": "PAYLOAD_SECRET",
      "valueFrom": "arn:aws:secretsmanager:us-east-1:ACCOUNT:secret:payload/secret-key-SUFFIX"
    }
  ]
}

Step 8: Verify Connectivity

Before moving to Part 3, verify the database is reachable from within your VPC using SSM Session Manager — no SSH key required:

# Start SSM session to bastion
aws ssm start-session --target i-YOUR_BASTION_INSTANCE_ID

# From within the session, test PostgreSQL connectivity
psql postgresql://payload_app:your-password@YOUR_CLUSTER_ENDPOINT:5432/payload_cms_app -c "SELECT version();"

If this succeeds, your database, security groups, and network configuration are correct.

Checklist Before Part 3

  • Aurora PostgreSQL cluster created and available
  • Dedicated payload_app database user created with schema privileges
  • payload/database-url secret stored in Secrets Manager
  • payload/secret-key secret stored in Secrets Manager
  • payload/internal-url placeholder secret created
  • ECS task execution role created with Secrets Manager read permission
  • DB security group allows inbound only from ECS security group
  • Database connectivity verified from within VPC

Common Issues

Cluster creation fails: "No default subnet group"
You need to create the DB subnet group first (Step 2) and reference it explicitly. Aurora does not use the default VPC subnet group.

Cannot connect to database from bastion
Add a separate inbound rule to the DB security group for your bastion's security group. The ECS SG rule only covers ECS tasks.

Secret ARN suffix changes
Always use the full ARN returned from the create-secret command. Never construct the ARN manually.


Part 3 covers the Docker build for Payload CMS — including the importMap problem that silently breaks rich text fields in containerized deployments, the root layout restriction that causes hydration error #418, and the exact Dockerfile that works in production.

The data layer built here is what powers the Chippewa County municipal CMS. Read the case study.

Share Article
Need production-grade AWS infrastructure?

We deploy headless CMS platforms to AWS at fixed price.

ECS Fargate, Aurora PostgreSQL, CloudFront, WAF. Fixed price confirmed within 48 hours.

Jae S. Jung has been building since 1997: infrastructure, SaaS platforms, legacy migrations, distributed teams across four continents. Not drawing diagrams and handing them off. Actually building. That's the philosophy behind WAM DevTech. AI doesn't replace nearly 30 years of that. It amplifies it.

Share Article