Custom domains for a multi-tenant Rails app
Resolve the tenant from request.host in Rails 8, keep host authorization from blocking customer domains, mint the DNS records with DoDomain over REST, verify the signed webhook in Ruby, and decide where TLS terminates on Puma behind Kamal or nginx.
A multi-tenant Rails app gets three separate problems the day a customer asks for app.acme.com instead of acme.yourproduct.com: the app has to know which tenant a hostname belongs to, the customer's DNS has to point that hostname at you and stay pointed, and something has to hold a certificate for a domain you do not own. Rails adds a fourth of its own — host authorization, which will happily answer 403 Blocked hosts to every customer domain you forgot to allow. This page covers all four in the order they bite, and ends with the two answers that are not obvious: where TLS terminates on a Rails stack, and what to say when a customer wants the bare acme.com.
The examples use Rails 8 and Ruby's standard library for HTTP and HMAC — there is no Ruby SDK, and none is needed: the REST API is four calls. Nothing here depends on a particular database.
1. Resolve the tenant from request.host
Rails already normalises the hostname for you: request.host reads X-Forwarded-Host when a proxy sets it and falls back to Host, with the port stripped. Put the lookup in a before_action on ApplicationController and publish the result through ActiveSupport::CurrentAttributes, so models and jobs never touch the request:
class Current < ActiveSupport::CurrentAttributes
attribute :tenant
endmodule ResolveTenantFromHost
extend ActiveSupport::Concern
PRIMARY_HOST = "yourproduct.com"
included do
before_action :set_current_tenant
end
private
def set_current_tenant
host = request.host.downcase
return if host == PRIMARY_HOST || host == "www.#{PRIMARY_HOST}"
# A tenant on your own subdomain (acme.yourproduct.com) or on a custom
# domain (app.acme.com). Both come from the same table — one row per
# hostname a tenant is allowed to serve — and only ACTIVE rows count.
Current.tenant = Tenant.find_by_active_hostname(host)
head :not_found unless Current.tenant
end
endclass ApplicationController < ActionController::Base
include ResolveTenantFromHost
endTenant.find_by_active_hostname is a query against a table you own. The rest of this guide is about how rows get into that table honestly: not when a customer types a domain into a form, but when DNS actually resolves to you.
Do not trust the form
A customer typing app.acme.com into your settings page proves nothing. Store the domain as
pending, and only mark it active — only let set_current_tenant serve it — when a
connection.verified webhook says the records are live. Otherwise a typo in a competitor's
hostname routes their traffic to a 404 on your infrastructure.
The Rails-specific trap: host authorization
ActionDispatch::HostAuthorization is what stops DNS-rebinding attacks, and it is configured with config.hosts. In production that list is empty by default, which means no validation at all — so a fresh app serves customer domains fine. The trap is the day someone hardens the app with config.hosts << "yourproduct.com": from that deploy on, every custom domain answers 403 Blocked hosts, and nothing in your test suite notices because tests never send a customer's hostname.
You cannot express "any hostname in my tenants table" as a static entry, so exclude those requests from the check instead — with the same lookup set_current_tenant uses:
config.hosts << "yourproduct.com"
config.hosts << /.*\.yourproduct\.com/
# Customer domains are not knowable at boot. A request whose host is an ACTIVE
# custom domain skips host authorization; everything else still gets the 403.
config.host_authorization = {
exclude: ->(request) { Tenant.active_custom_domain?(request.host) },
}The exclusion runs before your controllers, so keep the lookup cheap (a cached set of active hostnames is the usual answer) and make sure it only ever says yes for active rows — a pending domain excluded here would be reachable before DNS proves anything.
2. Mint the records with a connect session
Your server mints one session per domain, describing the records your product needs. For a subdomain that is a single CNAME to your ingress hostname; add a TXT if you want an ownership proof you can check yourself. The call is POST /api/v1/sessions with your app's secret key:
require "json"
require "net/http"
class DodomainClient
BASE_URL = "https://app.dodomain.io"
def initialize(secret_key: ENV.fetch("DODOMAIN_SECRET_KEY"))
@secret_key = secret_key
end
# Returns the parsed session: id, token, connectUrl, expiresAt, records[].
def create_session(domain:, records:, return_url:)
uri = URI("#{BASE_URL}/api/v1/sessions")
request = Net::HTTP::Post.new(uri)
request["Authorization"] = "Bearer #{@secret_key}"
request["Content-Type"] = "application/json"
request.body = JSON.generate(domain: domain, records: records, returnUrl: return_url)
response = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |http| http.request(request) }
body = JSON.parse(response.body)
# Every non-2xx answer is `{ "error": { "code", "message" } }` — the code is
# the stable part (quota_exceeded on a 402, invalid_request on a 400, …).
raise "DoDomain #{response.code}: #{body.dig("error", "code")}" unless response.is_a?(Net::HTTPSuccess)
body
end
endclass Settings::DomainsController < ApplicationController
def create
domain = params.require(:domain).downcase
session = DodomainClient.new.create_session(
domain: domain,
records: [{ type: "CNAME", host: "@", value: "cname.yourproduct.com" }],
return_url: "https://#{Current.tenant.slug}.yourproduct.com/settings/domains",
)
# pending until the webhook says otherwise
Current.tenant.custom_domains.create!(hostname: domain, dodomain_session_id: session["id"])
redirect_to session["connectUrl"], allow_other_host: true
end
endhost: "@" means "the domain itself" — the domain you passed is app.acme.com, so the CNAME lands on app.acme.com, not on acme.com. Read records[].fqdn in the response if you want to confirm what will be verified. Send the customer to connectUrl (the redirect_to above needs allow_other_host: true, because Rails 7+ refuses cross-host redirects by default). On Cloudflare-hosted zones the record is written with one OAuth consent; on a provider that has enabled Domain Connect for DoDomain it is one click; everywhere else the hosted page shows that provider's exact panel steps and verifies each record live. You do not branch on any of that.
3. Activate the tenant from the webhook, not the redirect
The customer coming back to returnUrl means they closed the flow, not that DNS is live. The signal you act on is the signed connection.verified event. Every delivery carries an x-dodomain-signature header of the form t=<unix ms>,v1=<hex>, where v1 is the HMAC-SHA256 of t + "." + rawBody keyed with the endpoint's whsec_... secret (the full scheme). The verifier is twenty lines of standard library:
require "openssl"
module DodomainWebhook
SIGNATURE_HEADER = "x-dodomain-signature"
DEFAULT_TOLERANCE_MS = 5 * 60 * 1000
# Verifies the exact bytes DoDomain sent. Never pass a re-serialized hash —
# the signature covers the raw body.
def self.valid?(secret, raw_body, header, tolerance_ms: DEFAULT_TOLERANCE_MS, now_ms: nil)
parts = header.to_s.split(",").to_h { |kv| kv.split("=", 2).map(&:strip) }
t = parts["t"].to_i
now_ms ||= (Process.clock_gettime(Process::CLOCK_REALTIME) * 1000).to_i
return false if t <= 0 || (now_ms - t).abs > tolerance_ms
got = parts["v1"].to_s
# A malformed signature is a failed verification, not an exception:
# shape-gate before the constant-time compare so it can never raise.
return false unless got.match?(/\A[0-9a-f]{64}\z/)
expected = OpenSSL::HMAC.hexdigest("SHA256", secret, "#{t}.#{raw_body}")
OpenSSL.fixed_length_secure_compare(got, expected)
rescue ArgumentError
false
end
endclass Webhooks::DodomainController < ActionController::API
def create
raw_body = request.raw_post
signature = request.headers["x-dodomain-signature"].to_s
return head :unauthorized unless DodomainWebhook.valid?(ENV.fetch("DODOMAIN_WEBHOOK_SECRET"), raw_body, signature)
event = JSON.parse(raw_body)
# Dedupe on event["id"] — retries reuse it. Store it before acting.
return head :no_content if WebhookReceipt.exists?(event_id: event["id"])
WebhookReceipt.create!(event_id: event["id"])
domain = event.dig("data", "domain")
case event["type"]
when "connection.verified"
# First verification, or recovery after a drift (data.recovered == true).
CustomDomain.activate!(domain)
when "connection.failed"
# scope "connection": a verified domain drifted and stayed wrong for three
# consecutive checks. scope "session": an attempt failed mid-flow — the
# row is still pending, there is nothing to deactivate.
CustomDomain.deactivate!(domain) if event.dig("data", "scope") == "connection"
when "connection.disconnected"
CustomDomain.deactivate!(domain)
end
head :no_content
end
endTwo Rails details: the controller inherits from ActionController::API (no CSRF token on an inbound webhook — if you must inherit from ActionController::Base, call skip_forgery_protection), and it reads request.raw_post, never params, because the signature covers the bytes on the wire. Route it with post "webhooks/dodomain", to: "webhooks/dodomain#create" and register the URL as an endpoint in the dashboard, which gives you the whsec_... secret. The webhooks page has the full event table and the retry schedule.
CustomDomain.activate! is what flips the row set_current_tenant reads. Once active, the domain stays watched: DoDomain re-checks every connection's records against authoritative DNS on every plan, at no charge — every 10 minutes for the first day, hourly for the first week, every 6 hours after that. A customer who deletes the CNAME months later produces a connection.failed, which your handler turns into a deactivated row instead of a tenant silently serving a 404 on their own domain. Details in Automatic DNS drift monitoring.
4. Where TLS terminates on a Rails stack
This is the part multi-tenant guides gloss over. DoDomain is a DNS layer and is never in your request path, so it holds no certificate for app.acme.com — the certificate is issued wherever your Rails app terminates TLS, and connection.verified is the moment issuance can succeed, because DNS finally resolves to that host. Puma itself terminates nothing in a normal deployment; what sits in front of it does.
Kamal 2. The proxy Kamal deploys (kamal-proxy) issues Let's Encrypt certificates for the hosts listed under proxy.hosts in config/deploy.yml — a static list, fixed at deploy time, which cannot contain a domain a customer will add tomorrow. kamal-proxy itself does have the answer: its --tls-on-demand-url option asks an HTTP endpoint of your choice before issuing a certificate for a hostname it has never seen, and a 200 permits issuance. Point it at a tiny Rails route that answers 200 only for active custom domains — the same table set_current_tenant reads, populated by the webhook — and no certificate is ever minted for a hostname that has not verified. Kamal's own proxy.ssl config does not expose that flag as of Kamal 2's documented options, so either run kamal-proxy with it directly or put Caddy in front (next paragraph). The session's CNAME points at the Kamal host.
Puma behind nginx. nginx holds the certificates, and certbot (or acme.sh) obtains one per customer domain with an HTTP-01 challenge — which only succeeds once DNS points at the box. Trigger issuance from the webhook: connection.verified enqueues a job that runs certbot for that hostname and reloads nginx. Set proxy_set_header X-Forwarded-Host $host; (or forward Host unchanged) so request.host sees the customer's hostname, not your upstream name.
Puma behind Caddy. Caddy's on_demand_tls issues a certificate the first time it sees a new hostname, and its ask endpoint lets it check with you before doing so. Point ask at the same "is this domain active?" route as the Kamal option above. The session's CNAME points at the Caddy host.
Cloudflare for SaaS. Create a custom hostname for the domain and have the session's CNAME point at your zone's fallback origin. Cloudflare validates through DNS and issues the edge certificate; your Rails origin sees X-Forwarded-Host, which request.host already honours.
The sequencing rule that holds for every host — add the domain on the platform, mint the session pointing at it, treat connection.verified as "issuance can proceed" — and the Render and Fly.io notes are on the SSL page.
5. Apex survival: what to say to "can I use acme.com itself?"
DNS forbids a CNAME at a zone's apex, so acme.com → cname.yourproduct.com is not a record any provider will accept, and DoDomain cannot create the provider-specific ALIAS/ANAME/flattened substitute for your customer. Decide the policy before the first customer asks, and enforce it in your form, not in the connect flow:
- Ask for a subdomain (
app.acme.com,links.acme.com). OneCNAME, every provider, every one-click path, andset_current_tenantneeds no change.POST /api/v1/domains/checkreturns both thedomainand thezonethat owns its records; when the two are equal the customer gave you an apex, and that is the moment to refuse with a message that says what to do. - Serve the apex from
A/AAAArecords if — and only if — your ingress has stable, documented IPs (an anycast edge, a load balancer with reserved addresses). Request them at"@"onacme.com. Those addresses become a contract with every connected customer. wwwplus a redirect at the provider. The customer pointswww.acme.comat you with aCNAME(verified and monitored), and sets apex-to-wwwforwarding in their DNS panel. Be explicit that the redirect is theirs: DoDomain neither creates it nor watches it.
The reasoning behind each, the CNAME-flattening providers, and the warnings a session returns for an apex request are on the Apex domains page.
Checklist
set_current_tenantresolves the tenant fromrequest.hostand serves only active rows.config.host_authorizationexcludes active custom domains, so hardeningconfig.hostslater cannot 403 your customers.- Domains enter as pending; only
connection.verifiedactivates them;connection.failed(scopeconnection) andconnection.disconnecteddeactivate them. - The webhook controller verifies
request.raw_postwith the twenty-line verifier above and dedupes onid. - Certificates come from where TLS terminates (kamal-proxy on-demand TLS, nginx + certbot, Caddy, Cloudflare for SaaS) — never from DoDomain.
- The apex policy is decided in your form: subdomain by default,
A/AAAAonly with stable IPs,www+ provider redirect as the fallback.
See also
- The connect flow — what your customer sees on the hosted page, provider by provider.
- DNS provider setup guides — the same panel steps the hosted flow shows, one page per provider, for your own support docs.
- Free tools — the provider detector and the authoritative-vs-public DNS lookup, for debugging a customer's zone without a session.
- DoDomain with Cloudflare for SaaS — the seam between the DNS half and the TLS half, if that is your edge.
Custom domains in multi-tenant Next.js: proxy.ts to TLS
Resolve the tenant from the Host header in a Next.js 16 proxy.ts, mint and verify the customer's DNS records, terminate TLS on Vercel, and settle the apex.
Custom domains for a multi-tenant Laravel app
Resolve the tenant from the request host in Laravel 12 middleware, keep trusted-host and CSRF checks from breaking customer domains, mint the DNS records with DoDomain through the Http client, verify the signed webhook in PHP, and decide where TLS terminates on Forge, Vapor or Octane.