Introduction
Moving a Rails application to AWS can feel overwhelming—there's no single "right way," and the cloud often feels like you're choosing between trade-offs rather than best practices. Rails on AWS: concepts, trade-offs and production lessons captures the reality of what we've learned running Rails workloads across compute, database, storage and CDN services in production. Whether you're scaling a monolith, migrating from another platform, or building fresh on AWS, understanding how to pair Rails with ECS Fargate, RDS Postgres, S3 and CloudFront will save you weeks of troubleshooting and months of unnecessary spend.
Compute: ECS Fargate vs EC2 with Kamal
The first decision you'll face is how to run your Rails application. Two patterns dominate: containerized deployments on ECS Fargate, or self-managed EC2 with orchestration tools like Kamal.
ECS Fargate is AWS's serverless container service. You define a task (container image, CPU, memory, environment variables), push your Docker image to ECR, and let Fargate spin up and manage the underlying EC2 infrastructure. You pay only for CPU and memory consumed. There's no patching, no instance management, no SSH access. For teams that want AWS to handle operational overhead, Fargate is compelling.
The trade-off: Fargate starts cold containers in 30–60 seconds, which means slower deployments and slower scaling during traffic spikes. If you have bursty workloads or need sub-second deployment feedback, you'll feel the friction. Also, Fargate pricing can become expensive at scale—a production Rails app running 24/7 on Fargate often costs more than EC2 instances running the same workload.
EC2 with Kamal flips the model. Kamal (formerly MRSK) is a containerized deployment tool that treats EC2 instances as a deployment target. You own the instance, Kamal manages container orchestration, rolling deployments, and health checks via SSH. This gives you more control: you can run smaller instances, co-locate services, customize networking, and scale more predictably.
The trade-off: you're responsible for patching, monitoring, and replacing instances. Kamal requires more hands-on infrastructure knowledge, and troubleshooting often means SSH'ing into machines to inspect logs or debug container networking.
A practical example: a SaaS team running a medium-scale Rails app (2–5 simultaneous requests per second) might start on Fargate for speed-to-production, then migrate to EC2 + Kamal once infrastructure costs become a concern. At 10+ req/s, EC2 is almost always cheaper and more flexible.
# Example Kamal deploy configuration (config/deploy.yml)
service: rails-app
image: myaccount.dkr.ecr.us-east-1.amazonaws.com/rails-app
servers:
web:
hosts:
- 203.0.113.10
- 203.0.113.11
env:
secret:
- DATABASE_URL
- RAILS_MASTER_KEY
registry:
server: 203.0.113.1
username: registry
password:
- REGISTRY_PASSWORD
Database: RDS Postgres
For Rails, Postgres on RDS is the default choice. RDS handles automated backups, failover, encryption at rest, and point-in-time recovery out of the box. Your Active Record queries work unchanged; you just swap your connection string.
Key production lessons: always enable automated backups with a retention period of at least 7 days, and enable multi-AZ deployment for production workloads. Multi-AZ means RDS maintains a synchronous standby in a different availability zone. On primary failure, failover is automatic, though it takes 60–120 seconds and may drop a few in-flight connections. For mission-critical apps, this is necessary.
Parameterized queries via Active Record protect you from SQL injection, but watch your n+1 queries—they're just as expensive on RDS as they are locally. Use Bullet gem to catch them in development.
One subtle trap: RDS Postgres defaults to a max_connections limit based on instance class. A db.t3.small allows ~180 connections; a db.t4g.large allows ~1024. Rails connection pooling prevents your app from opening a connection per request, but if you're running 20 Fargate tasks or EC2 instances, each holding a pool of 5 connections, you're already at 100 connections. Size accordingly or implement connection pooling middleware like PgBouncer on a separate EC2 instance.
For read-heavy workloads, RDS Read Replicas are a solid pattern. They're asynchronous copies of your database in the same or different regions. You can direct read-only queries (analytics, reports) to a replica, keeping your primary available for writes. However, replicas lag by a few milliseconds to seconds, so you can't use them for immediate consistency-critical reads.
Storage: S3 for Active Storage
Rails Active Storage makes it trivial to offload file uploads to S3. Instead of storing files on disk (which doesn't scale across multiple instances), Active Storage generates signed URLs and manages lifecycle.
Basic setup requires an S3 bucket, an IAM policy, and a few configuration lines:
# config/storage.yml
amazon:
service: S3
access_key_id: <%= Rails.application.credentials.aws_access_key_id %>
secret_access_key: <%= Rails.application.credentials.aws_secret_access_key %>
region: us-east-1
bucket: my-rails-app-uploads
# config/environments/production.rb
config.active_storage.service = :amazon
Common gotchas: presigned URLs have an expiration, typically 1 hour. If a user downloads a file hours later, the URL is stale and they get a 403. Always generate a fresh URL when serving downloads. Also, S3 has eventual consistency for deletes—a file might exist for a few seconds after you call delete. For most Rails apps, this doesn't matter, but if you're relying on immediate deletion for security or quota enforcement, add explicit checks.
Versioning and lifecycle policies matter. Enable versioning on your S3 bucket so old versions aren't lost if you accidentally overwrite. Set up lifecycle rules to transition old versions to Glacier or delete them after 90 days—this cuts storage costs significantly for apps with high file churn.
For direct uploads (user uploading to S3 without passing through your Rails app), Active Storage supports direct_upload. This cuts bandwidth costs and is essential for large files or high-volume uploads. The trade-off: your frontend must handle multipart uploads and retry logic.
CDN: CloudFront for Assets
Your Rails app serves CSS, JavaScript, and image assets. Serving these from EC2 or Fargate is wasteful—every user downloads from your origin, which burns bandwidth and adds latency for geographically distant users. CloudFront, AWS's CDN, caches assets at edge locations worldwide.
Setup is straightforward: create a CloudFront distribution pointing to your S3 bucket or Rails application as the origin. Then update your asset URLs to use the CloudFront domain instead of your app domain. In Rails, this is one line:
# config/environments/production.rb
config.asset_host = "https://d1234567890abc.cloudfront.net"
Rails will generate asset links using your CloudFront domain. CloudFront caches based on cache headers—set long TTLs (1 year or longer) for versioned assets (Rails adds a digest to filenames by default), so old versions aren't accidentally served.
Key production decision: cache invalidation. If you deploy a new version of app.js, old versions are still in CloudFront for up to 1 year. Explicitly invalidating the CloudFront distribution clears the cache, but invalidations cost money (first 3,000 per month are free, then $0.005 per invalidation). A common pattern is to use Capistrano or Kamal deploy hooks to invalidate only changed assets rather than invalidating everything.
For dynamic content (HTML responses), CloudFront can cache based on query strings, cookies, or headers. However, Rails session cookies and CSRF tokens vary per user, so caching HTML is usually a trap. Only cache static assets.
Common Mistakes
- Ignoring security groups and VPC configuration. By default, RDS is not accessible from the internet, which is correct—but your Fargate tasks or EC2 instances must be in the same VPC or have explicit security group rules allowing database traffic. Many teams provision infrastructure correctly but miss the networking layer and spend hours debugging "connection refused" errors.
- Not using Rails credentials or Secrets Manager. Hardcoding AWS access keys in your code or config files is a common security breach. Use Rails credentials with
rails credentials:editor AWS Secrets Manager to rotate keys safely. - Underestimating RDS CPU and memory. A
db.t3.microis cheap but will throttle under production load. Perform a production load test before launching, and monitor CloudWatch metrics—CPU, database connections, and disk I/O—in the first week to right-size. - Storing secrets in environment variables passed to Fargate. While convenient, secrets in task definitions are visible in the AWS console and API responses. Use Secrets Manager and grant IAM permissions to your task role instead.
- Cloudfront cache headers causing stale asset serves. If you deploy frequently and don't invalidate CloudFront, users may see old CSS or JavaScript for hours, breaking the app. Always test cache invalidation as part of your deployment pipeline.
Key Takeaways
- Choose compute based on team size and budget: Fargate for operational simplicity and quick starts, EC2 + Kamal for cost efficiency and control at scale.
- Always enable RDS automated backups and multi-AZ for production. Size the instance class to your connection pool and expected query load.
- Use Active Storage with S3 for file uploads, enable versioning and lifecycle policies, and consider direct uploads for large files.
- Pair your Rails app with CloudFront for asset delivery, use versioned asset filenames for long TTLs, and automate cache invalidation on deploy.
- Test security group rules and VPC networking before production—connection issues are often infrastructure misconfigurations, not Rails bugs.






Comments
Loading comments…