Back to Spree

AWS — ECS Fargate

docs/developer/deployment/aws_ecs.mdx

5.6.015.8 KB
Original Source

This is the advanced AWS path: containers managed by ECS Fargate with auto-scaling, zero-downtime rolling deploys, CI/CD via GitHub Actions, and secrets in AWS Secrets Manager. If you're getting started or running a single store, the EC2 + RDS guide gets you to production with a fraction of the moving parts — on the same Docker image, so you can graduate to ECS later without rework.

Required AWS Services

The required set is smaller than an AWS architecture diagram suggests — four services run the store:

ServicePurpose
ECS FargateRuns the web (and optional worker) containers — no servers to manage
RDSThe database — data, background jobs, and cache all live here. PostgreSQL, MySQL/MariaDB, and both Aurora flavors work — see Database Configuration
ECRRegistry for your Docker image
S3Uploaded files (product images) — Fargate containers have no persistent disk. See Asset Storage

In front of the web service you'll put an Application Load Balancer with a free HTTPS certificate from Certificate Manager — that's your public entry point.

Everything else is optional:

ServiceWhen
CloudFrontCDN in front of assets and images
ElastiCacheValkey/Redis for high-traffic caching — the default cache lives in the database
Route 53DNS — convenient on AWS, but any DNS provider works

Docker Image

Build your project's image with spree build --production, or use the stock ghcr.io/spree/spree image for deployments without customizations.

To build and deploy a custom image to AWS ECR via GitHub Actions:

yaml
name: Deploy to AWS Fargate

on:
  push:
    branches: [ main ]
  pull_request:
    branches: [ main ]

env:
  AWS_REGION: us-east-1
  ECR_REPOSITORY: spree-starter
  ECS_SERVICE_WEB: spree-web
  ECS_SERVICE_WORKER: spree-worker
  ECS_CLUSTER: spree-cluster
  
jobs:
  build:
    name: Build and Push to ECR
    runs-on: ubuntu-latest
    
    outputs:
      image: ${{ steps.build-image.outputs.image }}
      image-tag: ${{ steps.build-image.outputs.image-tag }}
    
    steps:
    - name: Checkout code
      uses: actions/checkout@v4
    
    - name: Configure AWS credentials
      uses: aws-actions/configure-aws-credentials@v4
      with:
        aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
        aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
        aws-region: ${{ env.AWS_REGION }}
    
    - name: Login to Amazon ECR
      id: login-ecr
      uses: aws-actions/amazon-ecr-login@v2
    
    - name: Build, tag, and push image to Amazon ECR
      id: build-image
      env:
        ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
        IMAGE_TAG: ${{ github.sha }}
      run: |
        # Build a docker container and push it to ECR
        docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG .
        docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG
        echo "image=$ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG" >> $GITHUB_OUTPUT
        echo "image-tag=$IMAGE_TAG" >> $GITHUB_OUTPUT
  
  deploy-web:
    name: Deploy Web Service
    runs-on: ubuntu-latest
    needs: build
    if: github.ref == 'refs/heads/main'
    
    steps:
    - name: Checkout code
      uses: actions/checkout@v4
    
    - name: Configure AWS credentials
      uses: aws-actions/configure-aws-credentials@v4
      with:
        aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
        aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
        aws-region: ${{ env.AWS_REGION }}
    
    - name: Fill in the new image ID in the Amazon ECS task definition
      id: task-def-web
      uses: aws-actions/amazon-ecs-render-task-definition@v1
      env:
        ECR_REGISTRY: ${{ needs.build.outputs.image }}
        IMAGE_TAG: ${{ needs.build.outputs.image-tag }}
        AWS_ACCOUNT_ID: ${{ secrets.AWS_ACCOUNT_ID }}
      with:
        task-definition: .aws/web-task-definition.json
        container-name: web
        image: ${{ needs.build.outputs.image }}
    
    - name: Deploy Amazon ECS task definition for web
      uses: aws-actions/amazon-ecs-deploy-task-definition@v1
      with:
        task-definition: ${{ steps.task-def-web.outputs.task-definition }}
        service: ${{ env.ECS_SERVICE_WEB }}
        cluster: ${{ env.ECS_CLUSTER }}
        wait-for-service-stability: true
  
  deploy-worker:
    name: Deploy Worker Service
    runs-on: ubuntu-latest
    needs: build
    if: github.ref == 'refs/heads/main'
    
    steps:
    - name: Checkout code
      uses: actions/checkout@v4
    
    - name: Configure AWS credentials
      uses: aws-actions/configure-aws-credentials@v4
      with:
        aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
        aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
        aws-region: ${{ env.AWS_REGION }}
    
    - name: Fill in the new image ID in the Amazon ECS task definition
      id: task-def-worker
      uses: aws-actions/amazon-ecs-render-task-definition@v1
      env:
        ECR_REGISTRY: ${{ needs.build.outputs.image }}
        IMAGE_TAG: ${{ needs.build.outputs.image-tag }}
        AWS_ACCOUNT_ID: ${{ secrets.AWS_ACCOUNT_ID }}
      with:
        task-definition: .aws/worker-task-definition.json
        container-name: worker
        image: ${{ needs.build.outputs.image }}
    
    - name: Deploy Amazon ECS task definition for worker
      uses: aws-actions/amazon-ecs-deploy-task-definition@v1
      with:
        task-definition: ${{ steps.task-def-worker.outputs.task-definition }}
        service: ${{ env.ECS_SERVICE_WORKER }}
        cluster: ${{ env.ECS_CLUSTER }}
        wait-for-service-stability: true

  migrate:
    name: Run Database Migrations
    runs-on: ubuntu-latest
    needs: [build, deploy-web]
    if: github.ref == 'refs/heads/main'
    
    steps:
    - name: Checkout code
      uses: actions/checkout@v4
    
    - name: Configure AWS credentials
      uses: aws-actions/configure-aws-credentials@v4
      with:
        aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
        aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
        aws-region: ${{ env.AWS_REGION }}
    
    - name: Run database migrations
      run: |
        aws ecs run-task \
          --cluster ${{ env.ECS_CLUSTER }} \
          --task-definition spree-web \
          --overrides '{
            "containerOverrides": [{
              "name": "web",
              "command": ["bundle", "exec", "rails", "db:migrate"]
            }]
          }' \
          --launch-type FARGATE \
          --network-configuration '{
            "awsvpcConfiguration": {
              "subnets": ["'${{ secrets.SUBNET_ID_1 }}'", "'${{ secrets.SUBNET_ID_2 }}'"],
              "securityGroups": ["'${{ secrets.SECURITY_GROUP_ID }}'"],
              "assignPublicIp": "ENABLED"
            }
          }'

This action requires secrets to be set in your GitHub repository. You can find the full list of secrets in the AWS ECS Deploy Task Definition GitHub Actions repository.

SecretDescription
AWS_ACCESS_KEY_IDAWS access key ID
AWS_SECRET_ACCESS_KEYAWS secret access key
AWS_ACCOUNT_IDAWS account ID
SUBNET_ID_1First subnet ID
SUBNET_ID_2Second subnet ID
SECURITY_GROUP_IDSecurity group ID

Environment Variables

Store secrets in AWS Secrets Manager and reference them in your task definitions. Non-sensitive configuration goes in the environment array directly.

For a full list of available variables, see Environment Variables.

Secrets Manager

Create the following secrets in AWS Secrets Manager:

Secret NameVariableDescription
spree/database-urlDATABASE_URLPostgreSQL connection URL
spree/secret-key-baseSECRET_KEY_BASEGenerate with bin/rails secret

Optional secrets for email delivery, file storage, and error tracking:

Secret NameVariableDescription
spree/smtp-passwordSMTP_PASSWORDSMTP auth password
spree/sentry-dsnSENTRY_DSNSentry DSN for error tracking
<Tip> S3 file storage credentials are not needed as environment variables when your ECS task role has the appropriate S3 permissions. Use IAM roles instead of access keys when possible. </Tip>

ECS Task Definitions

One task definition runs the whole app — the web server also runs background jobs in-process (combined mode). Save it as .aws/web-task-definition.json. The worker task definition is an optional scale-out step — skip it (and the deploy-worker job in the workflow above) until job load deserves its own service.

Web Service

json
{
  "family": "spree-web",
  "networkMode": "awsvpc",
  "requiresCompatibilities": ["FARGATE"],
  "cpu": "1024",
  "memory": "4096",
  "executionRoleArn": "arn:aws:iam::${AWS_ACCOUNT_ID}:role/ecsTaskExecutionRole",
  "taskRoleArn": "arn:aws:iam::${AWS_ACCOUNT_ID}:role/ecsTaskRole",
  "containerDefinitions": [
    {
      "name": "web",
      "image": "${ECR_REGISTRY}/${ECR_REPOSITORY}:${IMAGE_TAG}",
      "portMappings": [
        {
          "containerPort": 3000,
          "protocol": "tcp"
        }
      ],
      "essential": true,
      "environment": [
        {
          "name": "RAILS_ENV",
          "value": "production"
        },
        {
          "name": "PORT",
          "value": "3000"
        },
        {
          "name": "RAILS_MAX_THREADS",
          "value": "3"
        },
        {
          "name": "WEB_CONCURRENCY",
          "value": "auto"
        },
        {
          "name": "RAILS_LOG_LEVEL",
          "value": "info"
        },
        {
          "name": "AWS_BUCKET",
          "value": "your-spree-bucket"
        }
      ],
      "secrets": [
        {
          "name": "DATABASE_URL",
          "valueFrom": "arn:aws:secretsmanager:${AWS_REGION}:${AWS_ACCOUNT_ID}:secret:spree/database-url"
        },
        {
          "name": "SECRET_KEY_BASE",
          "valueFrom": "arn:aws:secretsmanager:${AWS_REGION}:${AWS_ACCOUNT_ID}:secret:spree/secret-key-base"
        }
      ],
      "logConfiguration": {
        "logDriver": "awslogs",
        "options": {
          "awslogs-group": "/ecs/spree-web",
          "awslogs-region": "${AWS_REGION}",
          "awslogs-stream-prefix": "ecs"
        }
      },
      "healthCheck": {
        "command": ["CMD-SHELL", "curl -f http://localhost:3000/up || exit 1"],
        "interval": 30,
        "timeout": 5,
        "retries": 3,
        "startPeriod": 60
      }
    }
  ]
}

Worker Service (optional scale-out)

Switches to split mode: background jobs move into a dedicated service — same image, no rebuild. When you deploy it, set SOLID_QUEUE_IN_PUMA=false in the web task's environment so the web container stops running jobs. Save as .aws/worker-task-definition.json:

json
{
  "family": "spree-worker",
  "networkMode": "awsvpc",
  "requiresCompatibilities": ["FARGATE"],
  "cpu": "512",
  "memory": "2048",
  "executionRoleArn": "arn:aws:iam::${AWS_ACCOUNT_ID}:role/ecsTaskExecutionRole",
  "taskRoleArn": "arn:aws:iam::${AWS_ACCOUNT_ID}:role/ecsTaskRole",
  "containerDefinitions": [
    {
      "name": "worker",
      "image": "${ECR_REGISTRY}/${ECR_REPOSITORY}:${IMAGE_TAG}",
      "command": ["bin/jobs"],
      "essential": true,
      "environment": [
        {
          "name": "RAILS_ENV",
          "value": "production"
        },
        {
          "name": "RAILS_LOG_LEVEL",
          "value": "info"
        }
      ],
      "secrets": [
        {
          "name": "DATABASE_URL",
          "valueFrom": "arn:aws:secretsmanager:${AWS_REGION}:${AWS_ACCOUNT_ID}:secret:spree/database-url"
        },
        {
          "name": "SECRET_KEY_BASE",
          "valueFrom": "arn:aws:secretsmanager:${AWS_REGION}:${AWS_ACCOUNT_ID}:secret:spree/secret-key-base"
        }
      ],
      "logConfiguration": {
        "logDriver": "awslogs",
        "options": {
          "awslogs-group": "/ecs/spree-worker",
          "awslogs-region": "${AWS_REGION}",
          "awslogs-stream-prefix": "ecs"
        }
      },
      "healthCheck": {
        "command": ["CMD-SHELL", "pgrep -f solid_queue || exit 1"],
        "interval": 30,
        "timeout": 5,
        "retries": 3,
        "startPeriod": 60
      }
    }
  ]
}

Production Sizing

The task definitions above are sized for production. Here is a summary and scaling guidance:

ServiceFargate CPUFargate MemoryInstances
Web1 vCPU4 GB2+ (use ECS Service Auto Scaling)
Worker (optional split)0.5 vCPU2 GB1+
RDS (PostgreSQL)db.r6g.large (2 vCPU, 16 GB)1

The database also stores background jobs and cache — the sizing above accounts for it. Scaling the web service to multiple instances is safe with in-process jobs: instances coordinate through the database. Add ElastiCache (Valkey or Redis) only for high-traffic caching.

Auto Scaling

Use ECS Service Auto Scaling to scale the web service based on CPU or memory utilization:

bash
# Register a scalable target (min 2, max 6 tasks)
aws application-autoscaling register-scalable-target \
  --service-namespace ecs \
  --resource-id service/spree-cluster/spree-web \
  --scalable-dimension ecs:service:DesiredCount \
  --min-capacity 2 \
  --max-capacity 6

# Scale based on average CPU utilization (target 70%)
aws application-autoscaling put-scaling-policy \
  --service-namespace ecs \
  --resource-id service/spree-cluster/spree-web \
  --scalable-dimension ecs:service:DesiredCount \
  --policy-name spree-web-cpu-scaling \
  --policy-type TargetTrackingScaling \
  --target-tracking-scaling-policy-configuration '{
    "TargetValue": 70.0,
    "PredefinedMetricSpecification": {
      "PredefinedMetricType": "ECSServiceAverageCPUUtilization"
    },
    "ScaleInCooldown": 300,
    "ScaleOutCooldown": 60
  }'

After Deployment

Admin

Access your admin panel at:

https://<your-domain>/admin

Default credentials are created during db:seed. Change them immediately after first login.

Database Migrations

The GitHub Actions workflow above runs migrations automatically on deploy. To run migrations manually:

bash
aws ecs run-task \
  --cluster spree-cluster \
  --task-definition spree-web \
  --overrides '{
    "containerOverrides": [{
      "name": "web",
      "command": ["bundle", "exec", "rails", "db:migrate"]
    }]
  }' \
  --launch-type FARGATE \
  --network-configuration '{
    "awsvpcConfiguration": {
      "subnets": ["subnet-xxx", "subnet-yyy"],
      "securityGroups": ["sg-xxx"],
      "assignPublicIp": "DISABLED"
    }
  }'

Next Steps