← Back to blog

How to Add Custom Domains to a Rails Multi-Tenant SaaS (with Code)

Daniel Sternlicht·
tutorialrailsmulti-tenantcustom domainssaas
How to Add Custom Domains to a Rails Multi-Tenant SaaS (with Code)

To add customer custom domains to a multi-tenant Rails app you need three pieces: a custom_domains table that maps hostnames to tenants, a resolver that reads the Host header on every request, and an edge that terminates TLS for each customer hostname. This guide builds all three, with working code, and compares the TLS options at the end.

Most Rails multi-tenant tutorials stop at subdomains. They wire up acts_as_tenant or row-level scoping, show you acme.yourapp.com, and call it done. Real customers want app.acme.com, their domain, their brand. That last step is where the tutorials go quiet, because it drags in DNS, certificates, and a Rails 6+ security feature that silently blocks the whole thing if you don't know it's there.

This is the Rails sibling of our Next.js multi-tenant guide. Same architecture, Rails idioms.

Architecture

[ visitor: app.acme.com ]
        |
        v  TLS handshake (cert for app.acme.com)
[ edge layer: terminates TLS, proxies to origin ]
        |
        v  X-Forwarded-Host: app.acme.com
[ your Rails origin (Heroku / Fly / VPS / wherever) ]
        |
        v  middleware reads the hostname
[ tenant lookup -> Current.tenant set for the request ]

The edge layer is whatever answers the TLS handshake for customer hostnames: a Caddy server you run, your platform's certificate manager, or a custom domains API. The Rails side is identical in all three cases, so we build that first.

Step 1: model custom domains per tenant

A tenant has_many :custom_domains. Keep domains in their own table, not a string column on the tenant, because one tenant will eventually want two domains and every domain needs its own lifecycle state.

# db/migrate/xxxx_create_custom_domains.rb
class CreateCustomDomains < ActiveRecord::Migration[8.0]
  def change
    create_table :custom_domains do |t|
      t.references :tenant, null: false, foreign_key: true
      t.string :hostname, null: false
      t.string :status, null: false, default: "pending" # pending -> verifying -> active
      t.timestamps
    end
    add_index :custom_domains, :hostname, unique: true
  end
end
# app/models/custom_domain.rb
class CustomDomain < ApplicationRecord
  belongs_to :tenant

  HOSTNAME_FORMAT = /\A[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+\z/

  validates :hostname, presence: true, uniqueness: true,
    format: { with: HOSTNAME_FORMAT }, length: { maximum: 253 }

  before_validation { self.hostname = hostname.to_s.downcase.strip }

  scope :active, -> { where(status: "active") }
end

The unique index matters. Without it, two tenants can claim the same hostname and you will route one tenant's traffic to the other. That is a security bug, not a UX bug.

Step 2: resolve the tenant from the Host header

Rails 8 ships ActiveSupport::CurrentAttributes for exactly this. One resolver, one before_action, and every controller and view can ask Current.tenant.

# app/models/current.rb
class Current < ActiveSupport::CurrentAttributes
  attribute :tenant
end
# app/controllers/concerns/tenant_resolvable.rb
module TenantResolvable
  extend ActiveSupport::Concern

  included do
    before_action :resolve_tenant
  end

  private

  def resolve_tenant
    # Your edge proxies to the origin and preserves the customer hostname
    # in X-Forwarded-Host. request.host is the local-dev fallback.
    host = request.x_forwarded_host.presence || request.host

    if (domain = CustomDomain.active.find_by(hostname: host))
      Current.tenant = domain.tenant
    elsif (subdomain_tenant = Tenant.find_by(slug: request.subdomains.first))
      Current.tenant = subdomain_tenant
    else
      render plain: "Unknown domain", status: :not_found
    end
  end
end

Two details people miss. DNS is case-insensitive but your database is not, so lowercase the hostname on the way in (the before_validation in step 1) AND on lookup. And cache the resolver, because this query runs on every single request. Rails.cache.fetch("tenant:#{host}", expires_in: 5.minutes) keeps it off your database's hot path once you have real traffic.

Step 3: the config.hosts trap

This is the Rails-specific gotcha that breaks almost every first attempt. Since Rails 6, host authorization rejects any request whose Host header is not on an allowlist, as protection against DNS rebinding. Your app boots, your subdomains work, and the first real customer domain gets a 403 Blocked hosts page.

You cannot enumerate customer domains in an initializer, because customers add them at runtime. You have two sane options:

# config/environments/production.rb

# Option 1: allow your own domains statically, authorize customer
# domains dynamically against the database.
config.hosts = [".yourapp.com"]
config.host_authorization = {
  exclude: ->(request) { CustomDomain.active.exists?(hostname: request.host) }
}
# Option 2: if your edge already validates hostnames before proxying
# (Caddy's ask endpoint or a custom domains API both do), the origin
# never sees an unauthorized Host, so clear the allowlist.
config.hosts.clear

Option 1 is stricter and costs one cached query. Option 2 is fine when the origin is not directly reachable from the internet. Pick one deliberately, don't stumble into hosts.clear from a Stack Overflow answer without understanding what was protecting you.

Step 4: terminate TLS for customer hostnames

Everything above is plain Rails. The hard part of custom domains is that https://app.acme.com needs a valid certificate for app.acme.com, issued automatically when the customer connects it, renewed forever. (The mechanics, ACME challenges, SNI and wildcards, are covered in how SSL provisioning works for custom domains.)

You have three realistic routes.

Option A: run Caddy with on-demand TLS

Caddy will obtain a certificate on the first TLS handshake for a new hostname, at runtime, if you enable on_demand_tls. The critical piece is the ask endpoint: before issuing, Caddy calls your Rails app to confirm the hostname belongs to a real tenant, so strangers can't burn your rate limits by pointing junk domains at your IP.

{
  on_demand_tls {
    ask http://localhost:3000/internal/domain_check
  }
}

https:// {
  tls {
    on_demand
  }
  reverse_proxy localhost:3000 {
    header_up X-Forwarded-Host {host}
  }
}
# config/routes.rb
get "/internal/domain_check", to: "internal/domains#check"

# app/controllers/internal/domains_controller.rb
class Internal::DomainsController < ActionController::API
  def check
    if CustomDomain.where(status: %w[verifying active])
                   .exists?(hostname: params[:domain])
      head :ok
    else
      head :not_found
    end
  end
end

This works, and it is the cheapest option in server bills. What you sign up for is the operational side: Let's Encrypt allows 50 new certificates per registered domain per week, failed validations lock a hostname out for an hour after 5 attempts, and you now own monitoring, renewal failures, and the 3am page when a certificate expires. Our debugging runbook for customer domains exists because of this route.

Option B: let your platform do it

If your Rails app lives on a PaaS, the platform can issue per-hostname certificates.

On Heroku, Automated Certificate Management is free on all apps. You run heroku domains:add app.acme.com, the customer points a CNAME at the returned herokudns.com target, and a Let's Encrypt certificate appears in 45 to 60 minutes. Wildcards are mostly out (only Fir-generation Private Spaces support them), and your certificate lifecycle is only as observable as heroku certs output.

On Fly.io it's fly certs add app.acme.com per hostname, with the same Let's Encrypt rate limits as DIY (50 per registered domain per week). Wildcards need a manual DNS-01 challenge.

Platform certs are genuinely fine at small scale. The limits are per-platform quotas, issuance latency you don't control, and lock-in: your domain onboarding flow is now made of heroku CLI calls or platform API calls that don't move with you.

Option C: use a custom domains API

Domainee is a custom domains API for SaaS with a native MCP server, 50 domains and 100 GB free. I build it, so weigh that as you read this section; the pricing math is public and you can check it yourself.

The model: your customer points a CNAME at the Domainee edge, the edge handles validation, issuance, renewal and serving, and proxies to your Rails origin with X-Forwarded-Host set. Your app makes two API calls:

# app/services/domainee.rb
class Domainee
  BASE = URI("https://api.domainee.dev/v1")

  def self.create_domain(hostname)
    request(Net::HTTP::Post.new(BASE + "/domains"), { hostname: hostname })
  end

  def self.check_domain(id)
    request(Net::HTTP::Post.new(BASE + "/domains/#{id}/check"))
  end

  def self.request(req, body = nil)
    req["Authorization"] = "Bearer #{Rails.application.credentials.domainee_api_key}"
    req["Content-Type"] = "application/json"
    req.body = body.to_json if body
    Net::HTTP.start(BASE.host, BASE.port, use_ssl: true) do |http|
      JSON.parse(http.request(req).body, symbolize_names: true)
    end
  end
end

Create the domain when the customer submits the form, show them the CNAME target from the response, and subscribe to a webhook so status: active flips your CustomDomain row the moment DNS and TLS are live:

# app/controllers/webhooks/domainee_controller.rb
class Webhooks::DomaineeController < ActionController::API
  def create
    event = JSON.parse(request.raw_post, symbolize_names: true)
    if event[:type] == "domain.active"
      CustomDomain.find_by(hostname: event.dig(:data, :hostname))
                  &.update(status: "active")
    end
    head :ok
  end
end

No certificate code in your repo, no rate-limit arithmetic, and the origin can stay on Heroku, Fly, or a VPS unchanged. The tradeoff is a vendor in your request path, which is the same tradeoff as Option B with a different vendor.

Which one should you pick?

DIY CaddyHeroku ACMFly.io certsDomainee
Setup effortHigh (server + ask endpoint + monitoring)Low, if you're on HerokuLow, if you're on FlyLow (CNAME + 2 API calls)
IssuanceSeconds, on first handshake45 to 60 minMinutesMinutes, webhook on completion
Rate limits you manageLet's Encrypt, 50/weekHeroku's problemLet's Encrypt, 50/weekDomainee's problem
Wildcard supportYes, via DNS-01Mostly noManual DNS-01Yes
Apex domainsYes, you run the IPsVia ALIAS/ANAMEYes, dedicated IPsYes, CNAME flattening or ALIAS
Cost at 100 domainsServer + your ops time$0 on top of dynos$0 on top of apps$10/mo (50 free, then $0.20)
Portable across hostsYesNoNoYes

Honest summary: under 10 domains on a PaaS, use the platform, it's free. Fleet growing past 50, or onboarding spikes that can hit 50 new domains in a week, and the DIY rate-limit math plus monitoring starts costing real engineering time, which is the point of the API route.

Step 5: the onboarding UX

Whatever edge you chose, the customer-facing flow is the same:

  1. Customer types app.acme.com into your settings page.
  2. You create the CustomDomain row (and the API call, if using one) and show exactly one instruction: "Add a CNAME record pointing app.acme.com to edge.yourapp.com".
  3. Poll or receive a webhook until DNS resolves and the certificate is live, then flip the status badge to Active.

Do not make the customer click "verify" and interpret DNS errors themselves. Check it for them, on a loop, and show plain-language status. A CNAME lookup on your side answers "has their DNS change landed" before you ever attempt issuance, and propagation can lag by minutes to hours depending on the old record's TTL.

FAQ

Can a single Rails app serve multiple custom domains?

Yes. Rails doesn't care how many hostnames point at it; one app can serve thousands. The work is in resolving the tenant per request (step 2), authorizing hosts (step 3), and terminating TLS per hostname (step 4).

Why does Rails return 403 Blocked hosts for my customer's domain?

Rails 6+ host authorization rejects any Host header not on config.hosts. Customer domains are added at runtime, so either authorize them dynamically with config.host_authorization[:exclude] backed by your database, or clear the allowlist when your edge already validates hostnames before proxying.

Do I need a wildcard certificate for a multi-tenant Rails app?

Only for your own preview subdomains (*.yourapp.com). Customer domains can't share a wildcard certificate because each one is on a different registered domain; every customer hostname needs its own certificate.

How do customer domains work if my Rails app is on Heroku?

Two ways: heroku domains:add per hostname with Heroku's free ACM issuing the certificate in about an hour, or put a custom domains edge in front and keep Heroku as a plain origin. The second keeps onboarding portable if you later leave Heroku.

How much does it cost to offer custom domains in a Rails SaaS?

On a PaaS, platform certificates are free until their limits bite. DIY costs a small server plus your operations time. Domainee is free for 50 domains and 100 GB bandwidth, then $0.20 per domain per month; at 100 customer domains that is $10/mo. Run your own numbers in the cost calculator.