Tutorial

Ruby on Rails Custom Domains Guide

A tutorial guide for adding custom domains as a feature to your Ruby on Rails app, with an example github repo.

This guide is meant to show you how to allow users of your Ruby on Rails app to connect their own domains and load up content specific to them. Custom domains - sometimes called vanity domains, whitelabel domains, or customer domains - are a key feature of many apps.

If you need to manage custom domains with SSL for SAAS, marketplaces, blog hosts, or other platforms, then this is the guide for you.

What you'll learn in this guide:

  • Domains. We'll briefly go over web domains, how they work, and how they can be used as custom domains with your Ruby on Rails app.
  • Networking. For your Ruby on Rails app to handle custom domain requests, they first need to reach your server(s).
  • Routing. Once the request reaches your Ruby on Rails app, you'll likely want to route it differently than other requests.
  • Controllers. Returning the right responses for custom domains. We recommend using separate controllers dedicated to custom domains in Ruby on Rails.
  • Views. You may want to have a different layout or other modifications for custom domains at the Ruby on Rails view layer.
  • Bonus! How to automate the networking and SSL management easily with Approximated.

Web domains and how they work

You're likely pretty familiar with web domains, but we'll go over a few of the specifics of how domains work that can sometimes cause confusion.

Registrars and ownership

Domains are bought and managed with registrars like Hover, GoDaddy, Namecheap, and many others. This is where a domain is officialy registered and may or may not be where it's DNS is managed. For our purposes, we can assume that it will be the user that owns their own domain, which they would like to point at your app.

DNS and pointing domains

Domains don't do much unless they're pointed at a target server. This is done with DNS, which tells devices where to send requests for a domain, using DNS records. These records are set at the domain's DNS provider, which may or may not be the same as the registrar.

Apex Domains vs Subdomains

Apex domains are the root, naked domain with no subdomain attached. For instance, example.com is an apex domain.

A subdomain is what we call it when we prepend something to an apex domain. For example, sub.example.com or sub2.sub1.example.com are both subdomains.

Technically, subdomains are domains. For the purpose of this guide though, when we use the word domain, we're referring to an apex domain.

SSL, TLS, and encrypting requests

When a request is sent out over the internet, there are various points where it's contents could be inspected and tampered with unless they're encrypted.

This is especially important for any private data such as ecommerce or personal information, and may be legally required in many cases. It's generally a very good idea for all requests to be encrypted, in order to prevent censorship, request injections, snooping and other tampering.

SSL certificates - which are technically TLS certificates now, but often still called by the old name - are the standard by which browsers and other applications encrypt these requests. Using them requires a server with a provisioned SSL certificate for each domain. We'll go more into detail on this in the networking section below.

Networking and SSL for custom domains on Ruby on Rails

In order for your app to serve custom domains, the requests for each custom domain need to be networked to reach your server(s). For those requests to be secured, you'll also need to provide and manage an SSL certificate for each custom domain.

Networking - getting the request to your server

Each custom domain will need to have DNS records set to point requests at your app - either directly or indirectly. There are two options for how you can go about this, each with significant trade-offs that you should know about:

  1. DNS A records pointed at an IPv4 address

    DNS A records can only be pointed at an IPv4 address, formatted as x.x.x.x, where each x can be any value between 0 and 255.

    It's important to note that apex domains - those with no subdomain (including www) - can only be pointed with A records. They cannot be pointed with CNAME records.

    This is the easiest and most universal method but also carries the risk that if you ever need to change that IPv4 address, you'll need to have each user change their DNS individually.

  2. CNAME records pointed through an intermediate domain

    CNAME records cannot point an apex domain and cannot point at an IPv4 address, but they can point subdomains at any other domain or subdomain.

    For example, you could point subdomain.example.com at test.com and requests would be sent to wherever test.com is pointing.

    If it's acceptable in your use case to require custom domains have a subdomain, then you can do the following:

    • Point the A record for a subdomain that you own at your server IPv4 address.
    • When a custom domain is created, have your user point a CNAME record at the subdomain above.
    • Requests for the custom domain will reach wherever the intermediate subdomain is pointed.
    • This has the advantage of allowing you to re-point all of the custom domains at once, by changing the A record for the intermediate subdomain.
    • The disadvantage here is that those custom domains cannot be apex domains and must have a subdomain.

SSL Certificates - securing many custom domains

You've likely secured your app already with SSL certificates, but unfortunately those will only apply to requests going directly to that domain. SSL certificates cannot be shared amongst apex domains.

You're going to need SSL certificates for each individual custom domain that points at your app, to encrypt requests as well as ensure security and privacy for that domain specfically.

In order to do that you'll need an automated system to provision SSL certificates automatically when custom domains are created, including:

  • Requesting the generation of an SSL certificate from a Certificate Authority.
  • Accepting and appropriately completing the verification challenge from the Certficate Authority.
  • Renewing within a reasonable window before the certificate expiration date (typically 3 months).
  • Avoiding going over rate limits with Certificate Authorities.
  • Checking for certificates that have been revoked early by Certificate Authorities and replacing them.
  • Monitoring all of it to catch and avoid the many edge cases and unexpected issues that can cause customer facing downtime.

For those that absolutely need to build this in-house, we recommend Caddy as your starting point. However, this is a very complex topic and could easily be many guides worth of content without even covering everything.

Building SSL management and networking systems in-house for more than a few domains is extremely non-trivial even with the help of amazing tools like Caddy. The potential pitfalls are many and the risk of customer facing downtime or security vulnerabilities is reasonably high without considerable experience.

To keep this tutorial from exploding in length, we won't be covering building this in-house here. If you'd prefer to have a third party service handle this for you, please see our bonus section below.

Routing custom domains within a Ruby on Rails app

Once requests for custom domains are able to reach your Ruby on Rails app, we'll probably want to handle them separately from other routes within the app.

For instance, a blog platform likely wants to display the blog that custom domain is tied to instead of your app home page. This section will explain how you can do that.

Configuring Rails to serve multiple domains in development

Rails has a setting for config.hosts to specify what hosts the server will respond to. This setting defaults to localhost in development, so if you want to test different domains locally, you need to specify them. In our example, we want to allow all domains, so we will just disable the host validation by setting it to nil.

# config/environments/development.rb

Rails.application.configure do
    # ...

    # Serve all domains.
    config.hosts = nil
end

For other Rails environments config.hosts is empty by default, which means Rails won't validate the host.

Grouping routes for custom domains in the Rails router

Most Rails apps are going to use the router configuration file located at config/routes.rb. When a request from a browser comes through to your Rails app, the router recognizes requested URLs and dispatches them to a corresponding controller's action.

Inside of the router configuration, we can group routes together by domain by using host matching and a feature called constraints.

# config/routes.rb

Rails.application.routes.draw do
    # Routes for primary domain
    constraints AppDomainConstraint do
        root 'pages#index', as: :pages_root
        resources :pages, except: %i[index]
    end

    # Routes for custom domains
    constraints !AppDomainConstraint do
        root 'public_pages#show', as: :public_pages_root
    end
end

The route constraint AppDomainConstraint differentiates between a primary domain and custom domains. The implementation can look like this:

# app/constraints/app_domain_constraint.rb

class AppDomainConstraint
    def self.matches?(request)
        # If you're using approximated.app the default is
        # to inject the header `apx-incoming-host` with the custom domain.
        requested_host = request.headers['apx-incoming-host'].presence

        # Otherwise just use the request host.
        requested_host ||= request.host

        # The env variable `APP_PRIMARY_DOMAIN` should contain
        # the primary domain of your app.
        requested_host.blank? || requested_host == ENV.fetch('APP_PRIMARY_DOMAIN')
    end
end

With this approach, you can cleanly separate out which routes are available to custom domains and which are available to your primary domain. It also means that you can use the same route paths, but do different things for custom domains vs your primary domain.

Depending on your app you may want to get more sophisticated than this, but this is a good place to start.

Handling custom domain requests with Ruby on Rails controllers

Once a request has gone through the router, it should arrive at whatever controller and function you've set in the router.

We recommend splitting out custom domain specific features into their own controllers to make things conceptually simple. It also helps you avoid "crossing wires" and accidentally handling a request for the primary domain with custom domain logic, or vice versa.

For the purposes of our example, we're going to have a PublicPagesController that displays a page associated with a custom domain. This assumes that we have a Page model with a domain field containing a custom domain.

# app/controllers/public_pages_controller.rb

class PublicPagesController < ApplicationController
    def show
        @page = Page.find_by!(domain: requested_host)
    end

    private

    def requested_host
        # If you're using approximated.app the default is
        # to inject the header `apx-incoming-host` with the custom domain.
        # Otherwise just use the request host.
        request.headers['apx-incoming-host'].presence || request.host
    end
end

This could be any controller and could use any logic you want, this is only an example. For instance, you could have a controller for rendering a view for a store front, or for handling API calls specific to custom domains.

Customising views in Ruby on Rails for custom domains

This isn't specific to custom domains, but it's a pretty common use case to need to use a different layout for custom domains instead of the one you use for the primary domain. We'll show you how to do that here.

First we need to specify the custom layout in our PublicPagesController.

# app/controllers/public_pages_controller.rb

class PublicPagesController < ApplicationController
    layout 'public'

    # ...
end

Then we need to create the public layout template file.

<!-- app/views/layouts/public.html.erb -->

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">

    <title><%= @page.title %></title>

    <%= csrf_meta_tags %>
    <%= csp_meta_tag %>

    <%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
    <%= javascript_importmap_tags %>
  </head>

  <body>
    <div>
        This should only load on <%= @page.domain %>
    </div>
    <div>
        <%= yield %>
    </div>
  </body>
</html>

This is everything we need to render templates completely customised for custom domains. You can get as sophisticated with this as you need and use all of the many Rails view features you'd normally have!

Bonus: Automating networking and SSL with Approximated

Remember how we said above that building a system for managing SSL certificates at scale is difficult, expensive, and error prone? Well, we know that from personal (painful) experience.

It's why we built Approximated - so that you don't have to. And at a price you can afford, starting at $20/month instead of thousands.

Approximated is your fully managed solution for automating custom domains and SSL certificates. We stand up a dedicated reverse proxy cluster, with edge nodes globally distributed around the world, for each and every customer.

You get a dedicated IPv4 address with each cluster so that you have the option of allowing your users to point their apex domains. Custom domain traffic goes through the cluster node nearest your user and straight to your app.

The best part? Your cluster will provision and fully manage SSL certificates automatically along the way. No need to worry about them ever again.

All you need to do is call our simple API from your app when you want to create, read, update, or delete a custom domain and it will handle the rest.

So what does that look like in our example so far?

We can simply attach some callbacks to our Page model. When someone creates, edits or destroys a page, we can call the Approximated API to manage virtual hosts for our custom domains.

# app/models/page.rb

class Page < ApplicationRecord
    # Virtual attribute that will hold instructions from Approximated
    # on where to point DNS records for the new domain.
    attribute :user_message, :text

    validates :title, presence: true
    validates :content, presence: true
    validates :domain, presence: true, uniqueness: true

    after_create :create_apx_vhost
    after_update :update_apx_vhost
    after_destroy :destroy_apx_vhost

    private

    def create_apx_vhost
        apx = Approximated.new
        rollback_on_apx_error do
            result = apx.create_vhost(domain, ENV.fetch('APP_PRIMARY_DOMAIN'))
            self.user_message = result.body.dig('data', 'user_message').presence
        end
    end

    def update_apx_vhost
        return unless domain_previously_changed?

        apx = Approximated.new
        rollback_on_apx_error do
            apx.get_vhost(domain_previously_was) # Check if the vhost exists.
            apx.update_vhost(domain_previously_was, 'incoming_address' => domain)
        rescue Approximated::ResourceNotFound
            apx.create_vhost(domain, ENV.fetch('APP_PRIMARY_DOMAIN'))
        end
    end

    def destroy_apx_vhost
        apx = Approximated.new
        rollback_on_apx_error do
            apx.delete_vhost(domain)
        rescue Approximated::ResourceNotFound
            # Ignore missing vhost
        end
    end

    def rollback_on_apx_error
      yield
    rescue Approximated::Error => e
      # Create a validation error message from the catched error.
      errors.add(:base, "APX Error: #{e.cause.to_s.upcase_first}")
      raise ActiveRecord::Rollback
    end
end

And this is what the Approximated service referenced above can look like:

# app/services/approximated.rb

# Approximated is a ruby wrapper for the Approximated API (https://approximated.app).
#
class Approximated
    # Data class holding a successful API response.
    Result = Data.define(:status, :body)

    # Approximated error base class.
    class Error < StandardError; end

    # Error raised on a 404 response.
    class ResourceNotFound < Error; end

    # Error raised on a 401 response.
    class UnauthorizedError < Error; end

    # Creates a virtual host.
    #
    # @param incoming_address [String] The incoming address.
    # @param target_address [String] The target address.
    # @param options [Hash] Optional fields. See https://approximated.app/docs/#create-virtual-host for more details.
    #
    # @return [Approximated::Result]
    #
    # @raise [Approximated::UnauthorizedError] If the API key does not exist.
    #
    def create_vhost(incoming_address, target_address, options = {})
        handle_exceptions do
            data = options.merge({ incoming_address:, target_address: })
            response = connection.post('/api/vhosts', data)
            handle_response(response)
        end
    end

    # Updates a virtual host.
    # Any fields not passed into options will remain the same.
    #
    # @param current_incoming_address [String] The current incoming address.
    # @param options [Hash] Optional fields. See https://approximated.app/docs/#update-virtual-host for more details.
    #
    # @return [Approximated::Result]
    #
    # @raise [Approximated::UnauthorizedError] If the API key does not exist.
    # @raise [Approximated::ResourceNotFound] If the virtual host could not be found.
    #
    def update_vhost(current_incoming_address, options = {})
        handle_exceptions do
            data = options.merge({ current_incoming_address: })
            response = connection.post('/api/vhosts/update/by/incoming', data)
            handle_response(response)
        end
    end

    # Reads a virtual host.
    #
    # @param incoming_address [String] The incoming address.
    #
    # @return [Approximated::Result]
    #
    # @raise [Approximated::UnauthorizedError] If the API key does not exist.
    # @raise [Approximated::ResourceNotFound] If the virtual host could not be found.
    #
    def get_vhost(incoming_address)
        handle_exceptions do
            response = connection.get("/api/vhosts/by/incoming/#{incoming_address}")
            handle_response(response)
        end
    end

    # Deletes a virtual host.
    #
    # @param incoming_address [String] The incoming address.
    #
    # @return [Approximated::Result]
    #
    # @raise [Approximated::UnauthorizedError] If the API key does not exist.
    # @raise [Approximated::ResourceNotFound] If the virtual host could not be found.
    #
    def delete_vhost(incoming_address)
        handle_exceptions do
            response = connection.delete("/api/vhosts/by/incoming/#{incoming_address}")
            handle_response(response)
        end
    end

    private

    def connection
        @connection ||= Faraday.new(
            url: 'https://cloud.approximated.app/',
            headers: { 'api-key' => ENV.fetch('APPROXIMATED_API_KEY') }
        ) do |faraday|
            faraday.request :json
            faraday.response :json, preserve_raw: true
            faraday.response :raise_error
        end
    end

    def handle_response(response)
        Result.new(status: response.status, body: response.body)
    end

    def handle_exceptions
        yield
    rescue Faraday::UnauthorizedError
        raise UnauthorizedError
    rescue Faraday::ResourceNotFound
        raise ResourceNotFound
    rescue Faraday::Error
        raise Error
    end
end

That's all there is to it - you'll have fully automated custom domains from now on!