Whether you've got technical questions or just want to learn more, we're happy to dig into it with you. Get answers from a real, human engineer over email or on a call.
Tutorial
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:
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.
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.
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 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.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.
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.
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:
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.
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:
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:
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.
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.
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.
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.
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.
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!
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!