ruby on rails → aws · 2026 migration playbook

Migrate a Rails app to AWS — the architecture, the Sidekiq + DMS cutover, and who runs it for you.

Ruby on Rails runs beautifully on AWS — but the move (usually off Heroku) is more than a deploy: it is web + Sidekiq workers on ECS/Fargate or App Runner, Postgres on RDS/Aurora, Redis on ElastiCache for Sidekiq and Action Cable, Active Storage on S3, an ALB out front, Rails credentials in Secrets Manager, and a database cutover via DMS. This is the senior-engineer playbook for all of it — and a MAP-funded AWS partner can run the whole thing, often at little-to-no cost to you.

typical cost cut
40–70%
cutover downtime
0–15 min
project length
3–8 weeks
cost to you (MAP)
$0–low
TL;DR
  • A Rails app maps cleanly onto AWS: the Puma web process becomes an ECS/Fargate service (or App Runner) behind an Application Load Balancer; Sidekiq becomes a second Fargate service; PostgreSQL goes to RDS or Aurora; Redis (Sidekiq queues, Action Cable, Rails cache) goes to ElastiCache; Active Storage attachments live on S3, fronted by CloudFront. Because Postgres and Redis are standard protocols, your models, queries, and jobs do not change — the work is containerizing Rails and the cutover.
  • The two Rails-specific tasks are Dockerizing the app (a multi-stage Dockerfile that precompiles assets at build time) and moving secrets: Rails `config/credentials.yml.enc` + the `RAILS_MASTER_KEY`, plus `DATABASE_URL`/`REDIS_URL`, move into AWS Secrets Manager and SSM Parameter Store, injected into the task definition so the container reads them exactly as on Heroku. Assets precompile in the image (or to S3) and serve via CloudFront so Puma never serves static files in production.
  • The cutover is the only risky moment and it is solvable: AWS DMS keeps RDS/Aurora continuously synced (CDC) with your current Postgres while you smoke-test the new stack, then a short maintenance + DNS switch (typically 0–15 min) flips traffic. CloudRoute routes you to a vetted AWS partner who runs it end-to-end — and when the workload qualifies for the AWS Migration Acceleration Program (MAP), AWS funds the migration and the customer pays little-to-nothing.
the trigger

IWhy move a Rails app to AWS — and what actually has to change

Most Rails teams that migrate to AWS are leaving Heroku (or Render, or a single big VPS) for the same three reasons: cost at scale, platform limits, and the need for real infrastructure control as the product and the team grow. Rails itself is not the problem — where and how it runs is.

The loudest reason is cost. Heroku prices Rails by the dyno, and a Rails monolith is rarely one dyno — it is web dynos plus Sidekiq worker dynos plus a clock/scheduler process, each a fixed monthly slice. Layer on Heroku Postgres and Heroku Data for Redis (both marked up over the underlying infrastructure) and a mid-size Rails app commonly runs $1,800–$3,500/month. The same workload on AWS — Fargate for web and Sidekiq, RDS/Aurora, ElastiCache, S3 + CloudFront, with a Compute Savings Plan — commonly lands 40–70% lower, and the gap widens with every worker and plan bump.

The second reason is platform limits that bite Rails specifically. Heroku Postgres caps connections (Standard ~120, the largest plans ~500–1,000), and Rails opens a pool per Puma worker and a pool per Sidekiq process — so a few web dynos and a busy Sidekiq fleet exhaust connections fast, forcing PgBouncer or the Heroku pooler. Heroku's hard 30-second router timeout kills slow report endpoints that should have been Sidekiq jobs. Dynos cycle daily, which is fine for stateless Puma but a constraint for long-running work. None of these are bugs; they are the cost of the abstraction, and exactly the walls a scaling Rails team hits.

The third reason is control. As a Rails app matures it wants things Heroku abstracts away: VPC networking so the database is never publicly reachable, fine-grained autoscaling on web and workers independently, private connectivity to RDS and ElastiCache, IAM-scoped access to S3, and a real deploy pipeline. AWS gives all of that — at the cost of owning it. That operational surface (landing zone, IAM, networking, container images, pipelines) is precisely why most teams do not run this migration alone.

The honest counterpoint: Heroku's `git push` simplicity has genuine value, and AWS asks a Rails team to own more. The rest of this page is the architecture and the runbook; the CloudRoute angle is that a vetted AWS partner runs it for you, and for qualifying workloads AWS funds it through MAP — so you capture the ongoing savings and the control without paying the usual migration bill or improvising the riskiest cutover of the year.

the margin math (Rails monolith)

A typical Series-A Rails app — web (Puma) + Sidekiq workers + a clock process, Heroku Postgres Standard/Premier, Heroku Redis, log + APM add-ons — commonly runs $1,800–$3,500/month. The same workload on AWS — Fargate (web + Sidekiq services) + RDS/Aurora PostgreSQL + ElastiCache + S3/CloudFront + CloudWatch, with a Compute Savings Plan — commonly lands at $700–$1,400/month. That recovered margin pays for the migration — and with MAP, AWS pays for it instead.

where things land

IIThe target architecture: a Rails monolith on AWS

There is no single "AWS version of Heroku," but a Rails app has a well-worn target shape. The compute choice is the one real decision; the data and supporting services map almost one-to-one, and your Rails code barely changes.

For compute, the two realistic targets are Amazon ECS on Fargate and AWS App Runner. ECS on Fargate is the workhorse and the most common landing spot for a Rails monolith: serverless containers, full VPC networking control, independent services for web and Sidekiq, an Application Load Balancer in front of Puma, and separate autoscaling policies (web scales on request count or CPU; Sidekiq scales on queue depth). App Runner is the closest thing to Heroku — point it at a container image and it builds, deploys, serves HTTPS, and autoscales — and it is a fine target for a small, mostly-web Rails app. But the moment you have a real Sidekiq fleet, Action Cable, and private database networking, Fargate is the right answer because that control is exactly what the team outgrew Heroku for. (EKS is overkill straight off Heroku unless you already run Kubernetes.)

The web tier is Puma running in a container, registered as an ECS service behind an ALB. The ALB terminates TLS (with a free ACM certificate), health-checks a lightweight Rails endpoint (e.g. `/up`, the built-in Rails health check), and load-balances across tasks. Rails runs with `RAILS_ENV=production`, `RAILS_LOG_TO_STDOUT=true` (so logs flow to CloudWatch), and `RAILS_SERVE_STATIC_FILES` left off because static assets are served by CloudFront, not Puma. Database migrations (`rails db:migrate`) run as a one-off ECS task or a pipeline step at release time — the AWS equivalent of Heroku's release phase.

For the relational database, PostgreSQL moves to Amazon RDS for PostgreSQL (like-for-like managed Postgres, simplest mental model) or Aurora PostgreSQL (AWS's cloud-native engine — slightly more per hour, but faster failover, autoscaling storage, up to 15 read replicas, and Serverless v2 capacity scaling). A common pattern is to land on RDS first to minimize change at cutover, then evaluate Aurora once stable. Either way it is still PostgreSQL, so ActiveRecord, your schema, and your queries are unchanged. Put RDS Proxy (or PgBouncer) in front from day one — a Rails app with multiple Puma workers and a Sidekiq fleet opens far more connections than a single Postgres instance wants to hold.

Redis becomes Amazon ElastiCache (Redis OSS or Valkey) — same protocol, so the three things Rails uses Redis for keep working with a connection-string change: Sidekiq queues, Action Cable's pub/sub backend, and the Rails cache store (`config.cache_store = :redis_cache_store`). Active Storage attachments live on Amazon S3 via the `aws-sdk-s3` gem and the `:amazon` service in `config/storage.yml`, fronted by CloudFront for delivery; if you were on Heroku you were already on S3 because the dyno filesystem is ephemeral, so this is usually a credentials-and-bucket swap. Scheduled jobs (the `whenever` gem, `sidekiq-cron`, or Heroku Scheduler) become either sidekiq-cron on the worker service or Amazon EventBridge Scheduler triggering an ECS task. Outbound email via Action Mailer moves to Amazon SES or stays with your existing provider.

App Runner vs Fargate for Rails — pick on control

App Runner for a small, mostly-web Rails app when you want git-push simplicity and minimal new operational surface. ECS on Fargate when you have a real Sidekiq fleet, Action Cable, independent web/worker autoscaling, or need private VPC connectivity to RDS and ElastiCache — the most common landing spot for "we outgrew Heroku" Rails teams. Skip EKS unless you already run Kubernetes.

the lookup table

IIIRails → AWS service map (the principles behind the table)

The full row-by-row mapping — every Rails component, its AWS service, the cost direction, and the effort — lives in the comparison table further down. Two principles make that table safe to read.

First, anything that speaks a standard protocol moves with a connection-string or credential change and no Rails rewrite: PostgreSQL (→ RDS/Aurora), Redis for Sidekiq/Action Cable/cache (→ ElastiCache), and S3 for Active Storage (usually already in place). These are the "trivial" rows — ActiveRecord, Sidekiq, Action Cable, and Active Storage are all storage-agnostic by design.

Second, anything platform-proprietary needs a one-time translation into an AWS-native primitive: the buildpack + Procfile become a Dockerfile, Rails credentials + config vars become Secrets Manager + SSM Parameter Store, the asset pipeline (Propshaft/Sprockets) precompiles in the image and serves via CloudFront, scheduled jobs become sidekiq-cron or EventBridge Scheduler, and custom domains/SSL become Route 53 + ACM. Done once and documented, that translation is the work a migration partner does — the "Medium/High effort" rows in the table.

the hands-on part

IVDockerizing Rails, and Rails credentials → Secrets Manager

The single most hands-on task is turning the buildpack-and-Procfile model into a container image — and Rails has its own wrinkles (asset precompilation, the master key, the bootsnap cache). It is mechanical, but it is where teams without container experience slow down, so here is exactly what it involves.

Modern Rails (7.1+) ships a generated production `Dockerfile`, and it is a good starting point: a multi-stage build that installs gems with `bundle install`, runs `rails assets:precompile` at build time, then copies only the gems and the precompiled app into a slim runtime image so the final container has no build toolchain. Each process type from your `Procfile` becomes a separate container command or ECS service: the `web` line (`bundle exec puma`) is the load-balanced service, the `worker` line (`bundle exec sidekiq`) is its own service, and a `clock`/scheduler process (if you use `clockwork` or `whenever`) is either folded into sidekiq-cron or replaced by EventBridge Scheduler. Heroku's release phase — `rails db:migrate` on deploy — becomes a one-off ECS run-task or a CodeBuild step gated before the new web tasks go live.

Asset precompilation is the Rails-specific trap. On Heroku the buildpack runs `assets:precompile` for you; in a Dockerfile you must do it explicitly, and it needs a dummy `SECRET_KEY_BASE` at build time (Rails requires one to initialize, even though precompilation does not use real secrets) — `SECRET_KEY_BASE_DUMMY=1` in recent Rails handles this cleanly. Precompiled assets are fingerprinted and baked into the image, then served by CloudFront (origin = the ALB or an S3 bucket you sync them to), so Puma never serves static files in production. Get this wrong and you see missing CSS/JS after cutover — the classic "it worked locally" Rails-on-containers failure.

Rails secrets are the other translation, and Rails is opinionated here. Production Rails uses `config/credentials.yml.enc` (or environment-specific `config/credentials/production.yml.enc`) decrypted at boot by the `RAILS_MASTER_KEY`. On Heroku that master key is a config var; on AWS the master key becomes a secret in AWS Secrets Manager, injected into the ECS task definition so the container sees `RAILS_MASTER_KEY` exactly as before and decrypts credentials normally. Everything else splits by sensitivity: real secrets (`DATABASE_URL`, `REDIS_URL`, third-party API keys, `SECRET_KEY_BASE` if you set it explicitly) into Secrets Manager; non-sensitive config (feature flags, public URLs, `RAILS_LOG_LEVEL`, `RAILS_MAX_THREADS`) into SSM Parameter Store. Both are referenced from the task definition so Rails reads them as plain environment variables — your `config/` code does not change. The migration step is a one-time export of `heroku config`, a sort into secret-vs-config, and a load into Secrets Manager / Parameter Store, scripted by a partner so nothing leaks into an image layer or is missed.

Two Rails conveniences to consciously replace: the ephemeral filesystem (Active Storage on S3 already handles uploads, but check for any code writing to `tmp/` or `public/` and expecting it to persist across tasks — it will not, since Fargate tasks are independent) and automatic `DATABASE_URL`/`REDIS_URL` injection (now you point those at your RDS and ElastiCache endpoints over private VPC networking, with the right `sslmode`). Neither is hard; both are easy to forget — which is why they live on the migration checklist, not in someone's memory.

sidekiq, action cable, scheduling

VBackground jobs, Action Cable, and scheduled work on AWS

Rails apps lean on Redis-backed background processing and real-time features more than most stacks. All of it ports cleanly — the trick is giving each concern the right AWS shape rather than cramming it into one container.

Sidekiq becomes its own ECS/Fargate service, separate from web, pointed at the same ElastiCache Redis endpoint. Running it as a distinct service is the whole point of moving to Fargate: you scale workers on queue depth (a CloudWatch metric or a custom autoscaling policy) independently of web traffic, size them for memory-hungry jobs without overpaying the web tier, and deploy them on their own cadence. Active Job sits on top unchanged — your `perform_later` calls do not know or care that the queue now lives in ElastiCache instead of Heroku Redis. ElastiCache should be configured for persistence appropriate to your durability needs (Sidekiq jobs are typically acceptable to lose on a hard failure, but Sidekiq Pro/Enterprise reliability features and a replicated ElastiCache setup raise the floor).

Action Cable — Rails' WebSocket layer — needs two things on AWS: the Redis pub/sub backend (the same ElastiCache cluster, set in `config/cable.yml`) and an ALB configured to handle WebSocket upgrades (the Application Load Balancer supports WebSockets natively; just ensure idle timeouts are generous enough for long-lived connections). If you run Action Cable in-process with Puma it scales with the web service; high-volume real-time apps sometimes split it into its own ECS service so connection load does not compete with request handling. Either way the code is unchanged — only `cable.yml` points at ElastiCache.

Scheduled jobs are the piece that must be recreated explicitly, and the classic "we forgot one" failure. If you use `sidekiq-cron`, the schedule lives in Redis/config and rides along on the Sidekiq service with no extra AWS plumbing — the simplest path. If you use the `whenever` gem (which writes a crontab) or Heroku Scheduler, those do not exist on Fargate; the clean replacement is Amazon EventBridge Scheduler firing an ECS run-task (e.g. `rails runner` or a rake task) on a cron expression, or migrating those jobs into sidekiq-cron. Whichever you choose, inventory every scheduled job in the Assess phase — a nightly billing rake task that silently stops firing after cutover is the kind of bug nobody notices until the invoices are late.

The Rails cache store (`:redis_cache_store`) and any rate-limiting (Rack::Attack on Redis) point at the same ElastiCache cluster with a connection-string change. The only judgment call is whether Sidekiq, Action Cable, and the cache share one ElastiCache cluster (fine for most apps, simplest) or get separated for isolation at scale — a sizing decision a partner makes during the Assess phase based on your traffic and durability requirements.

one Redis or several?

For most Rails apps a single ElastiCache (Redis/Valkey) cluster serves Sidekiq queues, Action Cable pub/sub, and the Rails cache — simplest and cheapest. Separate clusters make sense only at scale or when durability needs diverge (e.g. you want the cache to be evictable but Sidekiq queues to be replicated/persisted). Either way, put Sidekiq on its own Fargate service so workers autoscale on queue depth independently of web.

the asset pipeline + delivery

VIThe asset pipeline and CDN: Propshaft/Sprockets + CloudFront

Serving CSS, JavaScript, and images correctly is where a Rails-on-AWS migration most often shows a visible regression. The rule is simple: precompile at build time, let CloudFront serve the result, and never make Puma serve static files in production.

Whether your app uses Propshaft (the modern Rails default), Sprockets (the classic asset pipeline), or a JavaScript bundler via jsbundling-rails/cssbundling-rails (esbuild, Vite, Tailwind), the production move is the same: `rails assets:precompile` runs in the Docker build, producing fingerprinted, digest-stamped files in `public/assets`. Those files are immutable and cache-forever-safe because the digest changes whenever the content does — which is exactly what makes a CDN effective in front of them.

CloudFront sits in front of asset delivery, with one of two common origins. The simplest is CloudFront → ALB → Rails, with Rails configured so `config.public_file_server.enabled` serves precompiled assets only as a fallback and CloudFront caches them aggressively; this keeps everything in the image and is the least moving parts. The other pattern syncs `public/assets` to an S3 bucket at deploy time and points CloudFront at S3 for assets while the dynamic app stays behind the ALB — slightly more pipeline work, but it takes static-file serving entirely off your Fargate tasks. For Active Storage user uploads, CloudFront fronts the S3 bucket directly (with signed URLs or an origin access control), so image variants and downloads are served from the edge rather than streamed through Rails.

The two regressions to watch for: a missing or mismatched asset host (set `config.asset_host` to the CloudFront domain so `asset_path` helpers emit CDN URLs), and forgetting that `RAILS_SERVE_STATIC_FILES` / `config.public_file_server.enabled` interact with whether Rails will serve assets at all — get the combination wrong and you either double-serve through Puma (slow) or 404 the assets entirely (broken UI). A partner standardizes this so the first deploy looks identical to Heroku rather than shipping a stylesheet-less page to production.

the risky 15 minutes

VIIThe database cutover: DMS, DNS, and zero-downtime

Everything else — containers, the landing zone, Sidekiq, assets — can be built and tested without touching production. The PostgreSQL cutover is the one moment that touches live data, and where a plan earns its keep. Done right, user-visible downtime is zero to fifteen minutes.

The core technique is to keep the new RDS/Aurora database continuously in sync with your current Postgres while you test, so that at cutover you switch to an already-current copy rather than copying a cold database under time pressure. AWS Database Migration Service (DMS) does exactly this: a full load into RDS/Aurora, then a switch into change data capture (CDC) mode that streams every subsequent insert/update/delete in near-real time. Because both ends are PostgreSQL, this is a homogeneous migration — no Schema Conversion Tool needed; the schema moves as-is via a `pg_dump --schema-only` restore (which also brings your `schema_migrations` table, so Rails knows exactly which migrations have run). Very small databases can skip DMS for a single `pg_dump | pg_restore` inside the window; DMS earns its place once a cold dump/restore would blow past an acceptable downtime window.

With CDC running and the new stack smoke-tested against the synced database — including a full Sidekiq run and Action Cable connections — the cutover is short and scripted: enable Rails maintenance mode (or a maintenance page at the ALB) → let DMS drain the last few seconds → flip `DATABASE_URL` to RDS/Aurora and shift traffic to the new Fargate service → update the Route 53 record (TTL lowered ahead of time) → verify writes land in RDS and Sidekiq is draining its queues → exit maintenance. Because the data was already synced, the window is dominated by DNS propagation and verification, not data transfer — commonly 0–15 minutes during a low-traffic period. Pause Sidekiq during the window so jobs are not enqueued against one database and processed against another.

The rollback plan is non-negotiable and simple: keep the old environment (Heroku, the old VPS) running until you are confident, and if something is wrong, switch `DATABASE_URL` and DNS back. The clean window is before RDS has accepted production writes the old database hasn't seen — once committed, rolling back means reconciling those writes — so most teams keep the rollback window short (minutes to hours) and watch closely. A Rails-specific check: confirm the PostgreSQL sequence values came across correctly (DMS handles this, but verify `id` sequences are ahead of the max row id) so you do not get primary-key collisions on the first writes after cutover.

why DMS over a cold pg_dump

A cold pg_dump | pg_restore makes your downtime window = export + transfer + import of the whole database: 20–60 minutes for 5GB, unacceptable at 50GB+. DMS with CDC moves the bulk load before the window and only drains the last few seconds during it — so downtime stays in single-digit minutes regardless of database size. Always verify row counts and sequence values before exiting maintenance.

the runbook

VIIIThe step-by-step migration (assess → land → cut over → optimize)

Here is the end-to-end sequence a partner runs, mapped to the AWS MAP phases (Assess → Mobilize → Migrate). For a typical Rails app this is a 3–8 week project — most of it parallelizable, none of it touching production until the final cutover.

  • 1 — Assess (days 1–5) — Inventory the Procfile processes (web, Sidekiq, clock), every gem with infra implications (Sidekiq, Action Cable, Active Storage, whenever/clockwork), config vars, scheduled jobs, the Postgres size + connection pattern, and the asset pipeline. Decide Fargate vs App Runner and RDS vs Aurora; produce the target architecture and a cost before/after model. Under MAP this Assess/TCO phase is typically free.
  • 2 — Build the landing zone — Stand up the AWS account structure, VPC, subnets, security groups, IAM roles, ECR, and Secrets Manager / Parameter Store. (Reuses the AWS Landing Zone and AWS DevOps foundations in the DevOps cluster.)
  • 3 — Containerize Rails — Adopt/refine the multi-stage Dockerfile, wire asset precompilation (with SECRET_KEY_BASE_DUMMY), split the Procfile into a web service and a Sidekiq service, and confirm the image builds and boots locally and in CodeBuild.
  • 4 — Provision data + cache + storage — Create the RDS/Aurora instance (with RDS Proxy), the ElastiCache cluster, and the S3 bucket for Active Storage in the VPC; restore the schema; point cable.yml and the cache store at ElastiCache; wire EventBridge Scheduler or sidekiq-cron for scheduled jobs.
  • 5 — Start replication — Run a DMS full load from the source Postgres into RDS/Aurora, then switch to CDC so the new database tracks production while you test. Validate row counts, known records, and sequence values.
  • 6 — Deploy + smoke-test — Deploy the Fargate web + Sidekiq services against the synced database (no public traffic yet); run the full test suite and manual smoke tests including background jobs, Action Cable connections, file uploads, and scheduled tasks; confirm assets load via CloudFront.
  • 7 — Cut over — Maintenance mode → pause Sidekiq → drain CDC → flip DATABASE_URL/REDIS_URL → update Route 53 (low TTL pre-set) → verify writes + sequences in RDS and Sidekiq draining → exit maintenance. Target window: 0–15 minutes.
  • 8 — Optimize + decommission — Apply a Compute Savings Plan, right-size Fargate (web + Sidekiq) and RDS, set autoscaling on queue depth and CPU, confirm CloudWatch alarms and RDS backups. Keep the old environment as rollback for a short window, then cancel the dynos/add-ons — that cancellation is when the savings become real.
real numbers

IXCost before and after — a representative Rails app

Cost is the reason most teams start this, so here is a concrete model rather than a hand-wave. Numbers are 2026 list-price ranges for a mid-size production Rails app; yours vary with traffic, Sidekiq volume, and how aggressively you right-size and commit.

Take a typical Series-A Rails SaaS: web (Puma) across a few dynos, a Sidekiq worker fleet, a clock process, Heroku Postgres Standard/Premier, Heroku Redis, and the usual logging + APM add-ons. On Heroku that commonly totals $1,800–$3,500/month and scales roughly linearly — every new worker dyno and plan bump adds a fixed chunk, and a Sidekiq-heavy app adds workers fast.

The same workload on AWS: web + Sidekiq on Fargate (right-sized, with off-hours scale-down on web and queue-depth autoscaling on workers), RDS/Aurora PostgreSQL with RDS Proxy, an ElastiCache node, S3 + CloudFront for Active Storage and assets, CloudWatch, plus modest data transfer. At list price that is often $900–$1,600/month; layer a one- or three-year Compute Savings Plan and committed database capacity on top and steady-state lands closer to $700–$1,400/month. That is the 40–70% reduction Rails teams cite — and unlike Heroku, the AWS bill bends down as you commit and right-size rather than only stepping up.

The cost the table cannot show is the migration itself: engineering time, dual-running the old platform + AWS for a few weeks, and the learning curve of owning the infrastructure. This is exactly where the CloudRoute model changes the arithmetic. When the workload qualifies for the AWS Migration Acceleration Program, AWS funds the assessment and credits a large share of the migration/modernization cost — the partner is paid through MAP — so you capture the 40–70% ongoing savings without paying the usual migration bill. When MAP does not apply, it is a vetted-partner engagement that de-risks the cutover: you pay for the work, but you are not improvising the riskiest 15 minutes of your year.

representative Rails app · monthly cost before/after (2026 list-price ranges)
LayerHeroku (before)AWS at listAWS w/ Savings PlanHow
Web (Puma)$500–$900 (web dynos)$200–$400 (Fargate)$130–$260Right-size + off-hours scale-down
Sidekiq workers$500–$1,000 (worker dynos)$180–$400 (Fargate)$120–$260Autoscale on queue depth
PostgreSQL$400–$900 (HP Standard/Premier)$250–$550 (RDS/Aurora)$170–$400Reserved/committed capacity + RDS Proxy
Redis$200–$450 (Heroku Redis)$70–$200 (ElastiCache)$50–$150Single cluster, right-sized node
Storage + CDN$0–$50 (S3 add-on)$30–$120 (S3 + CloudFront)$30–$120Active Storage on S3, edge delivery
Logs / APM$200–$400 (add-ons)$40–$120 (CloudWatch)$40–$120CloudWatch Logs + Container Insights
Total$1,800–$3,500$900–$1,600$700–$1,400≈40–70% lower steady-state
Illustrative ranges for a mid-size Series-A Rails app; your numbers move with traffic, Sidekiq volume, and commitment level. The Heroku column scales by fixed dyno/plan steps; the AWS column bends down as you right-size and commit. With MAP, the one-time migration cost on top of this is funded by AWS.
what bites teams

XRails-specific gotchas: what turns a clean migration messy

Most Rails→AWS migrations that go badly go badly for a small, repeatable set of Rails-specific reasons. Naming them up front is the cheapest insurance there is.

  • Assets compile wrong → no CSS after cutover — The number-one Rails-on-containers failure: assets not precompiled in the image, a missing dummy SECRET_KEY_BASE at build time, or config.asset_host not pointing at CloudFront. Verify the production image serves fingerprinted assets through the CDN before the cutover window, not during it.
  • Connection limits move, they don't vanish — A bigger RDS instance buys headroom, but a Rails app with multiple Puma workers and a Sidekiq fleet opens far more connections than one Postgres wants. The real fix is RDS Proxy (or PgBouncer) plus a sane RAILS_MAX_THREADS / pool size — plan pooling into the architecture, not after the incident.
  • Sidekiq and scheduled jobs must be recreated explicitly — Run Sidekiq as its own Fargate service (not inside the web container), and recreate every scheduled job in sidekiq-cron or EventBridge Scheduler. A nightly rake task that silently stops firing after cutover — billing, digests, cleanup — is the classic "we forgot one." Inventory them in Assess.
  • The 30-second timeout hid slow requests — Heroku killed any request over 30 seconds, masking slow Rails endpoints (heavy reports, N+1 queries, synchronous third-party calls). Behind an ALB you can raise the timeout — but a 28-second request was already a problem. Cutover is the moment to move long work to Active Job/Sidekiq.
  • SSL / DATABASE_URL and RAILS_MASTER_KEY drift — Heroku injects DATABASE_URL with specific SSL parameters; RDS/Aurora want their own sslmode and CA bundle — many "won't connect after cutover" issues are just the connection string. And if RAILS_MASTER_KEY isn't wired into the task definition from Secrets Manager, Rails won't boot at all (it can't decrypt credentials). Verify both before the window.
  • Ephemeral filesystem assumptions — Active Storage on S3 covers uploads, but audit for any code writing to tmp/ or public/ and expecting persistence — it worked on one dyno by luck and breaks across independent Fargate tasks. Stateful data must use S3 or the database, never local disk.
  • Forgetting to decommission the old platform — Savings are not real until the dynos and add-ons are cancelled. Keep the old environment as rollback for a defined short window — then actually turn it off. Paying both bills "just in case" for months erases a chunk of year-one savings.
component-by-component

Rails component → AWS service (cost delta + effort)

The definitive lookup: every part of a Ruby on Rails app, where it lands on AWS, the rough cost direction, and the engineering effort. "Trivial" rows are connection-string or credential swaps; "Medium/High" rows are the real work — and the work a partner does for you.

Rails componentAWS serviceWhat changesCost directionEffort
Web (Puma) processECS/Fargate + ALB (or App Runner)Buildpack → Dockerfile; router → ALBDown 40–70%Medium
Sidekiq workersECS/Fargate service (own service)Worker dyno → separate autoscaling serviceDown 40–70%Medium
PostgreSQL (ActiveRecord)RDS for PostgreSQL / Aurora PostgreSQLConnection string + cutover via DMSDown 30–60%High (cutover)
Redis (Sidekiq / cache)ElastiCache (Redis OSS / Valkey)Connection string onlyDown 50–70%Trivial
Action Cable (WebSockets)ElastiCache pub/sub + ALB WebSocketscable.yml → ElastiCache; ALB upgrade configDownLow
Active Storage uploadsAmazon S3 + CloudFront:amazon service in storage.yml; usually already S3DownTrivial
Asset pipeline (Propshaft/Sprockets)Precompile in image → CloudFrontassets:precompile at build; asset_host = CDNDownMedium
Rails credentials + master keyAWS Secrets ManagerRAILS_MASTER_KEY + secrets → task definitionNegligibleLow
Config vars (ENV)Secrets Manager + SSM Parameter StoreSplit secret vs config; inject to taskNegligibleLow
Buildpack + ProcfileDockerfile + ECRExplicit multi-stage container buildNegligibleMedium
Scheduled jobs (whenever/Scheduler)sidekiq-cron or EventBridge SchedulerRecreate each job explicitlyDown (near $0)Low
db:migrate on deploy (release phase)One-off ECS run-task / CodeBuild stepRelease phase → gated migration taskNegligibleLow
Action Mailer (email)Amazon SES (or provider direct)Swap SMTP/API configDownLow
Logs + APM add-onsCloudWatch Logs + Container InsightsRAILS_LOG_TO_STDOUT → CloudWatch sinkDown vs add-onsLow
Custom domain + SSLRoute 53 + ACM (free TLS)DNS + cert on ALB/App RunnerDown (free certs)Low
CI/CD (Heroku Pipelines)CodePipeline + CodeBuild (or GitHub Actions)Build image → ECR → update ECS serviceDown / neutralMedium
Cost directions are representative 2026 ranges and depend on right-sizing and commitments (Savings Plans, committed DB capacity). The high-effort rows — the Postgres cutover, Dockerizing with asset precompilation, the CI/CD rebuild — are exactly what a MAP-funded partner handles end-to-end.
want this run for you — and funded?
Get matched with an AWS partner who runs your Rails migration (often MAP-funded)
Start in 3 minutes →
a recent match

A Rails→AWS migration CloudRoute routed — anonymized

inquiry · series-a b2b saas (rails 7.1 monolith), Toronto
Series-A B2B SaaS on a Ruby on Rails 7.1 monolith: ~6 web (Puma) dynos + a Sidekiq fleet (~8 worker dynos across three queues) + a clock process, Heroku Postgres Premier-2, Heroku Data for Redis, Action Cable for in-app notifications, Active Storage already on S3. Heroku bill ~$3,300/month and climbing; hitting the 500-connection ceiling at peak and the 30-second router timeout on export endpoints.

Situation: Margin pressure ahead of a Series-B raise, plus two hard technical walls: Postgres connections maxing out at peak (Puma pools × Sidekiq pools) and long CSV-export requests dying at the 30s router timeout. The three-person platform team had no container/AWS experience and could not afford a multi-month DIY migration or a risky big-bang database cutover with Sidekiq jobs in flight.

What CloudRoute did: Routed within 24 hours to an AWS Advanced-tier partner with a Rails + Heroku track record, who ran the MAP Assess phase (free) and filed the work as a MAP engagement. Target: ECS on Fargate with three services (Puma web behind an ALB, a Sidekiq service autoscaling on queue depth, and a small Action Cable service), Aurora PostgreSQL with RDS Proxy for pooling, ElastiCache for Sidekiq + Action Cable + the Rails cache, S3 + CloudFront for Active Storage and precompiled assets, Rails credentials + RAILS_MASTER_KEY in Secrets Manager with the rest split into Parameter Store, and GitHub Actions → ECR → ECS for deploys. Postgres moved via AWS DMS (full load + CDC); the export endpoints were refactored onto a dedicated Sidekiq queue.

Outcome: Cutover ran in a Sunday-night window with ~12 minutes of write-downtime — Sidekiq paused, DMS had Aurora fully in sync (row counts and sequences verified), so the switch was DNS + verification, not data transfer. Steady-state AWS bill landed at ~$1,250/month (a ~62% cut); the connection ceiling and export timeouts both gone; project ran ~6 weeks. Because the workload qualified for MAP, AWS funded the assessment and credited the migration cost — out-of-pocket migration cost was effectively $0, and CloudRoute's commission was paid by the partner from MAP funding.

project length: ~6 weeks · cutover downtime: ~12 min · monthly spend: $3,300 → $1,250 (−62%) · migration cost to customer: ~$0 (MAP-funded)

faq

Common questions

What is the best way to run a Ruby on Rails app on AWS?
For a Rails monolith the most common and flexible target is Amazon ECS on Fargate: the Puma web process runs as one service behind an Application Load Balancer, Sidekiq runs as a separate autoscaling service, PostgreSQL lives on RDS or Aurora PostgreSQL (with RDS Proxy for connection pooling), Redis on ElastiCache backs Sidekiq/Action Cable/the cache, and Active Storage uses S3 fronted by CloudFront. AWS App Runner is a simpler, more Heroku-like option for a small mostly-web app, but a real Sidekiq fleet and private database networking usually point to Fargate.
How long does it take to migrate a Rails app to AWS?
For a typical Rails app — a few web and Sidekiq processes, PostgreSQL, Redis, Active Storage, and a few add-ons — a partner-run migration is usually 3–8 weeks end-to-end. Most of that is building and testing the landing zone, the container images, and the synced database in parallel, without touching production; only the final cutover (a single short maintenance window) touches live traffic.
Will ActiveRecord, Sidekiq, and Action Cable migrate without code changes?
Largely yes. PostgreSQL → RDS/Aurora PostgreSQL is a homogeneous move, so ActiveRecord, your schema, and your queries are unchanged — you swap DATABASE_URL and migrate the data with DMS. Sidekiq, Action Cable, and the Rails cache all talk to Redis, which becomes ElastiCache with a connection-string change (cable.yml and the Sidekiq Redis config point at the ElastiCache endpoint). The code you actually touch is the container build (buildpack → Dockerfile with asset precompilation) and how secrets and config are loaded, not your application logic.
How do I handle Rails credentials and the master key on AWS?
Production Rails decrypts config/credentials.yml.enc at boot using RAILS_MASTER_KEY. On AWS, store the master key as a secret in AWS Secrets Manager and inject it into the ECS task definition so the container sees RAILS_MASTER_KEY exactly as on Heroku and decrypts credentials normally — if it is missing, Rails will not boot. Split the rest of your environment by sensitivity: real secrets (DATABASE_URL, REDIS_URL, API keys) into Secrets Manager, non-sensitive config (feature flags, log level, RAILS_MAX_THREADS) into SSM Parameter Store. Both are referenced from the task definition so Rails reads them as plain environment variables.
How do Sidekiq background jobs and scheduled tasks work on AWS?
Run Sidekiq as its own ECS/Fargate service pointed at ElastiCache Redis, scaled on queue depth independently of web traffic — Active Job and your perform_later calls are unchanged. Scheduled jobs must be recreated explicitly: sidekiq-cron rides along on the Sidekiq service with no extra AWS plumbing, or you use Amazon EventBridge Scheduler to fire an ECS run-task (a rake task or rails runner) on a cron expression. The whenever gem and Heroku Scheduler do not exist on Fargate, so inventory every scheduled job during the Assess phase so none silently stops firing after cutover.
How much downtime is there at cutover, and what about my data?
Typically 0–15 minutes of write-downtime, run during a low-traffic period. AWS Database Migration Service (DMS) bulk-loads your PostgreSQL data into RDS/Aurora ahead of time, then streams ongoing changes via change data capture. At cutover you pause Sidekiq, drain the last few seconds of CDC, flip DATABASE_URL, and update DNS — so the window is dominated by DNS propagation and verification, not copying data, regardless of database size. Verify row counts and PostgreSQL sequence values before exiting maintenance so the first writes after cutover do not collide on primary keys.
What does AWS MAP funding mean for the cost of the migration?
The AWS Migration Acceleration Program (MAP) is how the migration itself can cost little to nothing. For qualifying migrations — generally those with a meaningful projected AWS spend afterward — AWS funds the Assess phase (TCO/readiness, typically free) and credits a large share of the migration/modernization cost, paying the partner through MAP rather than you. When a workload does not qualify, CloudRoute still routes you to a vetted partner who runs the cutover at a fixed scope, so you are not improvising the riskiest part alone.
How much will I actually save moving my Rails app off Heroku to AWS?
Representative mid-size Rails apps cut their monthly bill by 40–70%. A workload running ~$1,800–$3,500/month on Heroku (web + Sidekiq dynos + Heroku Postgres + Redis + add-ons) commonly lands at $700–$1,400/month on AWS once you right-size Fargate and RDS, autoscale Sidekiq on queue depth, and apply a Compute Savings Plan. The savings come from buying AWS services at list/committed price instead of Heroku's per-dyno pricing and add-on markups — and a Sidekiq-heavy Rails app, which adds worker dynos fast on Heroku, tends to save at the higher end of that range.

Move your Rails app to AWS — run by a partner, often funded by AWS.

CloudRoute routes you to a vetted AWS partner who plans and runs the whole Ruby on Rails migration — Fargate for web + Sidekiq, RDS/Aurora, ElastiCache, S3/CloudFront, Secrets Manager, the DMS cutover, the cost optimization. Qualifying migrations are MAP-funded, so you capture the 40–70% ongoing savings without paying the usual migration bill.

matched within< 24h
typical cost cut40–70%
migration cost (MAP)$0–low
Migrate a Rails App to AWS — Architecture & Cutover (2026) · CloudRoute