In today's post, we'll learn how to build a Rails app that can support multiple subdomains. Let's assume that we have a gaming website funkygames.co
and we want to support multiple subdomains such as app.funkygames.co
, api.funkygames.co
, and dev.funkygames.co
with a single Rails application. We want to ensure that proper authentication is performed for all subdomains and that there are no duplicate routes.
We'll use Rails' powerful routing constructs to support multiple subdomains in our application. We'll also set up subdomains locally and write tests for multiple subdomains.
Prerequisites
For the purpose of this post, I'm assuming that you have set up appropriate DNS records for all the subdomains to point to the Rails app. We'll only deal with the Rails side of things in this post.
Handling Multiple Subdomains
Rails uses routes.rb
file to handle incoming requests and map them to
specific controller actions. In a trivial app, every mapping in routes.rb
maps a route to a controller action as follows:
get '/games/:id', to: 'games#show'
With this approach, all the endpoints defined in our routes.rb
file
are applicable to all the subdomains. So app.funkygames.co/games/1
as well as api.funkygames.co/games/1
will be handled by this route. However, we only want the request coming from app
subdomain to be handled by this route. The api
subdomain is only to be used for API routes. We'll add some rules to the routes so that they are handled only if a specific rule is met for the incoming request.
Rails routing provides a constraints
helper method which can specify additional rules for the given route.
get '/games/:id', to: 'games#show', constraints: { subdomain: 'app' }
This will ensure that if the request is coming from app.funkygames.co/games/1
, it will be handled by GamesController's
show action. Any request from other subdomains apart from app
will not be handled by this route.
It will become very cumbersome to define constraints
like this for each and every route.
get '/games/:id', to: 'games#show', constraints: { subdomain: 'app' }
get '/games/list', to: 'games#list', constraints: { subdomain: 'app' }
post '/games/start', to: 'games#start', constraints: { subdomain: 'app' }
We can use the block form of the constraints
helper to define multiple
routes for a single subdomain.
constraints subdomain: 'app' do
get '/games/:id', to: 'games#show'
get '/games/list', to: 'games#list'
post '/games/start', to: 'games#start'
end
To define routes for multiple subdomains, we just have to add multiple constraints
blocks in our routes.rb
file.
constraints subdomain: 'app' do
...
end
constraints subdomain: 'api' do
...
end
constraints subdomain: 'dev' do
...
end
Under the Hood
Rails routing provides request constraints and segment constraints. Segment constraints add rules on the request path whereas request constraints add conditions on the incoming request. The hash key in a request constraint needs to be a method on the Request
object that returns a string and the value needs to be the expected value.
constraints subdomain: 'app' do
...
end
In the above case, we are using the subdomain
method on the Request
object and matching it with a string like app
, api
or dev
.
For more details, consult the Rails routing guide.
Handling Multi-level Subdomains
Let's say we are using app.staging.funkygames.co
for our staging environment. If we have the setup above, we will quickly notice that all the requests that are supposed to hit the app
subdomain are returning a 404. If we debug things further, we will notice that our constraint for the subdomain is failing.
request.subdomain #=> app.staging
We expected the subdomain to return app
, but instead, it returns app.staging
. Of course, we want to solve this without adding environment-specific code! The parsing of request's subdomain is managed by config.action_dispatch.tld_length
option. The default value of this configuration is 1, which basically supports one level of subdomains. As we have two level subdomains, we need to set the value for config.action_dispatch.tld_length
to 2.
# config/application.rb
config.action_dispatch.tld_length = Integer(ENV['TLD_LENGTH'] || 1)
We can set it using an environment variable so that we can use the same code in the staging as well as in the production environment. Now, our routing setup will work for app.staging.funkygames.co
as well.
Session Management
Now that routes are defined to handle requests coming from multiple subdomains,
we need to take care of authentication for all the subdomains. We can do this in two waysβwe can either allow the same user session to be used across all subdomains, or we can have separate sessions for separate subdomains.
Authentication in a Nutshell
Rails uses cookies to store user session key by default. Once the user logs in, the user's session information is stored in the session store of our choice and the session key is stored as a cookie in the browser. So the next time the user visits our website, the same session cookie is sent from the browser to the server and the server decides whether the user is logged in or not based on whether the session exists for the incoming session cookie.
The default configuration for the session looks like this in the Rails app:
Rails.application.config.session_store :cookie_store, key: "_funkygames_session"
The key _funkygames_session
will be used as the name of the session cookie and its value will be the session id.
Cookies Primer
By default, cookies are set by the browser on the request's domain. So if we are hitting our application from app.funkygames.co
then the session cookie will be set against app.funkygames.co
. Each subdomain will set its own session cookies, therefore the user session will not be shared across subdomains by default.
Sharing Session between Different Subdomains
If we want to share the user session across subdomains, we'll need to set the session cookie on the funkygames.co
domain itself so that all subdomains can access it. This can be achieved by passing the domain
option to the session store settings.
Rails.application.config.session_store :cookie_store, key: "_funkygames_session", domain: :all
By passing domain
as :all
, we are basically telling Rails to set the session cookie on the top-level domain of the application such as funkygames.co
instead of on the request host which may include the individual subdomains. Once we do this, the session can be shared between different subdomains.
We can also pass a list of domains to the
domains
option in an array format to support multiple domains.
There is one more option that needs to be configured to properly set the cookies for all subdomains. It is the tld_length
option. When using domain: :all
, this option can specify how to parse the domain to interpret the TLD of the domain. In our case, for app.funkygames.co
, we should set tld_length
to 2 for Rails to interpret the TLD as funkygames.co
when setting up the cookies. So the final session store configuration for multiple subdomains looks like this:
Rails.application.config.session_store :cookie_store,
key: "_funkygames_session",
domain: :all,
tld_length: 2
The
tld_length
option from the session store is different from theconfig.action_dispatch.tld_length
discussed earlier.
Writing Tests for Multiple Subdomains
As the routes are subdomain specific, the request specs or integration tests result in 404 errors if the test request does not have a proper subdomain. Rails integration tests provide a host!
helper which can set the proper subdomain for all requests made within a test file.
# Configuring subdomain in Rails integration tests
setup do
host! 'dev.example.com'
end
# # Configuring subdomain in RSpec request specs
before do
host! 'dev.example.com'
end
After this, the requests will be correctly routed to the controller actions as per subdomain routing in routes.rb
file.
Note that the domain does not matter here, only the proper subdomain based in the code we are testing matters.
Setting up Multiple Subdomains Locally for Development
There are multiple ways to set up subdomains locally. The simplest is editing the /etc/hosts
file.
127.0.0.1 dev.funkygames.local
127.0.0.1 app.funkygames.local
127.0.0.1 api.funkygames.local
This ensures that the subdomains setup will work in a local environment. We can also use tools such as pow for managing subdomains locally.
Gotchas with Constraints Based Subdomain Routing
Though the constraints based subdomain routing works in most cases, it can be a pain in certain situations.
Dealing with External APIs
When we are working with third-party APIs and building integrations, the local development TLDs such as .local
or .dev
are not allowed. We have to use tools such as ngrok. The subdomain based routing does not work in such cases and we have to whitelist certain routes so that they are accessible via ngrok as well.
Routes Outside of Subdomains Constraints
Certain routes can't be placed inside the subdomain constraints. A typical example is healthcheck
or ping
endpoints. If we are using a load balancer in front of our Rails app, the load balancer needs to periodically check if the app is up or not. The healthcheck
endpoint used in such cases can't be under subdomain constraints as the load balancer most probably won't have knowledge of the request host.
Absence of root Route
Rails has a special root
route which is basically the default route of the application. If none of the other routes are matched with the request, then the root
route is used. When we have all of our routes under any one of the subdomains, then there can be situations where we don't have any root
route defined at all.
Certain gems might depend on the presence of a root
route and we need to add checks and balances accordingly.
Conclusion
In this post, we set up a Rails app with multiple subdomains with very few lines of configuration. We also saw how to set up the subdomains locally as well as with different environments, with tips on writing effective tests for multiple subdomains. With the plumbing provided by Rails, it becomes easy to set up and test a Rails app with multiple subdomains.
P.S. If you'd like to read Ruby Magic posts as soon as they get off the press, subscribe to our Ruby Magic newsletter and never miss a single post!
Guest author Prathamesh Sonpatki is a developer working in Ruby and Ruby on Rails. He also co-organizes RubyConfIndia and DeccanRubyConf
Top comments (1)
Wow, this is such a new approach to use subdomain constraint. I've always used it like this when building APIs:
and always forced myself to go this way when having multiple subdomains. Good to know there're more ways to do this!
Thanks for sharing :D