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”.
- Lesson 1: Knowing When to Build vs Dig
- Lesson 2: Averting Monkey Patches with a Custom CMS
- Lesson 3: Braving Mad Science with Tailwind
- Lesson 4: Planning HTTP/Proxy Caching for the Spottiest Internet
- Lesson 5: Hosting Multiple Websites and Domains without Collision ⬅︎
- Lesson 6: Theming and Templating Against Future Tech Debt
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