docs/developer/deployment/aws.mdx
Amazon Web Services offers reliable, scalable, and inexpensive cloud computing services. AWS is also one of the most popular choices for hosting a Spree application.
We recommend using AWS ECS Fargate to host your Spree application via Docker image.
To fully run your Spree application on AWS, you will need the following services:
| Service | Description |
|---|---|
| AWS ECS Fargate | Fully managed container orchestration — run and scale containers without managing infrastructure. |
| AWS RDS | Managed relational database. Spree works with Aurora PostgreSQL, Aurora MySQL, RDS PostgreSQL, RDS MySQL, and RDS MariaDB. |
| AWS ElastiCache | Valkey or Redis for background jobs (Sidekiq), caching, and Action Cable. We recommend separate instances for jobs and cache. |
| AWS S3 | Object storage for uploaded files (product images, etc.). More information. |
| AWS CloudFront | CDN for asset delivery (images, stylesheets, JavaScript). |
| AWS Route 53 | DNS service for domain name management. |
| AWS Certificate Manager | Free SSL/TLS certificates. Spree requires HTTPS in production. |
| AWS ECR | Docker container registry for storing your application images. |
You can use the official Docker image (ghcr.io/spree/spree) directly, or build your own from your Rails application's Dockerfile.
To build and deploy a custom image to AWS ECR via GitHub Actions:
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.
| Secret | Description |
|---|---|
AWS_ACCESS_KEY_ID | AWS access key ID |
AWS_SECRET_ACCESS_KEY | AWS secret access key |
AWS_ACCOUNT_ID | AWS account ID |
SUBNET_ID_1 | First subnet ID |
SUBNET_ID_2 | Second subnet ID |
SECURITY_GROUP_ID | Security group ID |
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.
Create the following secrets in AWS Secrets Manager:
| Secret Name | Variable | Description |
|---|---|---|
spree/database-url | DATABASE_URL | PostgreSQL connection URL |
spree/redis-url | REDIS_URL | Redis URL for background jobs and Action Cable |
spree/redis-cache-url | REDIS_CACHE_URL | Redis URL for caching (separate instance recommended) |
spree/secret-key-base | SECRET_KEY_BASE | Generate with bin/rails secret |
Optional secrets for email delivery, file storage, and error tracking:
| Secret Name | Variable | Description |
|---|---|---|
spree/smtp-password | SMTP_PASSWORD | SMTP auth password |
spree/sentry-dsn | SENTRY_DSN | Sentry DSN for error tracking |
You will need two ECS task definitions: one for the web service and one for the worker service. Save these as .aws/web-task-definition.json and .aws/worker-task-definition.json in your repository.
{
"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": "REDIS_URL",
"valueFrom": "arn:aws:secretsmanager:${AWS_REGION}:${AWS_ACCOUNT_ID}:secret:spree/redis-url"
},
{
"name": "REDIS_CACHE_URL",
"valueFrom": "arn:aws:secretsmanager:${AWS_REGION}:${AWS_ACCOUNT_ID}:secret:spree/redis-cache-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
}
}
]
}
{
"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": ["bundle", "exec", "sidekiq"],
"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": "REDIS_URL",
"valueFrom": "arn:aws:secretsmanager:${AWS_REGION}:${AWS_ACCOUNT_ID}:secret:spree/redis-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 sidekiq || exit 1"],
"interval": 30,
"timeout": 5,
"retries": 3,
"startPeriod": 60
}
}
]
}
The task definitions above are sized for production. Here is a summary and scaling guidance:
| Service | Fargate CPU | Fargate Memory | Instances |
|---|---|---|---|
| Web | 1 vCPU | 4 GB | 2+ (use ECS Service Auto Scaling) |
| Worker | 0.5 vCPU | 2 GB | 1+ |
| RDS (PostgreSQL) | — | db.r6g.large (2 vCPU, 16 GB) | 1 primary + read replica |
| ElastiCache (jobs) | — | cache.r6g.large (13 GB) | 1 |
| ElastiCache (cache) | — | cache.r6g.large (13 GB) | 1 |
Use ECS Service Auto Scaling to scale the web service based on CPU or memory utilization:
# 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
}'
Access your admin panel at:
https://<your-domain>/admin
Default credentials are created during db:seed. Change them immediately after first login.
The GitHub Actions workflow above runs migrations automatically on deploy. To run migrations manually:
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"
}
}'