Skip to main content
AWS ECS with Fargate provides a serverless container orchestration platform for running PowerSync without managing servers.

Prerequisites

Before deploying PowerSync on AWS ECS, ensure you have:
  • AWS account with permissions for EC2, ECS, ALB, IAM and Secrets Manager
  • AWS CLI installed and configured
  • Understanding of the deployment architecture for production vs development setup

1. PowerSync Configuration

Create your service.yaml configuration file following the Self-Hosted Configuration Guide. Your configuration must include:
  • Sync Streams (or legacy Sync Rules): Define which data to sync to clients
  • Client Auth: Your authentication provider’s JWKS
  • Source Database: Connection details for your source database
  • Telemetry: Enable the Prometheus metrics endpoint for connection-based auto-scaling (used in the Auto Scaling section):
    telemetry:
      prometheus_port: 9090
    
  • Bucket Storage: Connection details for your bucket storage database. PowerSync supports MongoDB or Postgres as bucket storage databases. In this guide, we focus on MongoDB.
    For bucket storage, we recommend configuring an AWS PrivateLink to establish a secure, private connection between your ECS tasks and MongoDB Atlas that doesn’t traverse the public internet.Follow the AWS PrivateLink guide for MongoDB Atlas to configure the VPC endpoint and update your MongoDB connection string to use the private endpoint. As seen in the Secrets Manager setup, use the updated connection string in your PS_MONGO_URI secret.

2. VPC and Networking Setup

This guide uses bash variables throughout for easy copy-paste execution.
# Set your AWS region and account ID
AWS_REGION="us-east-1"  # Change to your region
AWS_ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)
# Set your VPC ID (or create a new VPC)
VPC_ID="vpc-xxxxx"
# Set PowerSync version (check Docker Hub for latest: https://hub.docker.com/r/journeyapps/powersync-service/tags)
PS_VERSION="1.20.1" 

VPC Architecture Overview

PowerSync on ECS requires a VPC with both public and private subnets:
  • Public subnets: Host the Application Load Balancer (ALB) and NAT Gateway with direct internet access
  • Private subnets: Host ECS tasks for security, with outbound-only internet access via NAT Gateway
Network Flow:
Internet → Internet Gateway → Public Subnets (ALB, NAT) → Private Subnets (ECS Tasks)
Default VPC users: The AWS default VPC only contains public subnets. You must create private subnets following the steps below.

Check Existing Subnets

# List all subnets in your VPC
aws ec2 describe-subnets \
  --filters "Name=vpc-id,Values=$VPC_ID" \
  --query 'Subnets[*].[SubnetId,CidrBlock,MapPublicIpOnLaunch,AvailabilityZone]' \
  --output table
If MapPublicIpOnLaunch is True, those are public subnets. Save the public subnet IDs:
# Get public subnets (for ALB and NAT Gateway)
PUBLIC_SUBNET_1=$(aws ec2 describe-subnets \
  --filters "Name=vpc-id,Values=$VPC_ID" "Name=map-public-ip-on-launch,Values=true" \
  --query 'Subnets[0].SubnetId' --output text)

PUBLIC_SUBNET_2=$(aws ec2 describe-subnets \
  --filters "Name=vpc-id,Values=$VPC_ID" "Name=map-public-ip-on-launch,Values=true" \
  --query 'Subnets[1].SubnetId' --output text)

echo "Public Subnet 1: $PUBLIC_SUBNET_1"
echo "Public Subnet 2: $PUBLIC_SUBNET_2"

Create Private Subnets

Create two private subnets in different availability zones for high availability:
# Get available zones in your region
AZ1=$(aws ec2 describe-availability-zones --region $AWS_REGION --query 'AvailabilityZones[0].ZoneName' --output text)
AZ2=$(aws ec2 describe-availability-zones --region $AWS_REGION --query 'AvailabilityZones[1].ZoneName' --output text)

echo "Availability Zone 1: $AZ1"
echo "Availability Zone 2: $AZ2"

# Get VPC CIDR to determine available address space
VPC_CIDR=$(aws ec2 describe-vpcs --vpc-ids $VPC_ID --query 'Vpcs[0].CidrBlock' --output text)
echo "VPC CIDR: $VPC_CIDR"

# Create first private subnet (adjust CIDR if conflicts exist)
PRIVATE_SUBNET_1=$(aws ec2 create-subnet \
  --vpc-id $VPC_ID \
  --cidr-block 172.31.96.0/20 \
  --availability-zone $AZ1 \
  --tag-specifications 'ResourceType=subnet,Tags=[{Key=Name,Value=powersync-private-1}]' \
  --query 'Subnet.SubnetId' \
  --output text)

echo "Private Subnet 1: $PRIVATE_SUBNET_1"

# Create second private subnet (adjust CIDR if conflicts exist)
PRIVATE_SUBNET_2=$(aws ec2 create-subnet \
  --vpc-id $VPC_ID \
  --cidr-block 172.31.112.0/20 \
  --availability-zone $AZ2 \
  --tag-specifications 'ResourceType=subnet,Tags=[{Key=Name,Value=powersync-private-2}]' \
  --query 'Subnet.SubnetId' \
  --output text)

echo "Private Subnet 2: $PRIVATE_SUBNET_2"
CIDR Block Configuration: The example uses 172.31.96.0/20 and 172.31.112.0/20, which work for the default VPC (172.31.0.0/16). If you get a CIDR conflict error, adjust these blocks to match unused address space in your VPC. Each /20 block provides 4,096 IP addresses.
Create Route Table for Private Subnets:
# Create private route table
PRIVATE_RTB=$(aws ec2 create-route-table \
  --vpc-id $VPC_ID \
  --tag-specifications 'ResourceType=route-table,Tags=[{Key=Name,Value=powersync-private-rtb}]' \
  --query 'RouteTable.RouteTableId' \
  --output text)

echo "Private Route Table: $PRIVATE_RTB"

# Associate private subnets with route table
aws ec2 associate-route-table \
  --route-table-id $PRIVATE_RTB \
  --subnet-id $PRIVATE_SUBNET_1

aws ec2 associate-route-table \
  --route-table-id $PRIVATE_RTB \
  --subnet-id $PRIVATE_SUBNET_2

echo "Private subnets created and associated with route table"

NAT Gateway Setup

ECS tasks in private subnets need outbound internet access for:
  • Pulling container images from Amazon ECR
  • Fetching JWKS for authentication (if applicable in your client authentication setup)
  • Connecting to external services
Create NAT Gateway:
# Allocate Elastic IP
EIP_ALLOC=$(aws ec2 allocate-address \
  --domain vpc \
  --query 'AllocationId' \
  --output text)

echo "Elastic IP Allocation: $EIP_ALLOC"

# Create NAT Gateway in a PUBLIC subnet
NAT_GW=$(aws ec2 create-nat-gateway \
  --subnet-id $PUBLIC_SUBNET_1 \
  --allocation-id $EIP_ALLOC \
  --query 'NatGateway.NatGatewayId' \
  --output text)

echo "NAT Gateway: $NAT_GW"

# Wait for NAT Gateway to become available (takes ~2 minutes)
echo "Waiting for NAT Gateway to become available (this takes ~2 minutes)..."
aws ec2 wait nat-gateway-available --nat-gateway-ids $NAT_GW
echo "NAT Gateway is now available"
Add Route to Private Route Table:
# Add default route to NAT Gateway in private route table
aws ec2 create-route \
  --route-table-id $PRIVATE_RTB \
  --destination-cidr-block 0.0.0.0/0 \
  --nat-gateway-id $NAT_GW
Verify Setup:
# Verify private route table
aws ec2 describe-route-tables \
  --route-table-ids $PRIVATE_RTB \
  --query 'RouteTables[0].Routes' \
  --output table

# Should show:
# - 172.31.0.0/16 -> local (VPC internal routing)
# - 0.0.0.0/0 -> nat-xxxxx (Internet via NAT)

Create Security Groups

# ALB security group (allows HTTPS from internet)
ALB_SG=$(aws ec2 create-security-group \
  --group-name powersync-alb-sg \
  --description "PowerSync ALB" \
  --vpc-id $VPC_ID \
  --query 'GroupId' --output text)

echo "ALB Security Group: $ALB_SG"

aws ec2 authorize-security-group-ingress \
  --group-id $ALB_SG \
  --protocol tcp --port 443 --cidr 0.0.0.0/0

# ECS security group (allows traffic from ALB only)
ECS_SG=$(aws ec2 create-security-group \
  --group-name powersync-ecs-sg \
  --description "PowerSync ECS tasks" \
  --vpc-id $VPC_ID \
  --query 'GroupId' --output text)

echo "ECS Security Group: $ECS_SG"

aws ec2 authorize-security-group-ingress \
  --group-id $ECS_SG \
  --protocol tcp --port 8080 --source-group $ALB_SG

echo "Security groups created successfully"

3. Application Load Balancer

Domain Setup

PowerSync requires a domain name for SSL certificate provisioning. You can either:
  • Use an existing domain by creating a Route 53 hosted zone and updating your registrar’s nameservers
  • Register a new domain directly through Route 53
For detailed instructions, follow the official AWS guides: Once your hosted zone is created, export the zone ID:
export HOSTED_ZONE_ID=$(aws route53 list-hosted-zones-by-name \
  --dns-name yourdomain.com \
  --query 'HostedZones[0].Id' \
  --output text)

echo "Hosted Zone ID: $HOSTED_ZONE_ID"

Request SSL Certificate

For secure HTTPS connections, request an SSL certificate using AWS Certificate Manager (ACM):
# Set your domain name
POWERSYNC_DOMAIN="powersync.yourdomain.com"  # Change to your domain

# Request certificate
CERT_ARN=$(aws acm request-certificate \
  --domain-name $POWERSYNC_DOMAIN \
  --validation-method DNS \
  --region $AWS_REGION \
  --query 'CertificateArn' \
  --output text)

echo "Certificate ARN: $CERT_ARN"

# Get validation record details
VALIDATION_NAME=$(aws acm describe-certificate \
  --certificate-arn $CERT_ARN \
  --region $AWS_REGION \
  --query 'Certificate.DomainValidationOptions[0].ResourceRecord.Name' \
  --output text)

VALIDATION_VALUE=$(aws acm describe-certificate \
  --certificate-arn $CERT_ARN \
  --region $AWS_REGION \
  --query 'Certificate.DomainValidationOptions[0].ResourceRecord.Value' \
  --output text)

echo "Validation Name: $VALIDATION_NAME"
echo "Validation Value: $VALIDATION_VALUE"
Add DNS Validation Record:
Add the CNAME record using your DNS provider’s management console:
TypeNameValueTTL
CNAME[VALIDATION_NAME][VALIDATION_VALUE]300
Wait for Certificate Validation:
aws acm wait certificate-validated --certificate-arn $CERT_ARN --region $AWS_REGION

Create ALB

# Create load balancer
ALB_ARN=$(aws elbv2 create-load-balancer \
  --name powersync-alb \
  --subnets $PUBLIC_SUBNET_1 $PUBLIC_SUBNET_2 \
  --security-groups $ALB_SG \
  --scheme internet-facing \
  --query 'LoadBalancers[0].LoadBalancerArn' \
  --output text)

echo "ALB ARN: $ALB_ARN"

# Create target group
TG_ARN=$(aws elbv2 create-target-group \
  --name powersync-tg \
  --protocol HTTP \
  --port 8080 \
  --vpc-id $VPC_ID \
  --target-type ip \
  --health-check-path /probes/liveness \
  --health-check-interval-seconds 30 \
  --query 'TargetGroups[0].TargetGroupArn' \
  --output text)

echo "Target Group ARN: $TG_ARN"

# Create HTTPS listener
LISTENER_ARN=$(aws elbv2 create-listener \
  --load-balancer-arn $ALB_ARN \
  --protocol HTTPS \
  --port 443 \
  --certificates CertificateArn=$CERT_ARN \
  --default-actions Type=forward,TargetGroupArn=$TG_ARN \
  --query 'Listeners[0].ListenerArn' \
  --output text)

echo "Listener ARN: $LISTENER_ARN"

# Configure WebSocket support
# PowerSync uses long-lived WebSocket connections for real-time sync
# Default ALB timeout is 60s, which would disconnect clients prematurely
# Setting to 3600s (1 hour) prevents unnecessary disconnections
aws elbv2 modify-load-balancer-attributes \
  --load-balancer-arn $ALB_ARN \
  --attributes Key=idle_timeout.timeout_seconds,Value=3600

4. DNS Configuration

Point your domain to the load balancer:
# Get ALB DNS name
ALB_DNS=$(aws elbv2 describe-load-balancers \
  --names powersync-alb \
  --query 'LoadBalancers[0].DNSName' \
  --output text)

ALB_ZONE=$(aws elbv2 describe-load-balancers \
  --names powersync-alb \
  --query 'LoadBalancers[0].CanonicalHostedZoneId' \
  --output text)

echo "ALB DNS: $ALB_DNS"
echo "ALB Zone: $ALB_ZONE"

Create a CNAME record pointing to the ALB DNS name.
TypeNameValueTTL
CNAMEpowersync.yourdomain.com[ALB_DNS]300

5. Secrets Manager

Store your PowerSync configuration and connection strings securely in AWS Secrets Manager. This allows you to reference them in your ECS task definition without hardcoding sensitive information.
# Store config (base64-encoded, as required by the POWERSYNC_CONFIG_B64 env variable)
aws secretsmanager create-secret \
  --name powersync/config \
  --secret-string "$(base64 -i service.yaml)"

# Store connection strings

# Set your source database connection string (e.g., PostgreSQL, MongoDB, MySQL, or SQL Server)
aws secretsmanager create-secret \
  --name powersync/data-source-uri \
  --secret-string "postgresql://user:pass@host:5432/db" 

# Set your replication bucket storage connection string (e.g., MongoDB or Postgres)
aws secretsmanager create-secret \
  --name powersync/storage-uri \
  --secret-string "mongodb://user:pass@host:27017/?replicaSet=rs0" 

aws secretsmanager create-secret \
  --name powersync/jwks-url \
  --secret-string "https://your-auth-provider.com/.well-known/jwks.json"
AWS Secrets Manager automatically appends a 6-character suffix to secret ARNs (e.g., powersync/config-AbCdEf).ECS task definitions support prefix matching, allowing you to reference secrets using just the base name:
  • Created as: powersync/config-AbCdEf (with suffix)
  • Referenced as: arn:aws:secretsmanager:region:account:secret:powersync/config (without suffix)
This means you don’t need to update task definitions when secrets are rotated.

6. ECS Task Definition

The ECS task definition specifies how to run the PowerSync container, including environment variables, secrets, resource limits, and health checks.

Create IAM Role

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

# Wait for role to propagate
sleep 10

aws iam attach-role-policy \
  --role-name PowerSyncTaskExecutionRole \
  --policy-arn arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy

# Add Secrets Manager access
aws iam put-role-policy \
  --role-name PowerSyncTaskExecutionRole \
  --policy-name SecretsAccess \
  --policy-document '{
    "Version": "2012-10-17",
    "Statement": [{
      "Effect": "Allow",
      "Action": ["secretsmanager:GetSecretValue"],
      "Resource": "arn:aws:secretsmanager:'$AWS_REGION':'$AWS_ACCOUNT_ID':secret:powersync/*"
    }]
  }'

# Create task role (used by running containers for CloudWatch metrics publishing)
aws iam create-role \
  --role-name PowerSyncTaskRole \
  --assume-role-policy-document '{
    "Version": "2012-10-17",
    "Statement": [{
      "Effect": "Allow",
      "Principal": {"Service": "ecs-tasks.amazonaws.com"},
      "Action": "sts:AssumeRole"
    }]
  }'

# Wait for role to propagate
sleep 10

# Add CloudWatch permissions for the CW Agent sidecar to publish metrics
aws iam put-role-policy \
  --role-name PowerSyncTaskRole \
  --policy-name CloudWatchMetrics \
  --policy-document '{
    "Version": "2012-10-17",
    "Statement": [{
      "Effect": "Allow",
      "Action": [
        "cloudwatch:PutMetricData",
        "logs:CreateLogGroup",
        "logs:CreateLogStream",
        "logs:PutLogEvents"
      ],
      "Resource": "*"
    }]
  }'

TASK_ROLE_ARN="arn:aws:iam::$AWS_ACCOUNT_ID:role/PowerSyncTaskRole"
echo "Task Role ARN: $TASK_ROLE_ARN"

# Save role ARNs
TASK_EXECUTION_ROLE_ARN="arn:aws:iam::$AWS_ACCOUNT_ID:role/PowerSyncTaskExecutionRole"
echo "Task Execution Role ARN: $TASK_EXECUTION_ROLE_ARN"
echo "Task Role ARN: $TASK_ROLE_ARN"

Create Cluster

aws ecs create-cluster \
  --cluster-name powersync-cluster \
  --capacity-providers FARGATE

Register Task Definition

The task definitions below allocate 2 vCPU and 4GB memory per container. You can adjust resources based on your workload — see Deployment Architecture for scaling guidance (recommended baseline: 1 vCPU, 2GB memory). Note that AWS Fargate enforces specific CPU/memory combinations — for example, 2 vCPU (2048 CPU units) requires at least 4GB (4096 MiB) memory.
For production deployments, run separate replication and API processes to enable zero-downtime rolling updates. This allows independent scaling of API containers.Create Replication Task Definition
cat > replication-task-definition.json <<EOF
{
  "family": "powersync-replication",
  "networkMode": "awsvpc",
  "requiresCompatibilities": ["FARGATE"],
  "cpu": "2048",
  "memory": "4096",
  "executionRoleArn": "$TASK_EXECUTION_ROLE_ARN",
  "containerDefinitions": [
    {
      "name": "powersync-replication",
      "image": "journeyapps/powersync-service:$PS_VERSION",
      "command": ["start", "-r", "sync"],
      "essential": true,
      "environment": [
        {"name": "NODE_OPTIONS", "value": "--max-old-space-size-percentage=80"}
      ],
      "secrets": [
        {"name": "POWERSYNC_CONFIG_B64", "valueFrom": "arn:aws:secretsmanager:$AWS_REGION:$AWS_ACCOUNT_ID:secret:powersync/config"},
        {"name": "PS_DATA_SOURCE_URI", "valueFrom": "arn:aws:secretsmanager:$AWS_REGION:$AWS_ACCOUNT_ID:secret:powersync/data-source-uri"},
        {"name": "PS_MONGO_URI", "valueFrom": "arn:aws:secretsmanager:$AWS_REGION:$AWS_ACCOUNT_ID:secret:powersync/storage-uri"},
        {"name": "PS_JWKS_URL", "valueFrom": "arn:aws:secretsmanager:$AWS_REGION:$AWS_ACCOUNT_ID:secret:powersync/jwks-url"}
      ],
      "logConfiguration": {
        "logDriver": "awslogs",
        "options": {
          "awslogs-group": "/ecs/powersync-replication",
          "awslogs-region": "$AWS_REGION",
          "awslogs-stream-prefix": "ecs",
          "awslogs-create-group": "true"
        }
      }
    }
  ]
}
EOF

aws ecs register-task-definition --cli-input-json file://replication-task-definition.json
Create API Task DefinitionThe API task definition includes a CloudWatch Agent sidecar that scrapes Prometheus metrics from the PowerSync container and publishes them to CloudWatch. This enables connection-based auto-scaling.
The CloudWatch Agent sidecar adds ~256MB memory overhead. The task definition below allocates 4096MB total (shared between both containers). If you need more headroom, increase the task memory to 5120MB or 6144MB.
First, create the CloudWatch Agent configuration. This tells the agent to scrape the PowerSync Prometheus endpoint on localhost:9090 and publish the powersync_concurrent_connections metric to CloudWatch:
cat > cw-agent-config.json <<'CWEOF'
{
  "logs": {
    "metrics_collected": {
      "prometheus": {
        "log_group_name": "/ecs/powersync-api/prometheus",
        "prometheus_config_path": "env:PROMETHEUS_CONFIG_CONTENT",
        "emf_processor": {
          "metric_namespace": "PowerSync",
          "metric_declaration": [
            {
              "source_labels": ["job"],
              "label_matcher": "powersync",
              "dimensions": [["ClusterName"]],
              "metric_selectors": [
                "^powersync_concurrent_connections$"
              ]
            }
          ]
        }
      }
    }
  }
}
CWEOF
Store the CloudWatch Agent config in SSM Parameter Store:
aws ssm put-parameter \
  --name "/ecs/powersync/cwagent-config" \
  --type "String" \
  --value file://cw-agent-config.json \
  --overwrite

# Grant the execution role access to read the SSM parameter
aws iam put-role-policy \
  --role-name PowerSyncTaskExecutionRole \
  --policy-name SSMParameterAccess \
  --policy-document '{
    "Version": "2012-10-17",
    "Statement": [{
      "Effect": "Allow",
      "Action": ["ssm:GetParameters"],
      "Resource": "arn:aws:ssm:'$AWS_REGION':'$AWS_ACCOUNT_ID':parameter/ecs/powersync/*"
    }]
  }'
Now create the API task definition with both the PowerSync container and the CloudWatch Agent sidecar:
cat > api-task-definition.json <<EOF
{
  "family": "powersync-api",
  "networkMode": "awsvpc",
  "requiresCompatibilities": ["FARGATE"],
  "cpu": "2048",
  "memory": "4096",
  "executionRoleArn": "$TASK_EXECUTION_ROLE_ARN",
  "taskRoleArn": "$TASK_ROLE_ARN",
  "containerDefinitions": [
    {
      "name": "powersync-api",
      "image": "journeyapps/powersync-service:$PS_VERSION",
      "command": ["start", "-r", "api"],
      "essential": true,
      "portMappings": [
        {"containerPort": 8080, "protocol": "tcp"}
      ],
      "environment": [
        {"name": "PS_PORT", "value": "8080"},
        {"name": "NODE_OPTIONS", "value": "--max-old-space-size-percentage=80"}
      ],
      "secrets": [
        {"name": "POWERSYNC_CONFIG_B64", "valueFrom": "arn:aws:secretsmanager:$AWS_REGION:$AWS_ACCOUNT_ID:secret:powersync/config"},
        {"name": "PS_MONGO_URI", "valueFrom": "arn:aws:secretsmanager:$AWS_REGION:$AWS_ACCOUNT_ID:secret:powersync/storage-uri"},
        {"name": "PS_JWKS_URL", "valueFrom": "arn:aws:secretsmanager:$AWS_REGION:$AWS_ACCOUNT_ID:secret:powersync/jwks-url"}
      ],
      "logConfiguration": {
        "logDriver": "awslogs",
        "options": {
          "awslogs-group": "/ecs/powersync-api",
          "awslogs-region": "$AWS_REGION",
          "awslogs-stream-prefix": "ecs",
          "awslogs-create-group": "true"
        }
      },
      "healthCheck": {
        "command": ["CMD-SHELL", "curl -f http://localhost:8080/probes/liveness || exit 1"],
        "interval": 30,
        "timeout": 5,
        "retries": 3,
        "startPeriod": 60
      },
      "stopTimeout": 120
    },
    {
      "name": "cloudwatch-agent",
      "image": "public.ecr.aws/cloudwatch-agent/cloudwatch-agent:latest",
      "essential": false,
      "environment": [
        {
          "name": "PROMETHEUS_CONFIG_CONTENT",
          "value": "global:\n  scrape_interval: 30s\n  scrape_timeout: 10s\nscrape_configs:\n  - job_name: powersync\n    static_configs:\n      - targets:\n          - localhost:9090\n"
        }
      ],
      "secrets": [
        {
          "name": "CW_CONFIG_CONTENT",
          "valueFrom": "arn:aws:ssm:$AWS_REGION:$AWS_ACCOUNT_ID:parameter/ecs/powersync/cwagent-config"
        }
      ],
      "logConfiguration": {
        "logDriver": "awslogs",
        "options": {
          "awslogs-group": "/ecs/powersync-api/cwagent",
          "awslogs-region": "$AWS_REGION",
          "awslogs-stream-prefix": "ecs",
          "awslogs-create-group": "true"
        }
      },
      "memory": 256
    }
  ]
}
EOF

aws ecs register-task-definition --cli-input-json file://api-task-definition.json
The Prometheus port (9090) is not exposed through the ALB — it is only accessible within the task via localhost (ECS awsvpc networking). The CloudWatch Agent sidecar scrapes metrics locally every 30 seconds and publishes them to CloudWatch.

7. Deploy ECS Service

Create the ECS service to run PowerSync tasks
For production deployments, run separate replication and API processes to enable zero-downtime rolling updates. This allows independent scaling of API containers.Deploy Replication Service (1 Instance)
aws ecs create-service \
  --cluster powersync-cluster \
  --service-name powersync-replication \
  --task-definition powersync-replication \
  --desired-count 1 \
  --launch-type FARGATE \
  --network-configuration "awsvpcConfiguration={
    subnets=[$PRIVATE_SUBNET_1,$PRIVATE_SUBNET_2],
    securityGroups=[$ECS_SG],
    assignPublicIp=DISABLED
  }" \
  --deployment-configuration "minimumHealthyPercent=0,maximumPercent=100"
Deploy API Service (2+ Instances)
aws ecs create-service \
  --cluster powersync-cluster \
  --service-name powersync-api \
  --task-definition powersync-api \
  --desired-count 2 \
  --launch-type FARGATE \
  --network-configuration "awsvpcConfiguration={
    subnets=[$PRIVATE_SUBNET_1,$PRIVATE_SUBNET_2],
    securityGroups=[$ECS_SG],
    assignPublicIp=DISABLED
  }" \
  --load-balancers "targetGroupArn=$TG_ARN,containerName=powersync-api,containerPort=8080" \
  --health-check-grace-period-seconds 120 \
  --deployment-configuration "minimumHealthyPercent=100,maximumPercent=200"
Verify HA Deployment:
# Check replication service status
aws ecs describe-services \
  --cluster powersync-cluster \
  --services powersync-replication \
  --query 'services[0].[serviceName,status,runningCount,desiredCount]' \
  --output table

# Check API service status
aws ecs describe-services \
  --cluster powersync-cluster \
  --services powersync-api \
  --query 'services[0].[serviceName,status,runningCount,desiredCount]' \
  --output table

# Wait for tasks to be running (takes 2-3 minutes)
echo "Waiting for tasks to start..."
sleep 60

# Test endpoint (replace with your domain)
curl https://$POWERSYNC_DOMAIN/probes/liveness

# View API logs
aws logs tail /ecs/powersync-api --follow

# View replication logs
aws logs tail /ecs/powersync-replication --follow

Production Enhancements

For production deployments, consider adding the following enhancements: PowerSync requires daily compaction to optimize bucket storage. Schedule it as an ECS task with EventBridge:
Generate the compact task definition:
cat > compact-task-definition.json <<EOF
{
  "family": "powersync-compact",
  "networkMode": "awsvpc",
  "requiresCompatibilities": ["FARGATE"],
  "cpu": "1024",
  "memory": "2048",
  "executionRoleArn": "$TASK_EXECUTION_ROLE_ARN",
  "containerDefinitions": [
    {
      "name": "powersync-compact",
      "image": "journeyapps/powersync-service:$PS_VERSION",
      "command": ["compact"],
      "essential": true,
      "environment": [
        {"name": "NODE_OPTIONS", "value": "--max-old-space-size-percentage=80"}
      ],
      "secrets": [
        {"name": "POWERSYNC_CONFIG_B64", "valueFrom": "arn:aws:secretsmanager:$AWS_REGION:$AWS_ACCOUNT_ID:secret:powersync/config"},
        {"name": "PS_MONGO_URI", "valueFrom": "arn:aws:secretsmanager:$AWS_REGION:$AWS_ACCOUNT_ID:secret:powersync/storage-uri"}
      ],
      "logConfiguration": {
        "logDriver": "awslogs",
        "options": {
          "awslogs-group": "/ecs/powersync-compact",
          "awslogs-region": "$AWS_REGION",
          "awslogs-stream-prefix": "ecs",
          "awslogs-create-group": "true"
        }
      }
    }
  ]
}
EOF
Create IAM role for EventBridge and schedule with EventBridge (daily at 2 AM UTC):
# Register compact task
aws ecs register-task-definition --cli-input-json file://compact-task-definition.json

# Create EventBridge role
aws iam create-role \
  --role-name ecsEventsRole \
  --assume-role-policy-document '{
    "Version": "2012-10-17",
    "Statement": [{
      "Effect": "Allow",
      "Principal": {"Service": "events.amazonaws.com"},
      "Action": "sts:AssumeRole"
    }]
  }'

aws iam put-role-policy \
  --role-name ecsEventsRole \
  --policy-name ECS-Events-Policy \
  --policy-document '{
    "Version": "2012-10-17",
    "Statement": [{
      "Effect": "Allow",
      "Action": ["ecs:RunTask"],
      "Resource": "arn:aws:ecs:'$AWS_REGION':'$AWS_ACCOUNT_ID':task-definition/powersync-compact:*"
    }, {
      "Effect": "Allow",
      "Action": "iam:PassRole",
      "Resource": "'$TASK_EXECUTION_ROLE_ARN'"
    }]
  }'

export EVENTS_ROLE_ARN="arn:aws:iam::$AWS_ACCOUNT_ID:role/ecsEventsRole"

# Create schedule rule
aws events put-rule \
  --name powersync-compact-daily \
  --schedule-expression "cron(0 2 * * ? *)"

# Add target
aws events put-targets \
  --rule powersync-compact-daily \
  --targets '[{
    "Id": "powersync-compact",
    "Arn": "arn:aws:ecs:'$AWS_REGION':'$AWS_ACCOUNT_ID':cluster/powersync-cluster",
    "RoleArn": "'$EVENTS_ROLE_ARN'",
    "EcsParameters": {
      "TaskDefinitionArn": "arn:aws:ecs:'$AWS_REGION':'$AWS_ACCOUNT_ID':task-definition/powersync-compact",
      "TaskCount": 1,
      "LaunchType": "FARGATE",
      "NetworkConfiguration": {
        "awsvpcConfiguration": {
          "Subnets": ["'$PRIVATE_SUBNET_1'", "'$PRIVATE_SUBNET_2'"],
          "SecurityGroups": ["'$ECS_SG'"],
          "AssignPublicIp": "DISABLED"
        }
      }
    }
  }]'

Auto Scaling (High-Availability Setup)

PowerSync API containers are limited to 200 concurrent connections each, with a recommended target of 100 connections or less per container (see Deployment Architecture). Because PowerSync sync connections are long-lived (hours or days), CPU utilization alone may not reflect the actual connection load — a container can be near its connection limit while CPU remains relatively low. For this reason, we recommend scaling on both CPU utilization and concurrent connections.
ALB metrics are not suitable for PowerSync scaling. Metrics like ALBRequestCountPerTarget track request rate (requests per second), but PowerSync sync connections are long-lived HTTP streams or WebSockets — a single request stays open for hours or days. Similarly, ActiveConnectionCount tracks total connections across the entire ALB, not per target. Use the powersync_concurrent_connections Prometheus metric instead.

Prerequisites

Connection-based auto-scaling requires:
  1. Prometheus metrics enabled in your service.yaml (see Step 1):
    telemetry:
      prometheus_port: 9090
    
  2. CloudWatch Agent sidecar deployed in the API task definition (configured in Step 6). The sidecar scrapes the powersync_concurrent_connections metric from the PowerSync Prometheus endpoint and publishes it to CloudWatch under the PowerSync namespace.
  3. IAM permissions for the task role to publish CloudWatch metrics (configured in Step 6).

Register Scalable Target

Set the minimum and maximum number of API tasks:
  • min-capacity: We recommend at least 2 for high availability, ensuring your service stays available if one task fails. Auto-scaling handles load increases from there.
  • max-capacity: Set this to the upper bound of tasks you want auto-scaling to provision.
aws application-autoscaling register-scalable-target \
  --service-namespace ecs \
  --resource-id service/powersync-cluster/powersync-api \
  --scalable-dimension ecs:service:DesiredCount \
  --min-capacity 2 \
  --max-capacity 10
Choosing your minimum capacity: A minimum of 2 works well for most workloads, letting auto-scaling adjust capacity as needed. However, if your traffic is very spiky (e.g., many users connecting simultaneously at a predictable time), you may want a higher min-capacity to avoid waiting for new tasks to start. New Fargate tasks take 1-3 minutes to launch and pass health checks, so a larger baseline reduces the risk of connection overload during sudden spikes. As a guideline, each API task handles up to 200 concurrent connections (target ~100 for headroom).

Scaling Policy 1: CPU Utilization

This policy scales based on average CPU utilization across API tasks:
aws application-autoscaling put-scaling-policy \
  --service-namespace ecs \
  --resource-id service/powersync-cluster/powersync-api \
  --scalable-dimension ecs:service:DesiredCount \
  --policy-name cpu-scaling \
  --policy-type TargetTrackingScaling \
  --target-tracking-scaling-policy-configuration '{
    "TargetValue": 70.0,
    "PredefinedMetricSpecification": {"PredefinedMetricType": "ECSServiceAverageCPUUtilization"},
    "ScaleInCooldown": 300,
    "ScaleOutCooldown": 120
  }'

Scaling Policy 2: Concurrent Connections

This policy scales based on the average number of concurrent sync connections per task, using the custom metric published by the CloudWatch Agent sidecar:
aws application-autoscaling put-scaling-policy \
  --service-namespace ecs \
  --resource-id service/powersync-cluster/powersync-api \
  --scalable-dimension ecs:service:DesiredCount \
  --policy-name connection-scaling \
  --policy-type TargetTrackingScaling \
  --target-tracking-scaling-policy-configuration '{
    "TargetValue": 80.0,
    "CustomizedMetricSpecification": {
      "MetricName": "powersync_concurrent_connections",
      "Namespace": "PowerSync",
      "Statistic": "Average"
    },
    "ScaleInCooldown": 300,
    "ScaleOutCooldown": 120
  }'
How dual policies work: Both policies operate independently — ECS scales to whichever policy demands the higher number of tasks. For example, if CPU-based scaling wants 3 tasks but connection-based scaling wants 5, ECS runs 5 tasks.
Key configuration values:
ParameterValueRationale
TargetValue (connections)8040% of the 200 max connection limit per container. This matches PowerSync Cloud’s scaling strategy and provides headroom before the hard limit.
TargetValue (CPU)70.0Scale before CPU saturation impacts sync stream performance.
ScaleOutCooldown120sNew Fargate tasks take 1–3 minutes to start, pass health checks, and begin accepting connections. A shorter cooldown risks triggering multiple scale-out events before the first new task is ready.
ScaleInCooldown300sPrevents rapid scale-in oscillations. When a task is removed, its clients reconnect to remaining tasks, causing a temporary connection spike. The cooldown allows this spike to settle.

Scale-In Behavior

Scaling in (removing tasks) terminates active sync connections on the affected tasks. PowerSync client SDKs handle reconnection automatically, but there will be a brief interruption for affected clients. What happens during scale-in:
  1. ECS deregisters the task from the ALB target group — new connections are routed to other tasks
  2. The ALB deregistration delay allows existing connections to drain (default: 300s). Since sync streams never complete naturally, connections are forcefully closed after this timeout.
  3. ECS sends SIGTERM to the container — PowerSync closes all active sync streams gracefully
  4. After the stopTimeout period (configured to 120s in the task definition), ECS sends SIGKILL
  5. Disconnected clients automatically reconnect to remaining healthy tasks
To adjust the deregistration delay:
aws elbv2 modify-target-group-attributes \
  --target-group-arn $TG_ARN \
  --attributes Key=deregistration_delay.timeout_seconds,Value=300

Verify Auto-Scaling

After configuring both policies, verify they are active:
# List scaling policies
aws application-autoscaling describe-scaling-policies \
  --service-namespace ecs \
  --resource-id service/powersync-cluster/powersync-api \
  --query 'ScalingPolicies[*].[PolicyName,PolicyType,TargetTrackingScalingPolicyConfiguration.TargetValue]' \
  --output table

# Check the custom metric is being published (may take a few minutes after deployment)
aws cloudwatch list-metrics \
  --namespace "PowerSync" \
  --metric-name "powersync_concurrent_connections"

# View scaling activity
aws application-autoscaling describe-scaling-activities \
  --service-namespace ecs \
  --resource-id service/powersync-cluster/powersync-api \
  --query 'ScalingActivities[*].[StatusCode,Description,StartTime]' \
  --output table
If you prefer not to set up the CloudWatch Agent sidecar and custom Prometheus metrics, you can scale based on CPU utilization alone:
aws application-autoscaling register-scalable-target \
  --service-namespace ecs \
  --resource-id service/powersync-cluster/powersync-api \
  --scalable-dimension ecs:service:DesiredCount \
  --min-capacity 2 \
  --max-capacity 10

# Use CPU-only scaling policy
aws application-autoscaling put-scaling-policy \
  --service-namespace ecs \
  --resource-id service/powersync-cluster/powersync-api \
  --scalable-dimension ecs:service:DesiredCount \
  --policy-name cpu-scaling \
  --policy-type TargetTrackingScaling \
  --target-tracking-scaling-policy-configuration '{
    "TargetValue": 70.0,
    "PredefinedMetricSpecification": {"PredefinedMetricType": "ECSServiceAverageCPUUtilization"},
    "ScaleInCooldown": 300,
    "ScaleOutCooldown": 120
  }'
This approach is simpler but less responsive to connection spikes — CPU may not increase proportionally with new sync connections. Without connection-aware scaling, consider increasing min-capacity if your traffic is spiky, to provide a larger baseline while auto-scaling reacts.

Troubleshooting

SymptomSolution
Tasks fail health checksCheck logs: aws logs tail /ecs/powersync --follow
Increase startPeriod in health check to 120
502 Bad GatewayVerify security groups allow ALB→ECS on port 8080
Check tasks are running: aws ecs list-tasks --cluster powersync-cluster
WebSocket disconnectsVerify ALB idle timeout is 3600s (set in Step 3)
Can’t pull imageVerify NAT Gateway exists and route table configured correctly
Check NAT Gateway has internet access
Secrets not loadedCheck IAM role has secretsmanager:GetSecretValue permission
Verify secrets exist: aws secretsmanager list-secrets
Sync Rule lock errors during deployUsing multiple instances without HA setup
Use High Availability Setup for production
CIDR block conflictsAdjust CIDR blocks in Step 2 to match available VPC address space
Certificate validation failsVerify DNS nameservers are updated and propagated
Check validation CNAME record exists in Route 53
CloudWatch metric not appearingVerify telemetry.prometheus_port: 9090 is set in service.yaml
Check CW Agent logs: aws logs tail /ecs/powersync-api/cwagent --follow
Confirm the SSM parameter exists: aws ssm get-parameter --name /ecs/powersync/cwagent-config
Connection-based scaling not triggeringVerify metric in CloudWatch: aws cloudwatch list-metrics --namespace PowerSync
Check the scaling policy: aws application-autoscaling describe-scaling-policies --service-namespace ecs
Metric may take 2-3 minutes to appear after task startup
Clients disconnecting during scale-inThis is expected behavior — sync connections on terminated tasks are closed and clients reconnect automatically.
Increase deregistration_delay.timeout_seconds on the target group for a longer drain period

Additional Resources

  • AWS ECS Best Practices - AWS’s official guide covering security, networking, monitoring, and performance optimization for ECS deployments
  • Self-Host Demo Repository - Working example implementations of PowerSync self-hosting across different platforms and configurations