> ## Documentation Index
> Fetch the complete documentation index at: https://docs.powersync.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Deploy PowerSync on AWS ECS

> Deploy the self-hosted PowerSync Service on AWS ECS with Fargate.

[AWS ECS](https://aws.amazon.com/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](/maintenance-ops/self-hosting/deployment-architecture) for production vs development setup

## 1. PowerSync Configuration

Create your `service.yaml` configuration file following the [Self-Hosted Configuration Guide](/configuration/powersync-service/self-hosted-instances).

Your configuration must include:

* [Sync Streams](/sync/streams/overview) (or legacy [Sync Rules](/sync/rules/overview)): Define which data to sync to clients
* [Client Auth](/configuration/auth/overview): Your authentication provider's JWKS
* [Source Database](/configuration/source-db/setup): Connection details for your source database
* [Telemetry](/maintenance-ops/self-hosting/telemetry): Enable the Prometheus metrics endpoint for connection-based auto-scaling (used in the [Auto Scaling](#auto-scaling-high-availability-setup) section):
  ```yaml theme={null}
  telemetry:
    prometheus_port: 9090
  ```
* [Bucket Storage](/configuration/powersync-service/self-hosted-instances#bucket-storage-database): Connection details for your bucket storage database. PowerSync supports MongoDB or Postgres as bucket storage databases. In this guide, we focus on MongoDB.

  <Tabs>
    <Tab title="MongoDB Atlas">
      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](https://aws.amazon.com/blogs/apn/connecting-applications-securely-to-a-mongodb-atlas-data-plane-with-aws-privatelink/) to configure the VPC endpoint and update your MongoDB connection string to use the private endpoint. As seen in the [Secrets Manager](#5-secrets-manager) setup, use the updated connection string in your `PS_MONGO_URI` secret.
    </Tab>

    <Tab title="Self-Hosted MongoDB on EC2">
      For self-hosting MongoDB bucket storage on an EC2 instance, refer to AWS's guides (which refer to Amazon DocumentDB, but the installation steps are applicable):

      1. [Launch an EC2 Instance](https://docs.aws.amazon.com/dms/latest/sbs/chap-mongodb2documentdb.01.html)
      2. [Install and Configure MongoDB](https://docs.aws.amazon.com/dms/latest/sbs/chap-mongodb2documentdb.02.html)
      3. **Network Configuration**

         * Place MongoDB EC2 instance in the same VPC as your ECS tasks
         * Configure security groups to allow ECS tasks to connect to MongoDB on port 27017:

         ```bash theme={null}
         # Create MongoDB security group
         MONGO_SG=$(aws ec2 create-security-group \
           --group-name mongodb-sg \
           --description "MongoDB for PowerSync" \
           --vpc-id $VPC_ID \
           --query 'GroupId' --output text)

         # Allow ECS tasks to connect to MongoDB ($ECS_SG is the ECS tasks security group created later in the Security Groups section)
         aws ec2 authorize-security-group-ingress \
           --group-id $MONGO_SG \
           --protocol tcp --port 27017 --source-group $ECS_SG
         ```
    </Tab>
  </Tabs>

## 2. VPC and Networking Setup

This guide uses bash variables throughout for easy copy-paste execution.

```bash theme={null}
# 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)
```

<Warning>
  **Default VPC users**: The AWS default VPC only contains public subnets. You must create private subnets following the steps below.
</Warning>

### Check Existing Subnets

```bash theme={null}
# 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:

```bash theme={null}
# 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:

```bash theme={null}
# 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"
```

<Warning>
  **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.
</Warning>

**Create Route Table for Private Subnets:**

```bash theme={null}
# 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:**

```bash theme={null}
# 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:**

```bash theme={null}
# 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:**

```bash theme={null}
# 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

```bash theme={null}
# 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:

* [Configuring DNS routing for a new domain](https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/dns-configuring-new-domain.html) - For existing domains
* [Registering a new domain](https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/domain-register.html) - To register through Route 53

Once your hosted zone is created, export the zone ID:

```bash theme={null}
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):

```bash theme={null}
# 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:**

<Tabs>
  <Tab title="External DNS Provider">
    Add the `CNAME` record using your DNS provider's management console:

    | Type    | Name                | Value                | TTL   |
    | ------- | ------------------- | -------------------- | ----- |
    | `CNAME` | `[VALIDATION_NAME]` | `[VALIDATION_VALUE]` | `300` |
  </Tab>

  <Tab title="Route 53 DNS">
    Add the `CNAME` record using AWS CLI:

    ```bash theme={null}
    aws route53 change-resource-record-sets \
      --hosted-zone-id $HOSTED_ZONE_ID \
      --change-batch '{
        "Changes": [{
          "Action": "CREATE",
          "ResourceRecordSet": {
            "Name": "'$VALIDATION_NAME'",
            "Type": "CNAME",
            "TTL": 300,
            "ResourceRecords": [{"Value": "'$VALIDATION_VALUE'"}]
          }
        }]
      }'
    ```
  </Tab>
</Tabs>

**Wait for Certificate Validation:**

```bash theme={null}
aws acm wait certificate-validated --certificate-arn $CERT_ARN --region $AWS_REGION
```

### Create ALB

```bash theme={null}
# 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:

```bash theme={null}
# 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"

```

<Tabs>
  <Tab title="External DNS Provider">
    Create a `CNAME` record pointing to the ALB DNS name.

    | Type    | Name                       | Value       | TTL   |
    | ------- | -------------------------- | ----------- | ----- |
    | `CNAME` | `powersync.yourdomain.com` | `[ALB_DNS]` | `300` |
  </Tab>

  <Tab title="Using Route 53">
    Create an alias `A` record pointing to the ALB:

    ```bash theme={null}
    aws route53 change-resource-record-sets \
      --hosted-zone-id $HOSTED_ZONE_ID \
      --change-batch '{
        "Changes": [{
          "Action": "CREATE",
          "ResourceRecordSet": {
            "Name": "'$POWERSYNC_DOMAIN'",
            "Type": "A",
            "AliasTarget": {
              "HostedZoneId": "'$ALB_ZONE'",
              "DNSName": "'$ALB_DNS'",
              "EvaluateTargetHealth": true
            }
          }
        }]
      }'
    ```
  </Tab>
</Tabs>

## 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.

```bash theme={null}
# 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., Postgres, 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"
```

<Info>
  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.
</Info>

## 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

```bash theme={null}
# 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

```bash theme={null}
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](/maintenance-ops/self-hosting/deployment-architecture) for scaling guidance (recommended baseline: 1 vCPU, 2GB memory). Note that [AWS Fargate enforces specific CPU/memory combinations](https://docs.aws.amazon.com/AmazonECS/latest/developerguide/task_definition_parameters.html#task_size) — for example, 2 vCPU (2048 CPU units) requires at least 4GB (4096 MiB) memory.

<Tabs>
  <Tab title="High Availability Setup">
    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**

    ```bash theme={null}
    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 Definition**

    The 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](#auto-scaling-high-availability-setup).

    <Info>
      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.
    </Info>

    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:

    ```bash theme={null}
    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:

    ```bash theme={null}
    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:

    ```bash theme={null}
    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
    ```

    <Note>
      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.
    </Note>
  </Tab>

  <Tab title="Basic Setup (Single Instance)">
    This basic setup runs both replication and API processes in the same container. This is not recommended for production.

    Generate the task definition using your environment variables:

    ```bash theme={null}
    cat > task-definition.json <<EOF
    {
      "family": "powersync-service",
      "networkMode": "awsvpc",
      "requiresCompatibilities": ["FARGATE"],
      "cpu": "1024",
      "memory": "2048",
      "executionRoleArn": "$TASK_EXECUTION_ROLE_ARN",
      "containerDefinitions": [
        {
          "name": "powersync",
          "image": "journeyapps/powersync-service:$PS_VERSION",
          "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_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",
              "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
          }
        }
      ]
    }
    EOF

    # Register the task definition
    aws ecs register-task-definition --cli-input-json file://task-definition.json
    ```
  </Tab>
</Tabs>

## 7. Deploy ECS Service

Create the ECS service to run PowerSync tasks

<Tabs>
  <Tab title="High Availability Setup">
    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)**

    ```bash theme={null}
    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)**

    ```bash theme={null}
    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:**

    ```bash theme={null}
    # 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
    ```
  </Tab>

  <Tab title="Basic Setup (Single Instance)">
    This basic setup runs both replication and API processes in the same container. Running multiple instances (`desired-count > 1`) will cause **Sync Rule lock errors during rolling updates** when deploying new task definitions. A single-instance setup is not recommended for production.

    ```bash theme={null}
    aws ecs create-service \
      --cluster powersync-cluster \
      --service-name powersync-service \
      --task-definition powersync-service \
      --desired-count 1 \
      --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,containerPort=8080" \
      --health-check-grace-period-seconds 120 \
      --deployment-configuration "minimumHealthyPercent=0,maximumPercent=100"
    ```

    **Verify Basic Deployment:**

    ```bash theme={null}
    # Check service status
    aws ecs describe-services \
      --cluster powersync-cluster \
      --services powersync-service \
      --query 'services[0].[serviceName,status,runningCount,desiredCount]' \
      --output table

    # Wait for task 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 logs
    aws logs tail /ecs/powersync --follow
    ```
  </Tab>
</Tabs>

## Production Enhancements

For production deployments, consider adding the following enhancements:

### Daily Compact Job (Recommended)

PowerSync requires [daily compaction](/maintenance-ops/compacting-buckets) to optimize bucket storage. Schedule it as an ECS task with EventBridge:

<Accordion title="Compact Job Configuration">
  Generate the compact task definition:

  ```bash theme={null}
  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):

  ```bash theme={null}
  # 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"
          }
        }
      }
    }]'
  ```
</Accordion>

### 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](/maintenance-ops/self-hosting/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**.

<Warning>
  **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.
</Warning>

#### Prerequisites

Connection-based auto-scaling requires:

1. **Prometheus metrics enabled** in your `service.yaml` (see [Step 1](#1-powersync-configuration)):
   ```yaml theme={null}
   telemetry:
     prometheus_port: 9090
   ```
2. **CloudWatch Agent sidecar** deployed in the API task definition (configured in [Step 6](#6-ecs-task-definition)). 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](#6-ecs-task-definition)).

#### 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.

```bash theme={null}
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
```

<Info>
  **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).
</Info>

#### Scaling Policy 1: CPU Utilization

This policy scales based on average CPU utilization across API tasks:

```bash theme={null}
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:

```bash theme={null}
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
  }'
```

<Info>
  **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.
</Info>

**Key configuration values:**

| Parameter                   | Value | Rationale                                                                                                                                                                                           |
| --------------------------- | ----- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `TargetValue` (connections) | 80    | 40% 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.0  | Scale before CPU saturation impacts sync stream performance.                                                                                                                                        |
| `ScaleOutCooldown`          | 120s  | New 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. |
| `ScaleInCooldown`           | 300s  | Prevents 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](https://docs.aws.amazon.com/elasticloadbalancing/latest/application/load-balancer-target-groups.html#deregistration-delay) allows existing connections to drain (default: 300s). Since syncs never complete naturally, connections are forcefully closed after this timeout.
3. ECS sends `SIGTERM` to the container — PowerSync closes all active sync connections 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:

```bash theme={null}
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:

```bash theme={null}
# 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
```

<Accordion title="Alternative: CPU-Only Scaling (No Custom Metrics)">
  If you prefer not to set up the CloudWatch Agent sidecar and custom Prometheus metrics, you can scale based on CPU utilization alone:

  ```bash theme={null}
  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.
</Accordion>

## Troubleshooting

| Symptom                                 | Solution                                                                                                                                                                                                                                                      |
| --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Tasks fail health checks                | Check logs: `aws logs tail /ecs/powersync --follow`<br />Increase `startPeriod` in health check to 120                                                                                                                                                        |
| 502 Bad Gateway                         | Verify security groups allow ALB→ECS on port 8080<br />Check tasks are running: `aws ecs list-tasks --cluster powersync-cluster`                                                                                                                              |
| WebSocket disconnects                   | Verify ALB idle timeout is 3600s (set in [Step 3](#3-application-load-balancer))                                                                                                                                                                              |
| Can't pull image                        | Verify NAT Gateway exists and route table configured correctly<br />Check NAT Gateway has internet access                                                                                                                                                     |
| Secrets not loaded                      | Check IAM role has `secretsmanager:GetSecretValue` permission<br />Verify secrets exist: `aws secretsmanager list-secrets`                                                                                                                                    |
| Sync Rule lock errors during deploy     | Using multiple instances without HA setup<br />Use [High Availability Setup](#high-availability-setup) for production                                                                                                                                         |
| CIDR block conflicts                    | Adjust CIDR blocks in [Step 2](#2-vpc-and-networking-setup) to match available VPC address space                                                                                                                                                              |
| Certificate validation fails            | Verify DNS nameservers are updated and propagated<br />Check validation CNAME record exists in Route 53                                                                                                                                                       |
| CloudWatch metric not appearing         | Verify `telemetry.prometheus_port: 9090` is set in service.yaml<br />Check CW Agent logs: `aws logs tail /ecs/powersync-api/cwagent --follow`<br />Confirm the SSM parameter exists: `aws ssm get-parameter --name /ecs/powersync/cwagent-config`             |
| Connection-based scaling not triggering | Verify metric in CloudWatch: `aws cloudwatch list-metrics --namespace PowerSync`<br />Check the scaling policy: `aws application-autoscaling describe-scaling-policies --service-namespace ecs`<br />Metric may take 2-3 minutes to appear after task startup |
| Clients disconnecting during scale-in   | This is expected behavior — sync connections on terminated tasks are closed and clients reconnect automatically.<br />Increase `deregistration_delay.timeout_seconds` on the target group for a longer drain period                                           |

### Additional Resources

* [AWS ECS Best Practices](https://docs.aws.amazon.com/AmazonECS/latest/bestpracticesguide/) - AWS's official guide covering security, networking, monitoring, and performance optimization for ECS deployments
* [Self-Host Demo Repository](https://github.com/powersync-ja/self-host-demo) - Working example implementations of PowerSync self-hosting across different platforms and configurations
