Setting up PR previews for Rails on Render.com
tl;dr - Use the IS_PULL_REQUEST
environment variable set by Render to fork and use your staging environment config.
I've really been enjoying the trend in developer experience around PR based workflows. The idea that you push a branch to GitHub, create a PR, and that action kicks off a cascade of workflows that build confidence for releasing new features.
Several years ago, I started using "staging" branches and the team would all push to a staging branch that everyone would share and we'd check to see if the changes we made worked in an environment closer to production. Staging was also a way for product managers or other stakeholders to review and see if the changes made sense.
While at Stripe, we had a tool called "draft horse" or something similar. For each PR, it would spin up all the required infrastructure and servers to review every change. When I first started using this in 2019, it made total sense. Obviously, it comes at a price, a potentially high AWS bill if you're replicating all the boxes at the same sizes and configurations.
When I joined Craftwork, earlier this year, Mike had already configured Vercel's preview workflow to spin up new instances of our Next.js application which is :chefs-kiss:. We also use Inngest for background jobs with Next.js and they also provide a preview feature. For each preview box spun up on Vercel, Inngest would create a new "Branch environment" where you could debug your background jobs.
I wanted to set up something similar for Rails deployed on Render.com, but wasn't able to find any complete examples online showing how to get set up. This post is for future me when I inevitably try to go do this again in several years.
Setting up a staging environment with Rails
We're using Jumpstart Pro which came with a staging environment set up already. Watch Chris' GoRails video about how to set that up. It's basically:
cp config/production.rb config/staging.rb
- Change some settings in staging
- Create staging credentials with
EDITOR=vi rails credentials:edit --environment=staging
. Note: you probably want different staging env variables for things like AWS and Stripe.
Setting up an Env group in Render
Head over to Env groups and make sure you have one set up. We'll link to this env group from our production and all preview / PR builds.
I set both the RAILS_MASTER_KEY
to the key for the production credentials and STAGING_RAILS_MASTER_KEY
for the key for staging credentials.
Rails doesn't know that it needs to use the STAGING key for pull requests, so we have to tell it to use that STAGING key with some bash scripts that I'll share below.
Configure render.yaml
render.yaml
is the config file in the root of the rails application that tells Render's blueprints how to configure each service.
Here's the full render.yaml
for reference. Take note of these keys: previewsEnabled
, previewPlan
, buildCommand
, startCommand
, and initialDeployHook
.
# set up preview builds
previewsEnabled: true
# Example Render configuration. You will need to adjust this for the different services you run.
# Replace repo url with the repository url for your Jumpstart Pro application
services:
- type: web
plan: standard
previewPlan: starter
repo: https://github.com/yourorg/yourrepo
name: yourrepo-rails
env: ruby
region: ohio
buildCommand: './bin/render-build.sh'
startCommand: './bin/render-start.sh'
initialDeployHook: './bin/render-seed.sh'
envVars:
- key: RAILS_MASTER_KEY
sync: false
- key: DATABASE_URL
fromDatabase:
name: postgres
property: connectionString
- key: REDIS_URL
fromService:
type: redis
name: redis
property: connectionString
- fromGroup: default-env
- type: redis
name: redis
region: ohio
ipAllowList: [] # only allow internal connections
plan: starter # optional (defaults to starter)
maxmemoryPolicy: noeviction # optional (defaults to allkeys-lru)
- type: worker
name: sidekiq-worker
env: ruby
plan: starter # no free option for bg workers
region: ohio # the region must be consistent across all services for the internal keys to be read
buildCommand: './bin/render-build.sh'
startCommand: './bin/render-start-sidekiq.sh'
envVars:
- key: DATABASE_URL
fromDatabase:
name: postgres
property: connectionString
- key: REDIS_URL
fromService:
name: redis
type: redis
property: connectionString
- key: RAILS_MASTER_KEY
sync: false
- fromGroup: default-env
databases:
- name: postgres
plan: standard
region: ohio # the region must be consistent across all services for the internal keys to be read
ipAllowList: [] # only allow internal connections
Again, the trick is to use the IS_PULL_REQUEST
environment variable set by Render. I wrote a couple little bash scripts for the build, start, and initHook commands.
./bin/render-start.sh
First, we check if IS_PULL_REQUEST
is set to true, and if so, wet the RAILS_ENV and RACK_ENV to staging, then update the RAILS_MASTER_KEY to use STAGING_RAILS_MASTER_KEY
.
#!/usr/bin/env bash
# exit on error
set -o errexit
if [[ "${IS_PULL_REQUEST}" == "true" ]]; then
echo "IS_PULL_REQUEST is set. Setting staging environment variables and starting server."
export RAILS_ENV=staging
export RACK_ENV=staging
export RAILS_MASTER_KEY=${STAGING_RAILS_MASTER_KEY}
bundle exec rails s -e staging
else
echo "IS_PULL_REQUEST is not set or is set to false. Setting production environment variables and starting server."
export RAILS_ENV=production
export RACK_ENV=production
bundle exec rails s -e production
fi
./bin/render-start-sidekiq.sh
Similarly, when I start sidekiq for processing background jobs, it should use the staging environment:
#!/usr/bin/env bash
# exit on error
set -o errexit
if [[ "${IS_PULL_REQUEST}" == "true" ]]; then
echo "Setting up sidekiq in staging mode"
export RAILS_ENV=staging
export RACK_ENV=staging
export RAILS_MASTER_KEY=${STAGING_RAILS_MASTER_KEY}
bundle exec sidekiq -e staging
else
echo "Setting up sidekiq in production mode"
export RAILS_ENV=production
export RACK_ENV=production
bundle exec sidekiq -e production
fi
./bin/render-seed.sh
Finally, I want to make sure the database is seeded for the PR database instances. This is where the initialDeployHook
comes in handy because you can execute something once when the server is set up the first time.
#!/usr/bin/env bash
# exit on error
set -o errexit
if [[ "${IS_PULL_REQUEST}" == "true" ]]; then
echo "IS_PULL_REQUEST is set. Setting staging environment variables and starting server."
export RAILS_ENV=staging
export RACK_ENV=staging
export RAILS_MASTER_KEY=${STAGING_RAILS_MASTER_KEY}
bundle exec rails db:seed
else
echo "IS_PULL_REQUEST is not set or is set to false. Setting production environment variables and starting server."
echo "Don't seed in production!"
fi
Working with URLs from a background worker on Render
In addition to IS_PULL_REQUEST
render has several other environment variables you can use. The docs are incorrect when they suggest that RENDER_EXTERNAL_HOSTNAME
and RENDER_EXTERNAL_URL
will be available for all services. I discovered these are only available for web services. When generating emails in background jobs, I need the host name for the web server for the PR. Unfortunately, I've gotta sort of hack around that by using regex on the RENDER_SERVICE_NAME
to pull out the PR and it's number and reconstruct the URL.
# config/environments/staging.rb
service_name = ENV.fetch("RENDER_SERVICE_NAME", "sidekiq-worker-pr-101-5d1a")
staging_host = ENV["RENDER_EXTERNAL_HOSTNAME"]
if staging_host.blank?
staging_host = "yourservice-rails-#{service_name.match(/pr-\d+/)}.onrender.com"
end
staging_url = "https://#{staging_host}"
puts "RENDER_EXTERNAL_HOSTNAME: #{ENV["RENDER_EXTERNAL_HOSTNAME"]}"
puts "RENDER_EXTERNAL_URL: #{ENV["RENDER_EXTERNAL_URL"]}"
puts "Using staging host #{staging_host} and staging url #{staging_url}"
Rails.application.routes.default_url_options[:host] = staging_host
config.action_controller.asset_host = staging_url
config.action_mailer.asset_host = staging_url
config.asset_host = staging_url