Multiple subdomains with Rails... and Australian domains

Ruby on Rails has come a long way. It’s always been a powerful framework but, thanks to the likes of GitHub and Shopify, it now includes advanced features like out-of-the-box WebSockets, parallel testing, and multiple databases with sharding. Given the recent push into enterprise functionality, I’ve been surprised by how poor my experience has been using multiple subdomains. Hopefully I can save you some pain.

One of the many services provided by Education Advantage is repairs on Apple devices. Back in 2014 I made my first Rails app; a place for customers to log and track repairs. Since then, our focus has been mostly on internal automation rather than customer-facing applications. This year, however, we decided to start expanding our external presence. The first new project is essentially a help desk crafted specifically for our market with integrations around the business.

It would have been quite easy to add some new controllers to the application and expand the existing menu, however, I see repairing devices and a help desk as being fundamentally different concepts with wildly different interfaces and this is only the first of many new ideas. I’ve seen a lot of platforms that try to package the kitchen sink into one app and I can’t recall ever feeling like it did anything other than create unwieldy navigation and a poor user experience. (Yeah, I see you AWS console)

I made the decision that keeping the current system working as it is on the “service” subdomain and creating a new “support” subdomain would keep things clean and focused. I also intend to later add a “dashboard” that ties everything together.

The Rails routing guides makes this sound easy enough; just wrap your routes in a subdomain constraint and let the magic happen.

Based on that documentation, I started with something that kind of looked like this… but bigger:

Rails.application.routes.draw do
  devise_for :users

  constraints subdomain: "service" do
    root to: "jobs#index"
  end

  constraints subdomain: "support" do
    root to: "tickets#index"
  end
end

I want users to be able to update their profile and their password regardless of the subdomain they are on, but I only want Jobs to be on the “service” subdomain and Tickets only on “support”.

Duplicate routes not allowed#

It turns out you can’t have multiple routes defined as “root”. While this can be confusing if you think about it from the perspective of paths (the root URL will be / regardless of subdomain), there would be no way for Rails to know which subdomain you wanted if you asked for the root_url.

This can be solved by giving custom names to each of the routes:

constraints subdomain: "service" do
  root to: "jobs#index", as: :service_root

  resources :jobs
end

constraints subdomain: "support" do
  root to: "support/tickets#index", as: :support_root
end

If you use root_path anywhere in your application, just be sure to replace that with the new helper method.

Localhost isn’t gonna fly#

This is the issue that probably frustrated me the most because it seems like something that should be clearly documented but I uncovered it via Stack Overflow. You can’t use localhost when working with subdomains, you’ll need to use a “real” domain.

There are services that offer domains that simply point back to your device but I chose not to rely upon external providers and instead modified my host records.

Operating Systems based on Unix (including macOS) generally include a file, /etc/hosts, that facilitates a kind of personal DNS. When you make a web request, the OS checks if it has a record for that domain/IP before passing the lookup up the chain. This means you can override the IP of any domain by adding to this file. The file requires super user privileges to edit so I modify it with Vim (sudo vim /etc/hosts):

##
# Host Database
#
# localhost is used to configure the loopback interface
# when the system is booting.  Do not change this entry.
##
127.0.0.1       localhost
255.255.255.255 broadcasthost
::1             localhost

127.0.0.1 service.easi.dev.au
127.0.0.1 support.easi.dev.au

Yours might look a bit different but all that matters is those last lines are in there somewhere.

127.0.0.1 is an IP address that always refers to your local device so it’s telling the operating system that request to service.easi.dev.au and support.easi.dev.au should be handled by my laptop.

Tell Rails the host is okay#

Rails will reject any domain that it isn’t expecting so you need to modify your development environment to include the host.

config/environments/development.rb

Rails.application.configure do
  config.hosts << ".easi.dev.au"

If your Rails server is running, you’ll need to reboot it for that configuration to be read.

Cross-subdomain sessions#

By default, Rails assumes there is only 1 part to the TLD. This is great if you happen to be American and using a .com but is highly problematic if, say, you live in Australia and use .com.au.

The way Rails handles the number of parts in a TLD is both poorly documented and highly confusing.

Firstly, for Rails to be able to parse the URL and handle routing appropriately, you need to tell it the “TLD length”. In the case of Australian .com.au domains, the com is 1 and the au is 1 so the length is 2.

In config/application.rb, add:

config.action_dispatch.tld_length = 2

I’ve added this to application.rb because I find it easier to use the same TLD length in development, test, and production. If you have different TLD lengths—for instance, .fake in development and .com.au in production—then you will want to add this setting and the appropriate lengths to your environment configs (/config/environments/[development,test,production].rb).

Now your site should be loading across your subdomains, however, the session will only be valid for the subdomain you logged in on. As soon as you click a link that takes you to the other subdomain, you will be navigating as an unauthenticated user.

If you inspect the cookies for the site in your browser developer tools, you’ll see the cookie domain is set to the full domain of the site you logged in on (in my case, service.easi.dev.au) meaning it is not valid on any other domain or subdomain.

Create a new initializer in /config/initializers/session_store.rb:

Rails.application.config.session_store(
  :cookie_store,
  key: "_easi_session",
  domain: :all,
  tld_length: 3
)

Now, you may be wondering why the tld_length length is 3 here when we only just discussed that it should be 2. Yeah, well, so am I and I’d rather not reopen the box I’ve used to encase all the rage I felt while debugging this. I’ve moved on and I simply add 1 to whatever the number should be 🤷‍♂️

Be sure to reboot your Rails server.

Aside for those with a TLD length of 1#

Like many devs, I’ve been using the .dev TLD for my local development for many years. Unfortunately, Google bought this TLD and now enforces that all sites have a secure connection. Since I don’t want to set up SSL/TLS certificates in development, I’ve stopped using it.

If you are developing a site for a .com or similar TLD and don’t what to use something like .dev.au, keep in mind you can pretty much use whatever you want. For example, website.fake or example.pickles_make_me_chuckle.

Testing#

If you are like me, you might get to this point and celebrate your accomplishments. Before you can commit you need to run your tests to make sure everything still works. Of course, you celebrated too soon because yours tests are now red all over.

This seems simple enough, you just need to tell the tests which subdomain to use… right

I test with Rspec so the details will be different for Minitest or any another framework. For your request specs and non-JS feature specs, it is possible to simply change all of your requests from something_path to something_url and let Rails add the appropriate subdomain. The default domain used for non-JS tests is www.example.com so those become subdomain.www.example.com. While that looks weird, it works.

If you have a simple application, that might be all you need to do. If you have JavaScript tests, however, there is a whole new challenge to deal with.

When you run a “normal” request or feature spec, the test request is passed through the normal routing layer of Rails, however, it isn’t sent via a web server. For a feature spec that requires JS, however, a web server is started and the requests are sent via the server as if you were manually testing in the browser.

By default, the web server will be listening to requests on 127.0.0.1. As we learnt earlier, subdomains aren’t supported on localhost so nothing is loaded. Feature tests are facilitated by Capybara so you need to tell Capybara to use a different domain. The catch is that, since you are communicating with a real web server, you need to send the request to the port number that the server is listening on. When you make requests to paths (rather than URLs), Capybara handles this for you. When sending the full URL, however, you are telling Capybara to connect via a particular port (80 if none is specified).

The following is how I’ve gotten around this. I found documentation on the issue to be terribly lacking and so I can’t say if it is the best solution, but I can tell you it is a solution.

Ultimately, what I wanted was a way to leave the bulk of my test code exactly as it is but somehow tell Capybara which subdomain to run on regardless of whether the test goes via a web server or directly to rack.

The first step is to find your Capybara configuration which is likely to be in spec/rails_helper.rb and amend it to something like:

::Capybara.configure do |config|
  config.always_include_port = true
  config.default_host = "http://easi.dev.au"
  config.server = :puma
  config.server_port = rand(50_000..59_999)
end

always_include_port = true allows us to continue sending paths and leave it to Capybara to work out which port to send the request on. config.default_host will handle any requests not on a subdomain. You may not allow requests without a subdomain but it’s a good idea to set this. config.server_port technically shouldn’t be needed but I haven’t yet found a way to set the asset_host without manually setting the port number. When you set the app_host, the always_include_port config directs Capybara to automatically set the port number on the request but I’ve found it does not set the port number on the asset_host. Without it, your tests will still pass but debugging in the browser becomes difficult because your CSS won’t load.

Now that Capybaya is primed, we need a way to tell it which subdomain to use. I’ve settled on adding metadata to the spec file like:

RSpec.describe "Doing a thing", subdomain: :service do

If you have reason to have a spec file that tests multiple subdomains, that metadata can be placed on a lower-level block like:

RSpec.describe "Doing a thing" do
  describe "when on the service domain", subdomain: :service do
    it "does things"
  end

  describe "when on the support domain", subdomain: :support do
    it "does stuff"
  end
end

The Rspec configuration includes hooks to jump in before and after specs run and this is where you can listen for the metadata and change the subdomain. There are many ways to configure Rspec but I chose to keep this self-contained. My project includes a spec/support directory where I created a new class which I call at the end of my spec/rails_helper.rb with Metadata::Subdomain.call.

spec/support/metadata/subdomain.rb

# frozen_string_literal: true

module Metadata
  class Subdomain
    class << self
      def call
        RSpec.configure do |config|
          before(config)
          after(config)
        end
      end

      private

      def before(config)
        config.before(:each, when_tagged_with_subdomain) do |example|
          Metadata::Subdomain.new(example).before
        end
      end

      def after(config)
        config.after(:each, when_tagged_with_subdomain) do |example|
          Metadata::Subdomain.new(example).after
        end
      end

      def when_tagged_with_subdomain
        { subdomain: ->(subdomain) { subdomain.present? } }
      end
    end

    attr_reader :example

    def initialize(example)
      @example = example
    end

    def before
      Capybara.app_host = app_host
      Capybara.asset_host = asset_host
    end

    def after
      Capybara.app_host = Capybara.default_host
      Capybara.asset_host = "#{Capybara.default_host}:#{Capybara.server_port}"
    end

    private

    def app_host
      uri = URI(Capybara.default_host)
      "#{uri.scheme}://#{subdomain}.#{uri.host}"
    end

    def subdomain
      example.metadata.fetch(:subdomain)
    end

    def asset_host
      uri = URI(Capybara.default_host)
      "#{uri.scheme}://#{subdomain}.#{uri.host}:#{Capybara.server_port}"
    end
  end
end

It’s a lot, I know, but this complexity will rarely be seen and I found it made the tests a lot easier to read and write.

The summary of what is going on there is that it registers callbacks to be run before and after specs. The callbacks are only called if subdomain is in the metadata and a value for the subdomain was passed.

Before the spec is run, Capybara is given a new app_host that includes the subdomain. Once the test is complete, the app_host is set back to the default.

Hopefully, after all that, your tests should pass and you should be ready to commit.