From Heroku to Render
At Dockspot
Over the past few weeks we moved Dockspot, our Rails app for marinas and boaters, from Heroku to Render. The move itself was the easy part. Most of the work was rebuilding what Heroku had been doing for us.
Why
Two things made us start looking beyond Heroku. Last June, a major outage disrupted Heroku for most of a day, its status page included. Then in early February, Heroku announced it was moving to a “sustaining engineering model”: stability, security and support, but no new features. A platform in maintenance mode isn’t one we wanted to keep building on, so we started trying out Render soon after.
The setup
Everything is declared in a Render Blueprint per environment: a Puma web service, a SolidQueue worker, a small instance that only exists so we can open a Rails console, a nightly backup job, and Metabase. We’d already moved our background jobs from Sidekiq to Solid Queue last summer, so there was no Redis to bring along: the worker just needs Postgres. The web, worker and console services all run the same Docker image of the app, built by GitHub Actions and pushed to GitHub’s container registry.
We build the image ourselves because if you let Render build from the repository, each service builds its own copy of the app: there’s no sharing builds across services. And there’s nothing like Heroku’s “promote to production”, where the exact build you tested on staging goes live. So we build the image once ourselves, run it on every app service, and deploy and roll back with our own scripts.
The web service runs migrations in its pre-deploy step, and the other services wait for them before starting:
namespace :db do
desc "Wait for all migrations to be applied"
task wait_for_migrations: :environment do
max_attempts = ENV.fetch("DB_WAIT_MAX_ATTEMPTS", 60).to_i
interval = ENV.fetch("DB_WAIT_INTERVAL", 10).to_i
puts "Waiting for migrations to complete..."
max_attempts.times do |attempt|
context = ActiveRecord::Base.connection_pool.migration_context
pending_count = context.migrations_status.count { |status, _, _| status == "down" }
if pending_count.zero?
puts "All migrations applied!"
exit 0
else
puts "Attempt #{attempt + 1}/#{max_attempts}: #{pending_count} pending migration(s), waiting #{interval}s..."
sleep interval
end
end
abort "Timed out waiting for migrations after #{max_attempts * interval}s"
end
end
What Heroku used to do for us
- Deploy tracking. On Heroku, a release webhook told Honeybadger about each deploy. Now it’s the first step of the pre-deploy command, so if a migration fails we can see which deploy broke it.
- Deploys and rollbacks. This is our replacement for Heroku’s promote. We dropped the production
branch: a script promotes the image that is live on staging to production, and moves a
productiontag on the image in the registry. Rollbacks were the tedious part. The trick was to start from the digest of the image Render is running, not its tag, and work back to the commit from there. - Backups. Heroku took our backups; now a nightly cron job runs
pg_dump, encrypts the dump with each developer’s GPG key and uploads it to S3. A small script restores the latest one into a throwaway Postgres container, so we know the backups actually work. - Dataclips. Our saved SQL reports moved to Metabase.
Render also makes it easy to run PgHero next to the database, which found a missing constraint and a few duplicate indexes.