Building a Rails App With Multiple Subdomains
Tags: ruby, rails, webdev, beginners
Published: 2020-03-25 13:24:38

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:

```ruby
  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.

```ruby
  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.

```ruby
  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.

```ruby
  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.

```ruby
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.

```ruby
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](https://guides.rubyonrails.org/routing.html).

### 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.

```ruby
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.

```ruby
# 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:

```ruby
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.

```ruby
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:

```ruby
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.

```ruby
# 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.

```sh
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](http://pow.cx/) 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](https://ngrok.com/). 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](https://blog.appsignal.com/#ruby-magic)!**

*Guest author [Prathamesh Sonpatki](https://prathamesh.tech) is a developer working in Ruby and Ruby on Rails. He also co-organizes [RubyConfIndia](https://rubyconfindia.org) and [DeccanRubyConf](https://deccanrubyconf.org)*