How to Add Custom Domains to a Ruby on Rails App


To add custom domains to a Ruby on Rails app, have each customer point a DNS record at you, save their domain on their account, and pick the account in a before_action by looking up request.host. The remaining work is HTTPS: every one of those domains needs its own certificate before a browser will load it.
The Rails code is short: a migration, a model, a route constraint and a controller concern. Most of the time goes into what Rails does around them. HostAuthorization answers "Blocked hosts", forms fail with HTTP Origin header (...) didn't match request.base_url (...) once a proxy sits in front, and redirect_to sends visitors back to your own hostname. Everything below comes from a working Rails 8.1 app on GitHub, tested on Ruby 3.4 and 4.0, with unit tests and browser tests that push traffic through a mock of our proxy running on your laptop.
In this guide:
- Run the example app
- From a customer's DNS record to your Rails app
- Save each customer's domain in a model
- Pick the company from the hostname
- Fix "Blocked hosts" errors
- Add HTTPS for every custom domain
- Connect SaaS Custom Domains to Rails
- Fix "HTTP Origin header didn't match request.base_url"
- Keep each domain's status current
- Try it on your machine
Run the example app
The example is a status page product. Two companies come seeded, Acme Cloud and Globex Payments, each with a few components and a "Get updates" form that visitors can subscribe through. With Docker installed:
git clone https://github.com/saas-custom-domains/custom-domains-rails
cd custom-domains-rails
docker compose up --buildOpen http://localhost:3000, which is the owner dashboard, and connect acme.localhost to Acme Cloud. After a few seconds its badge turns Live. Now visit http://acme.localhost and subscribe with an email address. On port 80, scd-mock answers, which mimics our API and proxy, so Rails gets exactly the headers production sends. Every *.localhost name points at 127.0.0.1 in modern browsers and curl, so you don't touch /etc/hosts.
| Tested with | |
|---|---|
| Rails | 8.1.4 |
| Ruby | 3.4 and 4.0 |
| Puma | 8.0 |
| Background jobs | Solid Queue 1.7, inside Puma |
| Database | SQLite (any Active Record database works) |
Checked on October 28, 2026. Most of the code also runs on Rails 7.1 and later; params.expect needs 8.0, and the it block parameter needs Ruby 3.4.
From a customer's DNS record to your Rails app
Before any Ruby runs, the customer's domain has to reach your app. Say your status page product lives at app.statuspage.com, and Intercom, a customer, wants its status page on status.intercom.com.
Intercom adds one DNS record that hands the domain to SaaS Custom Domains. A subdomain gets a CNAME. A bare domain like intercomstatus.com can't hold a CNAME under the DNS rules, so it gets two A records with our anycast IPs, which stay the same for good:
; Subdomain
status.intercom.com. CNAME in.saascustomdomains.com.
; Root domain
intercomstatus.com. A 75.2.96.173
intercomstatus.com. A 99.83.186.151If you'd rather customers see your brand, white-label the target so they enter in.statuspage.com. Either way our proxy answers for status.intercom.com: it holds the certificate, accepts the visitor's HTTPS connection and passes the request on to app.statuspage.com with Intercom's domain in a header Rails can read. Step 5 shows how.
Save each customer's domain in a model
Rails has to know which customer owns which domain. A CustomDomain model holds that mapping, plus a status column, so only domains with working DNS get served. A Company is the tenant in this example:
class CreateCustomDomains < ActiveRecord::Migration[8.1]
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
endKeep that unique index. Without it, two companies could save the same hostname, and visitors would land on whichever company's row the database happened to return.
class CustomDomain < ApplicationRecord
belongs_to :company
enum :status, { pending: "pending", active: "active" }
normalizes :host, with: ->(host) { host.strip.downcase.chomp(".") }
normalizes :dns_contact_email, with: ->(e) { e.strip.presence }
validates :host, presence: true, uniqueness: true,
format: { with: /\A[a-z0-9-]+(\.[a-z0-9-]+)+\z/ }
validates :dns_contact_email, allow_nil: true,
format: { with: URI::MailTo::EMAIL_REGEXP }
validate :not_one_of_our_domains
# The one place that reads the hostname. Behind SaaS Custom
# Domains, SaasDomainsProxy has already put the customer's domain
# (from X-Served-For) into X-Forwarded-Host, so this stays as is.
def self.host_from(request)
request.host.downcase
end
# Hostnames your own app answers on: your domain, plus any internal
# name your platform uses to reach the app (health checks, webhooks).
def self.app_hosts
ENV.fetch("APP_HOSTS", "localhost").split(",")
.map { it.strip.downcase }.compact_blank
end
def self.app_host?(host)
app_hosts.include?(host)
end
private
def not_one_of_our_domains
ours = ENV.fetch("APP_DOMAIN") # "statuspage.com"
if host == ours || host.to_s.end_with?(".#{ours}") ||
self.class.app_host?(host)
errors.add(:host, "can't be one of our own domains")
end
end
endWhat each part is for:
normalizesturnsStatus.Intercom.com.intostatus.intercom.combefore validation and before every query, so the unique index andfind_byagree on one spelling. For internationalized domain names, convert to punycode first (thesimpleidngem does it).- The reserved-domain check stops a customer from claiming
admin.statuspage.com, or any hostname listed inAPP_HOSTS. APP_HOSTSis a list, not a single value. Health checks, webhooks and container networks often reach the app under a second name, and every one of them is "your own domain" for routing purposes.host_fromis the only method that reads the hostname. The route constraint and the tenant lookup both call it.
Pick the company from the hostname
Browsers put the hostname in the Host header of every request, and Rails exposes it as request.host, already without the port. For a visitor on status.intercom.com, it returns "status.intercom.com". Routing is a matter of looking that up.
A route constraint splits traffic first. Customer domains get the status page routes; your own hostnames get the dashboard:
class CustomDomainConstraint
def self.matches?(request)
!CustomDomain.app_host?(CustomDomain.host_from(request))
end
endRails.application.routes.draw do
# Reachable on any host: load balancers call /up by IP address, and
# webhooks may arrive on an internal hostname.
get "up" => "rails/health#show", as: :rails_health_check
post "/webhooks/saas_domains", to: "saas_domains_webhooks#create"
# Customers' domains: their status page and nothing else.
constraints(CustomDomainConstraint) do
root "status_pages#show", as: :custom_domain_root
resources :subscriptions, only: :create
match "*path", to: "status_pages#not_found", via: :all
end
# Your own domain (APP_HOSTS): the dashboard.
root "home#index"
resources :custom_domains, only: %i[create destroy] do
post :check, on: :member
end
endThe order of these blocks does real work. The health check and webhook routes come first because they have to answer on any hostname. The catch-all match "*path" inside the constraint means a customer's domain can only ever reach the status page: without it, status.intercom.com/custom_domains would fall through to your dashboard routes. The constraint never queries the database, so it costs nothing per request, and Rails autoloads app/constraints like any other folder under app/.
Then find the company. Current holds it for the rest of the request, and a concern keeps the lookup in one place:
class Current < ActiveSupport::CurrentAttributes
attribute :company
endmodule CustomDomainTenant
extend ActiveSupport::Concern
included do
before_action :set_company_from_domain
end
private
# Unknown and pending domains raise ActiveRecord::RecordNotFound,
# which Rails turns into a 404.
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
layout "status_page"
def show
@status_data = Current.company.fetch_status_data
@subscriber = Current.company.subscribers.new
end
def not_found
head :not_found
end
endIf someone points a random domain at your IP, or a customer's DNS isn't verified yet, find_by! raises and the visitor gets a 404 instead of somebody's status page. The subscribe form's controller includes the same concern and takes the company from Current, never from a form field, so a crafted POST can't subscribe someone to another tenant.
Two more notes. With acts_as_tenant, call set_current_tenant(domain.company) in that same before_action. And new Rails 8 apps generate allow_browser versions: :modern in ApplicationController, which answers 406 to older browsers; on a public page like a status page, you'll probably want to drop it, as the example does.
So far, a request travels like this:

Fix "Blocked hosts" errors
Open a customer's domain against a development server and Rails may stop you with this page:
Blocked hosts: status.intercom.com:3000
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:3000"The middleware behind it is ActionDispatch::HostAuthorization, which defends against DNS rebinding. Some details that the error page doesn't tell you:
- The hostname is shown exactly as the browser sent it, port included, so on port 3000 the suggested line contains
:3000. - It checks
Hostand also the last value ofX-Forwarded-Host. When both fail, both appear in the list. - Development allows
localhost, any*.localhostor*.testname, every IP address, and names in theRAILS_DEVELOPMENT_HOSTSvariable. - Production checks nothing until you set
config.hosts, which the generatedproduction.rbsuggests in a comment. Once set, a blocked request gets an empty 403, and the only clue is a log line:[ActionDispatch::HostAuthorization::DefaultResponseApp] Blocked hosts: status.intercom.com.
Listing customer domains one by one won't scale. config.hosts also takes lambdas, so let the custom_domains table answer:
# Your own hostnames (APP_HOSTS, e.g. "app.statuspage.com"), plus any
# customer domain in the database. The lambda gets the Host header as
# sent, so it may include a port.
config.hosts = [
*ENV.fetch("APP_HOSTS", "").split(",").map(&:strip),
->(host) {
CustomDomain.exists?(host: host.split(":").first.downcase)
}
]
# Load balancers call the health check by IP address.
config.host_authorization = {
exclude: ->(request) { request.path == "/up" }
}The example repo adds the same lambda and APP_HOSTS to development.rb. Any hostname not in the list sends Rails to the database, which is one more reason for the unique index on host. Wrap it in Rails.cache if it ever shows up in your metrics.
Add HTTPS for every custom domain
A browser won't show status.intercom.com over HTTPS until something holds a certificate for that exact name. Each customer domain needs its own, and you can't create them ahead of time because you don't know the domains ahead of time.
Let's Encrypt and ZeroSSL issue them at no cost. Getting them yourself means running a TLS-terminating reverse proxy in front of Rails and owning everything around it:
- Requesting a certificate when a new domain connects, and only for domains you actually serve, so strangers can't burn through your rate limits.
- Renewing every certificate before it expires, and noticing when a renewal fails.
- Sharing certificates and account keys between servers.
- A second certificate authority for when the first one is down or rate-limits you.
- Static IP addresses for customers with root domains, and a plan for abuse.
Kamal, the default deploy tool for Rails 8, gets certificates only for the hosts you list in deploy.yml, so it can't cover customer domains out of the box. Our custom domains guide for SaaS goes deeper into what certificates at scale involve, and this list of reasons not to run your own TLS proxy covers the rest: abuse, scanners, root domains and 45-day renewals. The rest of this guide hands the whole job to a managed proxy.
Connect SaaS Custom Domains to Rails
SaaS Custom Domains is a managed proxy that stands between your customers' visitors and your Rails app. Customers point their DNS at us; we hold their certificates and pass each request on to your app, telling it which domain the visitor used. What you get:
- Certificates from Let's Encrypt and ZeroSSL, issued on the first request for a new domain and renewed without any action from you.
- Edge servers in 119 locations on AWS. When a region struggles, visitors go to the closest healthy one.
- Two anycast IPs for root domains that will never change.
- A REST API to connect or disconnect a domain, and webhooks for DNS and certificate changes.
- Optional emails to your customer's DNS admin listing the exact records to create, followed by ongoing checks until they're right.
Billing starts at $29 a month, which covers 100 domains; each domain costs $0.29 monthly, with lower rates past 1,000 domains. You'll enter a card to start the 7-day trial and can cancel any time.
Five steps:
- Generate an API token (1 minute)
- Register your Rails app as an upstream (1 minute)
- Register domains from a background job (5 minutes)
- Have the customer point DNS at us (1 minute)
- Add a middleware that checks our token and hands Rails the customer's domain (10 minutes)
1. Generate an API token
In your account settings, open API tokens and generate one.

Copy your account UUID from account settings too:

Both go into environment variables, SCD_API_TOKEN and SCD_ACCOUNT_UUID. Anyone holding the token can manage your account, so store it like a database password.
2. Register the upstream
The upstream is where our proxy sends traffic: your Rails app. Here's the client the example uses, built on Net::HTTP alone:
require "net/http"
# A small client for the SaaS Custom Domains API, with nothing but the
# standard library. Docs: https://docs.saascustomdomains.com
class SaasDomains
class Error < StandardError
attr_reader :status
def initialize(status, body)
@status = status
super("SaaS Custom Domains API #{status}: #{body}")
end
# 4xx means the API said no (e.g. host_taken); retrying won't help.
def rejected?
status.between?(400, 499)
end
end
def initialize
# SCD_API_URL is only for the local mock. Leave it unset in
# production.
@base = ENV["SCD_API_URL"] || "https://app.saascustomdomains.com"
@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 }.compact)
end
def verify_dns(uuid)
request(:post, "#{domains_path}/#{uuid}/verify_dns_records")
end
def delete_domain(uuid)
request(:delete, "#{domains_path}/#{uuid}")
rescue Error => e
# Already gone is fine: the goal is that it doesn't exist.
raise unless e.status == 404
end
private
def domains_path
"upstreams/#{ENV.fetch("SCD_UPSTREAM_UUID")}/custom_domains"
end
def request(verb, path, body = nil)
uri = URI("#{@base}/api/v1/accounts/#{@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: uri.scheme == "https",
open_timeout: 5, read_timeout: 10) { |http| http.request(req) }
unless res.is_a?(Net::HTTPSuccess)
raise Error.new(res.code.to_i, res.body)
end
res.body.present? ? JSON.parse(res.body) : {}
end
endA few choices in there are deliberate. Errors carry the HTTP status, so callers can tell a refusal (400 with an error_code such as host_taken) from an outage. A 404 on delete counts as success. .compact drops options you left empty instead of sending null. Timeouts are explicit, because Net::HTTP would otherwise wait 60 seconds. SCD_API_URL lets the same class talk to the mock in development.
The upstream is a one-off, so the console is fine:
SaasDomains.new.create_upstream("app.statuspage.com")
# => {"uuid"=>"your_upstream_uuid",
# "host"=>"app.statuspage.com",
# "port"=>443,
# "tls"=>true,
# "auth_token"=>"3f9c...",
# ...}Put the uuid in SCD_UPSTREAM_UUID and the auth_token in PROXY_AUTH_TOKEN; step 5 checks that token on every request. You can also create the upstream in our dashboard; the upstreams API docs list the other fields.
3. Register domains from a background job
When a customer adds a domain in your dashboard, tell us about it from a job, and keep the UUID we return for checking and removing it later:
class AddProviderUuidToCustomDomains < ActiveRecord::Migration[8.1]
def change
add_column :custom_domains, :provider_uuid, :string
add_index :custom_domains, :provider_uuid, unique: true
end
end # Register new domains with SaaS Custom Domains in the background.
after_create_commit :register
private
def register
RegisterCustomDomainJob.perform_later(self)
endclass RegisterCustomDomainJob < ApplicationJob
TRANSIENT_ERRORS = [
SaasDomains::Error, Net::OpenTimeout, Net::ReadTimeout,
Errno::ECONNREFUSED
].freeze
# Network errors and 5xx: try again later. If it never works, don't
# keep a domain the proxy doesn't know about.
retry_on(*TRANSIENT_ERRORS,
wait: :polynomially_longer, attempts: 5) do |job, _error|
job.arguments.first.destroy!
end
discard_on ActiveJob::DeserializationError # removed meanwhile
def perform(domain)
res = SaasDomains.new.create_domain(
domain.host,
# Whoever manages their DNS gets step-by-step instructions
instructions_recipient: domain.dns_contact_email
)
domain.update!(provider_uuid: res["uuid"])
rescue SaasDomains::Error => e
raise unless e.rejected?
# The API said no (e.g. host_taken): remove our row too.
Rails.logger.warn("Removing #{domain.host}: #{e.message}")
domain.destroy!
end
endThe job separates the two ways a call can fail. A refusal, such as a domain another account already uses, won't go away on retry, so the job deletes the local row rather than leaving a domain that waits for DNS forever. Timeouts and 5xx answers are retried with growing pauses, and the row goes only if every attempt fails. Since the work happens in a job, a refused domain simply vanishes from the dashboard; in your app, tell the user why.
dns_contact_email is a nullable string column on custom_domains, filled from an optional field next to the domain input. With it set, we send that address an email listing the records for this domain, which helps when the person adding the domain to your app has no access to the company's DNS. The other options are in the custom domains API docs.
The example runs Solid Queue inside Puma (SOLID_QUEUE_IN_PUMA=true), the Rails 8 default for one server. On more servers, run bin/jobs separately.
4. Point the customer's DNS at us
The customer adds one of these:
; Subdomain
status.intercom.com. CNAME in.saascustomdomains.com.
; Root domain
intercomstatus.com. A 75.2.96.173
intercomstatus.com. A 99.83.186.151Older A records on the same name must be deleted, or part of the traffic keeps reaching the previous server. The CNAME target can carry your brand, too: customers would point at in.statuspage.com. We don't edit anyone's DNS ourselves; we check it and, if you asked for it, email the instructions.
Expect a few minutes before DNS changes show up, sometimes longer while resolvers hold on to cached answers.
Cloudflare needs one setting. Switch the record's proxy status to DNS only (grey cloud). If the orange cloud is on, Cloudflare answers with its own addresses, our DNS check fails, and we can't get a certificate.

Customers who must keep Cloudflare's proxy can: pass challenge_type: "dns01" to create_domain. The response then includes an extra CNAME under _acme-challenge for them to create, and the certificate is validated through DNS.
5. Check our token, then hand Rails the customer's domain
Our proxy deliberately keeps your hostname, app.statuspage.com, in Host and X-Forwarded-Host, because hosting platforms, load balancers and many apps route on it, and they keep working unchanged. The customer's domain arrives separately, in X-Served-For, next to the visitor's IP in X-SaaS-Domains-IP and X-Forwarded-Proto: https.
Any client can set a header, though. A script hitting app.statuspage.com directly could add X-Served-For: status.intercom.com and be treated as Intercom's domain. So every request we forward includes a second header, X-SaaS-Domains-Auth-Token, holding the upstream's auth token (docs).
When your app has to know the customer's domain, for forms, redirects or links (this one does, for the subscribe form), hand it to Rails right after the token check. A Rack middleware does both:
class SaasDomainsProxy
def initialize(app)
@app = app
end
def call(env)
served_for = env["HTTP_X_SERVED_FOR"]
return @app.call(env) if served_for.nil?
unless verify_proxy_token(env["HTTP_X_SAAS_DOMAINS_AUTH_TOKEN"])
return [ 403, {}, [ "Forbidden" ] ]
end
# Rails reads X-Forwarded-Host before Host for request.host and
# request.base_url. X-Forwarded-Proto is already "https".
env["HTTP_X_FORWARDED_HOST"] = served_for
@app.call(env)
end
private
# Constant time, so a wrong guess doesn't reveal how much of it was
# right. An unset or empty PROXY_AUTH_TOKEN rejects everything.
def verify_proxy_token(token)
expected = ENV["PROXY_AUTH_TOKEN"].to_s
expected.present? &&
ActiveSupport::SecurityUtils.secure_compare(token.to_s, expected)
end
end
# After HostAuthorization, which then only sees your own hostnames.
Rails.application.config.middleware.use SaasDomainsProxyHow it fits together:
- Requests on your own domain have no
X-Served-Forand pass straight through, so the dashboard is unaffected. expected.present?matters.secure_compare("", "")is true, so ifPROXY_AUTH_TOKENwere set to an empty string in some environment, a request without the token header would otherwise get in.- Rails builds
request.hostfromX-Forwarded-Hostwhen it's there. After the middleware,host_fromreturnsstatus.intercom.comwithout any change to the model, and so does everything else in Rails that reads the host. config.middleware.useappends the middleware afterHostAuthorization. That check therefore seesapp.statuspage.comin both headers and passes onAPP_HOSTSalone, with no database lookup for proxied traffic.
If you'd rather have the proxy put the customer's domain in X-Forwarded-Host itself, ask support to enable it; keep the token check either way.
Fix "HTTP Origin header didn't match request.base_url"
Here's what the middleware prevents. Without it, Rails thinks every proxied request is for app.statuspage.com, and the first form a visitor submits on a customer's domain fails:
ActionController::InvalidAuthenticityToken (HTTP Origin header
(https://status.intercom.com) didn't match request.base_url
(https://app.statuspage.com))Rails checks the CSRF token and, separately, compares the browser's Origin header with request.base_url; new apps have had the Origin check on since Rails 5. The authenticity token is fine here. The base URL is wrong. The same root cause shows up in two more places:
redirect_to custom_domain_root_pathproducesLocation: https://app.statuspage.com/, moving the visitor off the customer's domain. Every_urlhelper does the same.- A session store set to
domain: :alltakes the cookie domain fromrequest.host, and the browser onstatus.intercom.comdiscards a cookie forstatuspage.com. Sign-ins and flash messages silently stop working.
With SaasDomainsProxy in place, all three use status.intercom.com. For cookies, also leave domain unset: the default host-only cookie is right on a domain you don't own, while domain: :all would now scope it to intercom.com, the customer's entire domain. Here's the subscribe action in the example, which works unchanged on any connected domain:
# The form on a customer's status page. A POST on their domain, so it
# goes through Rails' CSRF check, including the Origin check.
class SubscriptionsController < ApplicationController
include CustomDomainTenant
layout "status_page"
def create
# The company comes from the domain, never from the form.
@subscriber = Current.company.subscribers.find_or_initialize_by(
email: params.expect(subscriber: [ :email ])[:email]
)
if @subscriber.save
redirect_to custom_domain_root_path,
notice: "Subscribed to #{Current.company.name} updates."
else
@status_data = Current.company.fetch_status_data
render "status_pages/show", status: :unprocessable_content
end
end
endKeep each domain's status current
Customers want to know whether their domain works yet. A "Check DNS" button asks us to look right now:
# The "Check DNS" button: ask SaaS Custom Domains to look now.
def check_dns!
return unless provider_uuid # not registered yet
res = SaasDomains.new.verify_dns(provider_uuid)
update!(status: res["dns_status"] == "valid" ? :active : :pending)
res["dns_status"] # "valid", "pending" or "invalid"
endIt sets the status both ways, so a domain whose DNS was changed away from us goes back to pending and stops being served.
Webhooks save you from polling. Add one in our dashboard with your app's URL; each delivery has its secret in the token query parameter, which the controller checks before anything else:
# Events: https://docs.saascustomdomains.com/webhooks
class SaasDomainsWebhooksController < ActionController::API
before_action :verify_webhook_token
def create
uuid = params.dig(:domain, :uuid)
domain = CustomDomain.find_by(provider_uuid: uuid)
# Not ours, or already removed: acknowledge so it isn't retried.
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!
when "tls_certificate_issued" then domain.update!(https_ready: true)
end
head :ok
end
private
def verify_webhook_token
expected = ENV["SCD_WEBHOOK_TOKEN"].to_s
token = params[:token].to_s
valid = expected.present? &&
ActiveSupport::SecurityUtils.secure_compare(token, expected)
head :unauthorized unless valid
end
endThe dashboard badge follows from two columns: "Waiting for DNS" while pending, "DNS OK, waiting for HTTPS" once active, and "Live" when https_ready (a boolean column, default false) flips too. The handlers are idempotent, which matters since we redeliver a failed webhook up to 10 times; answer with a 2xx quickly. The webhooks docs list the remaining events, such as domain_created and domain_deleted.
Remove a domain
When a customer disconnects a domain or leaves, remove it from our side before your own table:
class RemoveCustomDomainJob < ApplicationJob
retry_on(*RegisterCustomDomainJob::TRANSIENT_ERRORS,
wait: :polynomially_longer, attempts: 10)
discard_on ActiveJob::DeserializationError # already removed
# Proxy first, database second: if the API call fails, the row is
# still here and the job is retried.
def perform(domain)
if domain.provider_uuid
SaasDomains.new.delete_domain(domain.provider_uuid)
end
domain.destroy!
end
endReverse the order and a failed API call leaves a domain live on the proxy that no row in your app owns anymore. A 404 from the API means it's already gone, which the client treats as success.
Try it on your machine
Routing needs no proxy at all to try out. Chrome, Firefox and curl send every *.localhost name to your own machine, and Rails accepts them in development:
# bin/rails console
company = Company.first
company.custom_domains.insert_all([
{ host: "intercom.localhost", status: "active" }
])insert_all skips the callbacks, so nothing is registered with the API. Visit http://intercom.localhost:3000 for that company's status page while http://localhost:3000 keeps showing the dashboard; APP_HOSTS=localhost is what tells the constraint which one is yours.
You can play the proxy by hand with curl:
curl http://localhost:3000/ \
-H "X-Served-For: intercom.localhost" \
-H "X-SaaS-Domains-Auth-Token: $PROXY_AUTH_TOKEN"Remove the token header and the middleware answers 403.
For the whole flow, use the repo's docker compose setup. scd-mock answers the same API calls as production, flips *.localhost and *.test domains to valid after two seconds, sends the webhooks, and proxies requests with production's headers. One setting differs: the mock has no TLS, so compose.yaml sets SCD_MOCK_FORWARDED_PROTO=http. Production always says https, and if the mock did too, your http:// browser tab would fail the Origin check for a reason that never happens in production. The repo's Playwright tests send *.test domains through the mock as an HTTP proxy, then add a domain, wait for the webhook, submit the form, remove the domain, and try a spoofed X-Served-For.
Frequently asked questions
Where does Rails keep the domain the visitor typed?
Call request.host. It gives the hostname without the port, and it prefers X-Forwarded-Host over Host. Behind SaaS Custom Domains, the customer's domain comes in X-Served-For; a middleware that verifies X-SaaS-Domains-Auth-Token and copies that value into X-Forwarded-Host makes request.host return it everywhere.
Why does Rails return "Blocked hosts" for my customer's domain?
HostAuthorization only lets through hosts that match config.hosts, checking both Host and X-Forwarded-Host. Development has a default list; production is open until you set one. Add a lambda that looks the name up in your domains table, and exclude /up for health checks that use an IP address.
Why do forms fail with InvalidAuthenticityToken on a custom domain?
When the proxy forwards requests under your hostname, request.base_url is your domain while the browser's Origin is the customer's, so the Origin check fails. Set X-Forwarded-Host to the verified customer domain before the request reaches your controllers, as shown in step 5.
Do I need a gem to support custom domains in Rails?
No. A model, a route constraint, a concern and a small middleware cover it. acts_as_tenant helps with scoping queries to one company, but finding the company from the hostname is still your code.
Can Kamal issue SSL certificates for my customers' domains?
Not out of the box. Kamal requests certificates for the hosts in deploy.yml, and customer domains aren't known at deploy time. Put a managed proxy such as SaaS Custom Domains in front and keep deploying with Kamal as usual.
Can customers use a root domain like intercomstatus.com?
Yes. They create A records for 75.2.96.173 and 99.83.186.151, because a CNAME isn't allowed at the apex. ALIAS records or CNAME flattening also do the job where the DNS provider has them.
Will Devise and OmniAuth work on custom domains?
Devise does. Each domain gets its own host-only session cookie, so a visitor signs in separately on each domain. OAuth providers only redirect to callback URLs registered beforehand, so send "Sign in with Google" through your main domain and return the visitor to the customer's domain afterwards.
Next steps
Your Rails app now has the full path: customer DNS pointing at the proxy, a normalized and unique domain table, a constraint and concern that pick the company, config.hosts that knows your customers, a middleware that trusts X-Served-For only with the right token, and webhooks that keep each domain's status current. The example repo has all of it plus the tests. For what changes with thousands of domains, read the long-form guide to SaaS custom domains.
Building in other frameworks as well? See the guides for Laravel, Django, Next.js, SvelteKit, FastAPI, Go and Node.js.
Stuck on something Rails-specific? Email support@saascustomdomains.com or open the chat widget on saascustomdomains.com. Ready to put a real customer domain on it? Sign up for a 7-day trial, or grab a 15-minute call about your Rails app.


