Lesson 5: Hosting Multiple Websites and Domains without Collision.

Post 5 in the 6 Things We Learned Revamping Ruby Central's Conference Websites series

This is the fifth post in a series on things we explored while “Revamping the Ruby Central Conference Websites”.

One of the fun challenges when developing a Rails website is to figure out how to host multiple tenants within the same codebase. Once you craft the proper way to divide, route and serve the data for multiple accounts the value of your codebase immediately increases multifold. CFP app is not like other multi-tenant codebases since it is designed to be self-hosted for just one organization. However, it does support managing multiple independent events creating similar challenges and benefits to traditional multi-tenant apps.

The hardest problem for us to solve was how to host both Rubyconf and Railsconf websites and have traffic from both rubyconf.org and railsconf.org be correctly routed to their respective websites. Also, there was an additional challenge of being able to serve the websites for conferences for the same event (i.e. sharing the same domain like rubyconf.org) from previous years.

The solution we developed started with adding a simple Website#domains field on the model. It is plural so that in theory it could even support multiple domains. CFP app for the ruby central conference events are hosted on Heroku so the next step was to follow their directions for adding a custom domain which also includes configuring the DNS to point to the heroku app domain.

The next step was to figure out how to route traffic so that it would render the correct event website content and also not get mixed with the routes for the main function of CFP app which is managing the proposal submission, review and acceptance proposals for the events. We came up with this:

Rails.application.routes.draw do
  constraints DomainConstraint.new do
    get '/', to: 'pages#show'
    get '/(:slug)/program', to: 'programs#show'
    get '/(:slug)/schedule', to: 'schedule#show'
    get '/(:slug)/sponsors', to: 'sponsors#show'
    get '/(:slug)/banner_ads', to: 'sponsors#banner_ads'
    get '/(:slug)/sponsors_footer', to: 'sponsors#sponsors_footer'
    get '/:domain_page_or_slug', to: 'pages#show'
    get '/:slug/:page', to: 'pages#show'
  end

  ...
  resources :events, param: :slug do
    get '/' => 'events#show', as: :event
    # more scoped routes...
  end
  ...
  get '/(:slug)', to: 'pages#show', as: :landing
  get '/(:slug)/program', to: 'programs#show', as: :program
  get '/(:slug)/schedule', to: 'schedule#show', as: :schedule
  get '/(:slug)/sponsors', to: 'sponsors#show', as: :sponsors
  get '/(:slug)/:page', to: 'pages#show', as: :page
end

class DomainConstraint
  def matches?(request)
    Website.domain_match(request.domain).exists?
  end
end

class Website
...
  def self.domain_match(domain)
    where(arel_table[:domains].matches("%#{(domain)}"))
  end
...
end

The proposal management side of Ruby Central events is accessed at https://cfp.rubycentral.org/events so the DomainConstraint immediately ensures that all traffic and only traffic coming in for the event website pages using their domains (e.g. rubyconf.org) have access to that set of routes.

Fortunately the main routes for accessing the core proposal management features of CFP app were already scoped to /events paths which allowed us to have another set of paths at the end of the routes for the website pages so that they could also be accessed using the main domain for CFP app without collision. For example, one can still access a dynamically named “Location” website page at https://cfp.rubycentral.org/location particularly when you manage the website content from the backend. The reason this works is because inside the ApplicationController we have the following important helper methods:


 def current_website
   @current_website ||= begin
     if current_event
       current_event.website
     elsif params[:slug]
       Website.joins(:event).find_by(events: { slug: params[:slug] })
     else
       older_domain_website || latest_domain_website
     end
   end
 end

 def older_domain_website
   @older_domain_website ||=
     domain_websites.find_by(events: { slug: params[:domain_page_or_slug] })
 end

 def latest_domain_website
   @latest_domain_website ||= domain_websites.first
 end

 def domain_websites
   Website.domain_match(request.domain).joins(:event).order(created_at: :desc)
 end

 def set_current_event(event_id)
   @current_event = Event.find_by(id: event_id)
   session[:current_event_id] = @current_event.try(:id)
   @current_event
 end

The current_event gets set when logging in to the backend so whatever event you are working with will determine which website content to serve. The rest of this code mostly helps with determining how to allow older conference websites that share the same domain (e.g. railsconf.org) to be accessed while still allowing the latest one to be served. The biggest challenge is dealing with a possible collision between something like the landing page for railsconf.org/railsconf-2022 and a page slug path for the latest Railsconf like railsconf.org/location. That is why there is a special domain_page_or_slug param based route that then gets resolved in the correct order with the code: older_domain_website || latest_domain_website . Finally in the PagesController we need the following:

class PagesController < ApplicationController
  before_action :require_website, only: :show
  before_action :require_page, only: :show
  before_action :set_cache_headers, only: :show

  def show
    @body = @page.published_body
    render layout: "themes/#{current_website.theme}"
  end

  private

  def require_page
    @page = current_website.pages.published.find_by(page_conditions)
    unless @page
      @body = "Page Not Found"
      render layout: "themes/#{current_website.theme}" and return
    end
  end

  def page_conditions
    landing_page_request? ? { landing: true } : { slug: page_param }
  end

  def page_param
    params[:domain_page_or_slug] || params[:page]
  end

  def landing_page_request?
    page_param.nil? || @older_domain_website
  end
end

In the past, the conference website organizers had the chore of taking static snapshots of the website pages and archiving them in a public folder for posterity viewing. With the above code we can have all the pages for the latest conference be accessed without the event slug while also having the older conferences still be reachable by simply adding the event slug to the url. Very nice.

In our next and final blog post in the series we will look at some of the templating and theming support we added to support a range of development options from one click development to advanced customization.

If you’re looking for a team to help you discover the right thing to build and help you build it, get in touch.

Published on November 15, 2022