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.
Required AWS Services
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. |
Docker Image
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 |
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 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 |
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.
ECS Task Definitions
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.
Web Service
{
"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
}
}
]
}
Worker Service
{
"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
}
}
]
}
Production Sizing
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 |
Auto Scaling
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
}'
After Deployment
Admin Dashboard
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:
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