How to Add Custom Domains to a Ruby on Rails App


To add custom domains to a Ruby on Rails app, your customer points a CNAME (or A records for a root domain) at your servers, you store the domain against their account, and a controller looks up the tenant from the request's hostname. Then you need an HTTPS certificate for every one of those domains, which is where most of the work is.
The Rails part is small: a model, a route constraint and a before_action. This guide walks through it step by step for Rails 7.1 and 8, and covers the things that trip up Rails apps in production: "Blocked host" errors, forms that fail the CSRF check, and redirects that send visitors back to your own domain. There's an example GitHub repo you can use to kickstart your own implementation.
In this guide:
- How custom domains reach your Rails app
- Store your customers' domains
- Route custom domain requests to the right tenant
- Fix "Blocked host" errors
- Add HTTPS for every custom domain: Caddy, Kamal or a managed proxy
- Set it up with SaaS Custom Domains (optional)
- Test custom domains locally
How custom domains reach your Rails app
The first thing you may ask yourself is how requests from custom domains even end up hitting your Rails app. The answer is DNS: your customer adds a record that points their domain at your servers. Which record depends on whether it's a subdomain or a root domain.
Let's use an example. Your Rails app is a status page product running on app.statuspage.com, on a server with the IP 192.0.2.1. Intercom is a customer, and wants its status page on its own domain.
Subdomains: a CNAME record
A CNAME maps one domain name to another, like an alias. Intercom creates a CNAME at status.intercom.com pointing to app.statuspage.com. When someone visits status.intercom.com, DNS follows the alias, finds your server's IP, and the request lands on your Rails app.
This is the option you want customers to use. If your servers move, you update app.statuspage.com once and every customer's domain follows.
Root domains: A records
Now imagine Intercom wants the status page on a root domain, intercomstatus.com. DNS doesn't allow a CNAME at the root of a domain, so Intercom has to create an A record that points intercomstatus.com straight at your IP, 192.0.2.1.
Some DNS providers offer ALIAS records or CNAME flattening, which act like a CNAME at the root. Plenty don't, so you need the A record option either way.
IMPORTANT: If you hand out an IP address, it must never change, ever! If it does, every A record your customers created now points at an IP that isn't yours. You'll have to reach out to all of them and ask them to update their DNS. Get ready for a sleepless night. Use a static IP (an Elastic IP on AWS, a reserved IP elsewhere), or better, a pair of anycast IPs in front of a load balancer.
Here are the records your customers add, in zone-file form:
; Subdomain
status.intercom.com. CNAME app.statuspage.com.
; Root domain
intercomstatus.com. A 192.0.2.1In practice, use a dedicated CNAME target such as in.statuspage.com instead of your app's own hostname. Then you can move custom domain traffic onto separate infrastructure later without touching your main app.
Store your customers' domains
Your app needs to know which domain belongs to which customer. A CustomDomain model keeps that mapping, with a status so you only serve domains that are actually pointed at you. In this example, a Company is the tenant:
class CreateCustomDomains < ActiveRecord::Migration[8.0]
def change
create_table :custom_domains do |t|
t.references :company, null: false, foreign_key: true
t.string :host, null: false
t.string :status, null: false, default: "pending"
t.timestamps
end
add_index :custom_domains, :host, unique: true
end
endThe unique index matters. Without it, two companies can claim the same domain, and whichever row the database returns first gets the other one's traffic.
class CustomDomain < ApplicationRecord
belongs_to :company
enum :status, { pending: "pending", active: "active" }
normalizes :host, with: ->(host) { host.strip.downcase.chomp(".") }
validates :host, presence: true, uniqueness: true,
format: { with: /\A[a-z0-9-]+(\.[a-z0-9-]+)+\z/ }
validate :not_one_of_our_domains
def self.host_from(request)
request.host.downcase
end
private
def not_one_of_our_domains
ours = ENV.fetch("APP_DOMAIN") # "statuspage.com"
if host == ours || host.to_s.end_with?(".#{ours}")
errors.add(:host, "can't be one of our own domains")
end
end
endA few notes:
- Normalize before you save.
Status.Intercom.com.andstatus.intercom.comare the same domain.normalizes(Rails 7.1+) lowercases it and strips the trailing dot, so lookups and the unique index both work. If you accept international domain names, convert them to punycode first; thesimpleidngem does this. - Block your own domains. Otherwise a customer can claim
app.statuspage.comoradmin.statuspage.com. host_fromis the one place that reads the hostname. The route constraint and controller below both call it. If you put a proxy in front of your app later, this is the only method you change.
Check that the DNS points at you
A domain should stay pending until its DNS actually points at you. Ruby's standard library can check that with Resolv:
require "resolv"
class CustomDomain < ApplicationRecord
CNAME_TARGET = "in.statuspage.com"
OUR_IPS = %w[192.0.2.1].freeze
def dns_points_to_us?
Resolv::DNS.open do |dns|
cnames = dns.getresources(host, Resolv::DNS::Resource::IN::CNAME)
ips = dns.getaddresses(host).map(&:to_s)
cnames.any? { |r| r.name.to_s.casecmp?(CNAME_TARGET) } ||
ips.intersect?(OUR_IPS)
end
end
endRun it from a background job every few minutes after a domain is added, and call active! once it passes. Re-check active domains every day too. Customers change DNS and forget to tell you.
Route custom domain requests to the right tenant
Every request to your app includes a Host header, which tells you which domain the visitor typed. When someone opens status.intercom.com, request.host is "status.intercom.com". All you have to do is look it up.
Start with a route constraint, so requests on custom domains go to the customer-facing pages and requests on your own domain go to your app as usual:
class CustomDomainConstraint
def self.matches?(request)
CustomDomain.host_from(request) != ENV.fetch("APP_HOST")
end
endRails.application.routes.draw do
constraints(CustomDomainConstraint) do
root "status_pages#show", as: :custom_domain_root
end
root "home#index"
# ...the rest of your app
endThe constraint doesn't touch the database, so it's cheap to run on every request. Rails autoloads everything under app/, so app/constraints works without extra configuration.
Then find the tenant. A controller concern keeps it in one place, and Current makes the company available everywhere in the request:
class Current < ActiveSupport::CurrentAttributes
attribute :company
endmodule CustomDomainTenant
extend ActiveSupport::Concern
included do
before_action :set_company_from_domain
end
private
def set_company_from_domain
host = CustomDomain.host_from(request)
domain = CustomDomain.active.find_by!(host: host)
Current.company = domain.company
end
endclass StatusPagesController < ApplicationController
include CustomDomainTenant
def show
@status_data = Current.company.fetch_status_data
end
endfind_by! raises ActiveRecord::RecordNotFound for a domain you don't know, or one that isn't active yet, and Rails turns that into a 404 in production. That's what you want: the page shouldn't render for someone who pointed a random domain at your IP.
If you scope every model by tenant with the acts_as_tenant gem, call set_current_tenant(domain.company) in the same before_action.
Here's how the request travels through the system so far:

Fix "Blocked host" errors
The first time you open a custom domain in development, you'll probably see this:
Blocked hosts: status.intercom.com
To allow requests to these hosts, make sure they are valid
hostnames (containing only numbers, letters, dashes and dots),
then add the following to your environment configuration:
config.hosts << "status.intercom.com"That's Rails' ActionDispatch::HostAuthorization middleware, which protects against DNS rebinding attacks. It checks both the Host and X-Forwarded-Host headers against config.hosts.
- In development, Rails allows
localhost, any*.localhostand*.testname, and IP addresses. Anything else is blocked. - In production,
config.hostsis empty by default, so nothing is checked. But new Rails apps ship with a commented-outconfig.hostsexample inconfig/environments/production.rb, and if you've enabled it, every custom domain gets blocked.
Don't add each domain to the list by hand. config.hosts also accepts a lambda, so let the database decide:
config.hosts = [
"app.statuspage.com",
->(host) {
CustomDomain.exists?(host: host.split(":").first.downcase)
}
]
# Let health checks and your proxy's ask endpoint through.
config.host_authorization = {
exclude: ->(request) { request.path.in?(["/up", "/caddy/ask"]) }
}The lambda runs on every request to a host that isn't in the list, so keep the unique index on host. Add a short Rails.cache around it if the query shows up in your metrics.
Add HTTPS for every custom domain
If your customers just point their domains at your app and try to visit them, they're in for a nasty surprise. Your app doesn't support HTTPS for these domains yet, so the browser shows a terrifying warning and blocks the page.
Where are the good ol' days when everything ran on HTTP and people simply trusted each other? Oh well, let's get on with it.
Every custom domain needs its own TLS certificate. Let's Encrypt issues them for free, but you need something that requests a certificate when a new domain shows up, renews it before it expires, and terminates TLS in front of Rails. That's a TLS-terminating reverse proxy. You have three realistic options.
Option 1: Caddy with on-demand TLS
Caddy can get a certificate during the first TLS handshake for a new hostname. This is called on-demand TLS. Without a check, anyone could point a domain at your IP and make you request certificates for it, which burns through your Let's Encrypt rate limits. So Caddy asks your Rails app first:
{
on_demand_tls {
ask http://localhost:3000/caddy/ask
}
}
https:// {
tls {
on_demand
}
reverse_proxy localhost:3000
}The ask endpoint returns 200 for active domains and 404 for everything else:
# config/routes.rb: get "/caddy/ask", to: "caddy#ask"
class CaddyController < ActionController::API
def ask
host = (params[:domain] || params[:host]).to_s.downcase
if CustomDomain.active.exists?(host: host)
head :ok
else
head :not_found
end
end
endCaddy sends the hostname as domain; Kamal (below) sends it as host, so this endpoint accepts both. Caddy passes the original Host through to Rails, so request.host is the customer's domain and the routing above works as is.
That gets you working HTTPS on one server. For production, you also need shared certificate storage across servers, a second certificate authority for when Let's Encrypt rate limits or fails, monitoring for failed renewals, and a plan for abuse. We cover all of that in our guide to custom domains for SaaS.
What about Kamal?
Kamal is the default way to deploy Rails 8 apps, and its proxy handles Let's Encrypt for you. For custom domains, there's a catch.
kamal-proxy added on-demand TLS in v0.10.0 (September 2026). You give it a --tls-on-demand-url instead of a list of hosts, and it calls that URL with ?host=status.intercom.com before issuing a certificate:
kamal-proxy deploy web --target web-1:3000 \
--tls --tls-on-demand-url="/caddy/ask"If the URL is a path, kamal-proxy sends the check through to your Rails app, so the ask endpoint above works unchanged. But as of October 2026, Kamal's config/deploy.yml doesn't have an option for it yet (the pull request is still open). Kamal's automatic SSL also only works when you deploy to a single server. For now, the practical route on Kamal is to run Caddy or a managed proxy in front.
Option 2: a managed proxy
Running a TLS-terminating proxy fleet means owning certificate storage, renewals, rate limits, monitoring and on-call. If you'd rather not, a managed proxy does it for you. That's what we built: the rest of this guide shows how to set it up with SaaS Custom Domains, for $29/month for your first 100 domains.
Set it up with SaaS Custom Domains
SaaS Custom Domains sits in front of your Rails app. Your customers point their domains at us, we issue and renew their certificates, and we proxy every request to your app with the original hostname in a header. That covers:
- We issue certificates for new domains, renew them before they expire, and store them securely, using both Let's Encrypt and ZeroSSL.
- Our proxies run on AWS in 119 locations, close to your users. If a region has problems, traffic goes to the nearest healthy one.
- Root domains point at two static anycast IPs that won't change.
- An API adds or removes a domain in one call, and webhooks tell you when DNS changes or a certificate is issued.
- We can email your customers step-by-step instructions for the exact records to add, and check the records for you.
Pricing is $0.29 per domain per month with a 100-domain minimum ($29/month), with volume discounts from 1,000 domains. There's a 7-day free trial; you'll need a card to start it, and you can cancel any time.
Here are the steps:
- Create an API token (1 minute)
- Create an upstream for your Rails app (1 minute)
- Add custom domains from your Rails app (5 minutes)
- Point the customer's DNS at us (1 minute)
- Read the custom domain in Rails and verify the proxy's token (5–15 minutes)
1. Create an API token
Go to API tokens in your account settings and generate a new token.

You'll also need your account UUID, which is in your account settings:

Store both as environment variables, SCD_API_TOKEN and SCD_ACCOUNT_UUID. Treat the token like a password: it gives full access to your SaaS Custom Domains account.
2. Create an upstream
An upstream is the server we forward requests to: your Rails app. Here's a small API client with nothing but the standard library:
require "net/http"
class SaasDomains
class Error < StandardError; end
BASE = "https://app.saascustomdomains.com/api/v1/accounts"
def initialize
@account = ENV.fetch("SCD_ACCOUNT_UUID")
@token = ENV.fetch("SCD_API_TOKEN")
end
def create_upstream(host)
request(:post, "upstreams", host: host)
end
def create_domain(host, **options)
request(:post, domains_path, { host: host, **options })
end
def verify_dns(uuid)
request(:post, "#{domains_path}/#{uuid}/verify_dns_records")
end
def delete_domain(uuid)
request(:delete, "#{domains_path}/#{uuid}")
end
private
def domains_path
"upstreams/#{ENV.fetch("SCD_UPSTREAM_UUID")}/custom_domains"
end
def request(verb, path, body = nil)
uri = URI("#{BASE}/#{@account}/#{path}")
req = Net::HTTP.const_get(verb.capitalize).new(uri)
req["Authorization"] = "Bearer #{@token}"
req["Content-Type"] = "application/json"
req.body = body.to_json if body
res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http|
http.request(req)
end
unless res.is_a?(Net::HTTPSuccess)
raise Error, "#{res.code}: #{res.body}"
end
JSON.parse(res.body)
end
endYou only create the upstream once, so do it from the Rails console:
SaasDomains.new.create_upstream("app.statuspage.com")
# => {"uuid"=>"your_upstream_uuid",
# "host"=>"app.statuspage.com",
# "port"=>443,
# "tls"=>true,
# "auth_token"=>"3f9c...",
# ...}Save the uuid as SCD_UPSTREAM_UUID and the auth_token as PROXY_AUTH_TOKEN. You'll use the token in step 5. You can also create the upstream in the dashboard. See the upstreams API docs for the other options.
3. Add custom domains from your app
When a customer adds a domain in your app, register it with us from a background job. Store the UUID we return so you can check and remove the domain later:
class AddProviderUuidToCustomDomains < ActiveRecord::Migration[8.0]
def change
add_column :custom_domains, :provider_uuid, :string
add_index :custom_domains, :provider_uuid, unique: true
end
endclass RegisterCustomDomainJob < ApplicationJob
def perform(domain)
res = SaasDomains.new.create_domain(
domain.host,
# Whoever manages their DNS gets step-by-step instructions
instructions_recipient: domain.company.dns_contact_email
)
domain.update!(provider_uuid: res["uuid"])
end
endclass CustomDomain < ApplicationRecord
after_create_commit -> { RegisterCustomDomainJob.perform_later(self) }
endThe instructions_recipient is optional. When you set it, we email that address the exact DNS records to add for this domain. That helps a lot when the person adding the domain in your app isn't the person who manages DNS. More options are in the custom domains API docs.
4. Point the customer's DNS at us
Your customer adds one of these records:
; Subdomain
status.intercom.com. CNAME in.saascustomdomains.com.
; Root domain
intercomstatus.com. A 75.2.96.173
intercomstatus.com. A 99.83.186.151Any old A records on the domain have to go, or some visitors will still end up on the old server. You can also white-label the CNAME target, so customers point at in.statuspage.com instead of our hostname.
DNS changes usually take a few minutes, sometimes longer while old records are cached.
Special note for Cloudflare users: if your customer manages DNS on Cloudflare, the record's proxy status must be DNS only (the grey cloud). When it's proxied, Cloudflare answers with its own IPs, so DNS checks fail and HTTPS breaks.

If they need Cloudflare's proxy, pass challenge_type: "dns01" to create_domain. The API response then includes one extra CNAME for them to add, under _acme-challenge, so the certificate can be issued through DNS instead.
5. Read the custom domain in Rails
Behind our proxy, the Host header is your app's own hostname, app.statuspage.com, so your hosting setup keeps routing requests to the right place. The customer's domain arrives in the X-Served-For header instead.
Remember host_from? That's the one method to change:
def self.host_from(request)
host = request.headers["X-Served-For"].presence || request.host
host.downcase
endThe route constraint and the tenant lookup now read the customer's domain from the header, and requests to your own domain carry on as before. Because Host is your own hostname, you don't need the config.hosts lambda from earlier either.
Check that the request came through the proxy
There's a catch. Your app now trusts X-Served-For to decide whose data to show. Anyone who reaches your app directly could send X-Served-For: status.intercom.com and see Intercom's pages, from any domain.
So every request we forward includes your upstream's auth token in the X-SaaS-Domains-Auth-Token header (docs). Reject any request that claims a custom domain without it, and compare in constant time so the check doesn't leak how much of a guess was right:
class ApplicationController < ActionController::Base
# Runs before every other before_action, including the tenant lookup
prepend_before_action :verify_proxy_token
private
def verify_proxy_token
return unless request.headers["X-Served-For"]
token = request.headers["X-SaaS-Domains-Auth-Token"].to_s
expected = ENV.fetch("PROXY_AUTH_TOKEN")
valid = ActiveSupport::SecurityUtils.secure_compare(token, expected)
head :forbidden unless valid
end
endKeep the token out of your repo (Rails credentials or an environment variable both work), and change it in the dashboard if it ever leaks.
Forms, redirects and sessions
For read-only pages, you're done. If customers' visitors log in or submit forms on their custom domain, there's one more thing. Rails builds absolute URLs from request.host, which behind the proxy is your own hostname. That causes three problems:
- Redirects leave the custom domain.
redirect_to dashboard_pathsends the visitor fromstatus.intercom.comtohttps://app.statuspage.com/dashboard. So do_urlroute helpers. - Form posts fail the CSRF check. Rails compares the browser's
Originheader withrequest.base_urland raisesActionController::InvalidAuthenticityTokenwith "HTTP Origin header (https://status.intercom.com) didn't match request.base_url (https://app.statuspage.com)". - Shared session cookies get rejected. If your session store uses
domain: :all, Rails computes the cookie domain fromrequest.host. The browser onstatus.intercom.comrefuses a cookie for.statuspage.com, so logins silently fail.
The fix is to have Rails see the customer's domain. Email support and we'll pass the custom domain through in X-Forwarded-Host for your account. Rails uses X-Forwarded-Host for request.host, so redirects, URL helpers and the CSRF check all use the customer's domain. Some of our Rails customers run exactly this setup.
Two things to adjust when you do:
Hoststays your own hostname, butHostAuthorizationalso checksX-Forwarded-Host. If you've setconfig.hostsin production, add the lambda from Fix "Blocked host" errors.- Leave the session cookie's
domainunset. Rails' default cookie is host-only, which is what you want on a domain you don't own.
Show DNS status and react to webhooks
Your customers will want to know whether their domain works yet. Ask us to re-check the DNS when they click a "Check DNS" button:
def check_dns!
res = SaasDomains.new.verify_dns(provider_uuid)
active! if res["dns_status"] == "valid"
res["dns_status"] # "valid", "pending" or "invalid"
endInstead of polling, you can let webhooks tell you when something changes. Add a webhook in the dashboard pointing at your app. Every webhook includes its security token in the token query parameter, so check that first:
# config/routes.rb:
# post "/webhooks/saas_domains", to: "saas_domains_webhooks#create"
class SaasDomainsWebhooksController < ActionController::API
before_action :verify_webhook_token
def create
uuid = params.dig(:domain, :uuid)
domain = CustomDomain.find_by(provider_uuid: uuid)
return head :ok unless domain
case params[:event]
when "domain_dns_proxy_record_valid" then domain.active!
when "domain_dns_proxy_record_invalid" then domain.pending!
end
head :ok
end
private
def verify_webhook_token
expected = ENV.fetch("SCD_WEBHOOK_TOKEN")
token = params[:token].to_s
valid = ActiveSupport::SecurityUtils.secure_compare(token, expected)
head :unauthorized unless valid
end
endOther events include domain_created, domain_deleted and tls_certificate_issued, which you can use to show "HTTPS ready" in your UI. The full list is in the webhooks docs. Return a 2xx quickly. Failed deliveries are retried up to 10 times.
Remove a domain
When a customer removes a domain or cancels, delete it from the proxy first, then from your database:
class RemoveCustomDomainJob < ApplicationJob
def perform(domain)
SaasDomains.new.delete_domain(domain.provider_uuid)
domain.destroy!
end
endIn this order, if the API call fails, your record is still there and you can retry the job. In the other order, a failed call leaves a domain on the proxy that no longer belongs to anyone in your app. If you run Caddy yourself, removing the row is enough: the ask endpoint starts returning 404, so the certificate isn't renewed.
Test custom domains locally
You don't need a real domain to test the routing. Chrome, Firefox and curl resolve any *.localhost name to your own machine, and Rails allows *.localhost in development, so there's nothing to configure:
# bin/rails console
company = Company.first
company.custom_domains.create!(
host: "intercom.localhost", status: :active
)Open http://intercom.localhost:3000 and you'll see that company's status page, while http://localhost:3000 still shows your app. Set APP_HOST=localhost in development so the route constraint knows which host is yours.
To simulate the proxy, send the headers yourself:
curl http://localhost:3000/ \
-H "X-Served-For: intercom.localhost" \
-H "X-SaaS-Domains-Auth-Token: $PROXY_AUTH_TOKEN"Try it without the token too. You should get a 403.
Frequently asked questions
How do I get the custom domain in a Rails controller?
Use request.host. It returns the hostname the visitor typed, without the port. Behind SaaS Custom Domains, read request.headers["X-Served-For"] instead, and check the X-SaaS-Domains-Auth-Token header first.
Why does Rails say "Blocked host" for my custom domain?
Rails' HostAuthorization middleware only allows hosts listed in config.hosts. It's on by default in development and off in production unless you've set config.hosts. Add a lambda that checks your custom_domains table instead of listing every domain.
Do I need a gem to support custom domains in Rails?
No. A model, a route constraint and a before_action are enough. Gems like acts_as_tenant help with scoping queries to the current tenant, but you still look up the tenant from the hostname yourself.
Can Kamal issue SSL certificates for my customers' domains?
kamal-proxy supports on-demand TLS since v0.10.0, but as of October 2026 Kamal's deploy.yml can't configure it yet. Run Caddy with on-demand TLS, or a managed proxy, in front of your Kamal app.
Can customers use a root domain like intercomstatus.com?
Yes, with A records pointing at your static IPs, because DNS doesn't allow a CNAME at the root. Some DNS providers support ALIAS records or CNAME flattening, which work like a CNAME there.
Will Devise and OmniAuth work on custom domains?
Devise works: each custom domain gets its own host-only session cookie, so users sign in separately on each domain. OAuth providers only redirect to callback URLs you registered in advance, so route "Sign in with Google" through your main domain and send the user back to the custom domain afterwards.
Next steps
You've got custom domains working in Rails: DNS pointing at you, a model to store them, routing by hostname, HTTPS and the security check on the proxy's token. For the parts that only show up at scale, read the full guide to custom domains for SaaS. If you work in other stacks too, we have guides for Django and Node.js.
If you have any questions, reach out via email or the chat widget on saascustomdomains.com. If you don't have an account yet, start your 7-day free trial, or book a 15-minute demo.


