ruby

Building a Rails App With Multiple Subdomains

Prathamesh Sonpatki on

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.

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 the config.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!