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:

  1. cp config/production.rb config/staging.rb
  2. Change some settings in staging
  3. 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