Engineering

How to Add Custom Domains to a Ruby on Rails App

Drago Crnjac
Drago Crnjac
CEO & Founder
Cover Image for 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

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 --build

Open 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
Rails8.1.4
Ruby3.4 and 4.0
Puma8.0
Background jobsSolid Queue 1.7, inside Puma
DatabaseSQLite (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.151

If 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:

db/migrate/20261028000002_create_custom_domains.rb
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
end

Keep 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.

app/models/custom_domain.rb
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
end

What each part is for:

  • normalizes turns Status.Intercom.com. into status.intercom.com before validation and before every query, so the unique index and find_by agree on one spelling. For internationalized domain names, convert to punycode first (the simpleidn gem does it).
  • The reserved-domain check stops a customer from claiming admin.statuspage.com, or any hostname listed in APP_HOSTS.
  • APP_HOSTS is 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_from is 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:

app/constraints/custom_domain_constraint.rb
class CustomDomainConstraint
  def self.matches?(request)
    !CustomDomain.app_host?(CustomDomain.host_from(request))
  end
end
config/routes.rb
Rails.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
end

The 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:

app/models/current.rb
class Current < ActiveSupport::CurrentAttributes
  attribute :company
end
app/controllers/concerns/custom_domain_tenant.rb
module 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
end
app/controllers/status_pages_controller.rb
class 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
end

If 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:

A visitor's request to status.intercom.com travelling to the Rails app, which picks the company

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 Host and also the last value of X-Forwarded-Host. When both fail, both appear in the list.
  • Development allows localhost, any *.localhost or *.test name, every IP address, and names in the RAILS_DEVELOPMENT_HOSTS variable.
  • Production checks nothing until you set config.hosts, which the generated production.rb suggests 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:

config/environments/production.rb
  # 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:

  1. Generate an API token (1 minute)
  2. Register your Rails app as an upstream (1 minute)
  3. Register domains from a background job (5 minutes)
  4. Have the customer point DNS at us (1 minute)
  5. 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.

Generating a token on the API tokens page

Copy your account UUID from account settings too:

Where the account UUID is shown in account settings

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:

app/services/saas_domains.rb
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
end

A 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:

db/migrate/20261028000003_add_provider_uuid_to_custom_domains.rb
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
app/models/custom_domain.rb
  # Register new domains with SaaS Custom Domains in the background.
  after_create_commit :register

  private

  def register
    RegisterCustomDomainJob.perform_later(self)
  end
app/jobs/register_custom_domain_job.rb
class 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
end

The 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.151

Older 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.

A Cloudflare DNS record with its proxy status set to DNS only

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:

config/initializers/saas_domains_proxy.rb
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 SaasDomainsProxy

How it fits together:

  • Requests on your own domain have no X-Served-For and pass straight through, so the dashboard is unaffected.
  • expected.present? matters. secure_compare("", "") is true, so if PROXY_AUTH_TOKEN were set to an empty string in some environment, a request without the token header would otherwise get in.
  • Rails builds request.host from X-Forwarded-Host when it's there. After the middleware, host_from returns status.intercom.com without any change to the model, and so does everything else in Rails that reads the host.
  • config.middleware.use appends the middleware after HostAuthorization. That check therefore sees app.statuspage.com in both headers and passes on APP_HOSTS alone, 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_path produces Location: https://app.statuspage.com/, moving the visitor off the customer's domain. Every _url helper does the same.
  • A session store set to domain: :all takes the cookie domain from request.host, and the browser on status.intercom.com discards a cookie for statuspage.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:

app/controllers/subscriptions_controller.rb
# 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
end

Keep each domain's status current

Customers want to know whether their domain works yet. A "Check DNS" button asks us to look right now:

app/models/custom_domain.rb
  # 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"
  end

It 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:

app/controllers/saas_domains_webhooks_controller.rb
# 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
end

The 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:

app/jobs/remove_custom_domain_job.rb
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
end

Reverse 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.

More Articles

Cover Image for 45-Day Certificates: What Changes for SaaS Custom Domains
Drago Crnjac

45-Day Certificates: What Changes for SaaS Custom Domains

Let's Encrypt cuts certificates to 64 days on February 10, 2027. For platforms that issue certificates for customer domains, the real change is how little time you get to fix a customer's broken DNS.

Read more
Cover Image for Custom Domain SSL: 12 Reasons Not to Build It Yourself
Drago Crnjac

Custom Domain SSL: 12 Reasons Not to Build It Yourself

The first certificate takes an afternoon. Then come 45-day renewals, rate limits, static IPs for root domains, scanners, abuse, and a CDN you never meant to build.

Read more
Cover Image for How to Add Custom Domains to a Multi-Tenant Spring Boot App
Drago Crnjac

How to Add Custom Domains to a Multi-Tenant Spring Boot App

How to add custom domains to a Spring Boot app: finding the customer from the hostname, redirects and forms that stay on their domain, and HTTPS.

Read more