I recently had to set up staging and production environments for a multi-tenant application that uses subdomains to specify the tenant. I opted to do both of these under the same top-level domain (TLD). In this post I will cover how I managed to get this working with the apartment gem hosted on Heroku and playing nicely with ActiveStorage and ActionMailer.

Some of these snippets are brief or from memory and do not represent my opinions on tabs, spaces, indentation, style, or favorite type of chile pepper. Don’t bother me about this.

Apartment

Run the install command for apartment and edit the initializer to use the subdomain elevator. The instructions for doing this is covered in their documentation.

I’ve added the following to this file:

Apartment::Elevators::Subdomain.excluded_subdomains = %w[staging www]

This way if we’re at staging.myapp.com we will be on the public schema and not trying to find the staging schema. Same for www.

In our ApplicationController we have a helper method to load the current tenant by subdomain:

def current_tenant
  # Use requests.subdomain.first rather than request.subdomain
  # This way we get 'some-tenant' on staging rather than 'some-tenant.staging'
  @current_tenant ||= Tenant.find_by(subdomain: request.subdomains.first)
end

Local Setup

I’ve added the following to my /etc/hosts file:

127.0.0.1 myapp.local
127.0.0.1 www.myapp.local
127.0.0.1 some-tenant.myapp.local
127.0.0.1 another-tenant.myapp.local

I’ve also added the dotenv-rails gem and the folloiwng to our .env file:

HOST=myapp.local:5000

Heroku Domains and DNS Configuration

First, we’ll add the domains to our Heroku apps.

heroku domains:add *.staging.myapp.com --app myapp-staging
heroku domains:add staging.myapp.com --app myapp-staging
heroku domains:add *.myapp.com --app myapp-production
heroku domains:add myapp.com --app myapp-production

heroku config:set HOST=staging.myapp.com --app myapp-staging
heroku config:set HOST=myapp.com --app myapp-production

This will give us some CNAME records that we’ll need to add to our DNS manager. I recommend using DNSimple or Route53, but anything that supports ALIAS will work.

myapp.com A ALIAS {myapp.com-heroku-cname}
*.myapp.com CNAME {*.myapp.com-heroku-cname}
staging.myapp.com CNAME {staging.myapp.com-heroku-cname}
*.staging.myapp.com CNAME {*.staging.myapp.com-heroku-cname}

Now that this is setup, when we go to www.staging.myapp.com or some-tenant.staging.myapp.com we will hit the staging app on Heroku. www.myapp.com or some-tenant.myapp.com will hit production.

ActionMailer

This is where things get ‘fun’. In our use case we have our user model scoped to whichever schema is loaded by apartment. Our public schema has super admins and tenant schemas have other roles. For some applications you may want to keep users in the public schema, but we want our invite, password recovery, and etc emails to always reference the tenant subdomain when providing a link to a user.

First, I added SendGrid to the staging app and configured an authorized domain with DNS records and added the following setup to config/environments/production.rb

config.action_mailer.default_url_options = { host: ENV.fetch('HOST') }
config.action_mailer.delivery_method = :smtp
config.action_mailer.perform_deliveries = true
config.action_mailer.smtp_settings = {
  user_name: ENV['SENDGRID_USERNAME'],
  password: ENV['SENDGRID_PASSWORD'],
  # The domain env var here is not automatically set by the addon
  domain: ENV['SENDGRID_DOMAIN'],
  port: 587,
  authentication: :plain,
  enable_starttls_auto: true
}

This is fairly typical and probably takes up too much space in a post that I’m trying to keep brief.

Next, we will want to add a helper method in app/helpers/subdomain_helper.rb:

module SubdomainHelper
  def self.with_subdomain(subdomain)
    subdomain = subdomain || ''
    subdomain += '.' unless subdomain.empty?
    host = ENV.fetch('HOST')
    [subdomain, host].join
  end

  # You can also add this if you want to use it in your views
  def with_subdomain(subdomain)
    SubdomainHelper.with_subdomain
  end
end

We will use this in our ApplicationController:

  before_action :set_mailer_host

  def set_mailer_host(subdomain = nil)
    subdomain = subdomain || request.subdomains.first
    ActionMailer::Base.default_url_options[:host] = SubdomainHelper.with_subdomain(subdomain)
  end

Now we don’t have to go around editing all of our Devise mailer views and figuring out fun ways to pass in the current tenants subdomain or whether or not we’re on staging. It will read the HOST env var that we set earlier and prepend the subdomain rather than replacing it on staging like url_for and polymorphic_url were like to do.

We can also pass a subdomain as an argument. This is useful if we’re inviting users from the public schema that belong in a tenant schema. Before we send the email, we can simply do something like set_mailer_host(@tenant.subdomain).

ActiveStorage

Speaking of polymorphic_url

With our setup, our tenants have logos in the public schema that we want to display while our application is loaded by apartment to a tenant schema. Since it’s not a great idea to add ActiveStorage::Attachment and ActiveStorage::Blob to the excluded models list in apartment, we will need to be a bit extra careful when referencing the files that live in the public schema.

In our config/environments/development.rb and config/environments/production.rb files add

config.action_controller.default_url_options = { host: ENV.fetch('HOST') }

Now you only have to do something like this in somewhere like app/views/shared/_logo.html.erb:

<% Apartment::Tenant.switch('public') do %>
  <%= image_tag(polymorphic_url(current_tenant.logo.variant(resize: '235x50').processed)) %>
<% end >

That’s It

I may add more to this as we run into new problems, but this seems to be a solid way to what we want. It may not be the most optimal solution, but I can worry about that when we hit 100 million requests per day (we won’t.)

Hopefully this was helpful!