# Custom domains for a multi-tenant Rails app (https://dodomain.io/docs/guides/custom-domains-multi-tenant-rails)

Custom domains in multi-tenant Rails 8: resolve the tenant from request.host, allow customer hosts, mint DNS records, verify webhooks, place TLS.

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](https://dodomain.io/docs/api) is four calls. Nothing here depends on a particular database.

## 1. Resolve the tenant from `request.host` [#1-resolve-the-tenant-from-requesthost]

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:

```ruby title="app/models/current.rb"
class Current < ActiveSupport::CurrentAttributes
  attribute :tenant
end
```

```ruby title="app/controllers/concerns/resolve_tenant_from_host.rb"
module 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
end
```

```ruby title="app/controllers/application_controller.rb"
class ApplicationController < ActionController::Base
  include ResolveTenantFromHost
end
```

`Tenant.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.

<Callout title="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.
</Callout>

### The Rails-specific trap: host authorization [#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:

```ruby title="config/environments/production.rb"
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 [#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:

```ruby title="app/services/dodomain_client.rb"
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
end
```

```ruby title="app/controllers/settings/domains_controller.rb"
class 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
end
```

`host: "@"` 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 [#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](https://dodomain.io/docs/webhooks#verifying-signatures)). The verifier is twenty lines of standard library:

```ruby title="app/lib/dodomain_webhook.rb"
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
end
```

```ruby title="app/controllers/webhooks/dodomain_controller.rb"
class 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
end
```

Two 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 with the `add_webhook_endpoint` tool or `POST /api/v1/webhook-endpoints`, which returns the `whsec_...` secret. The [webhooks page](https://dodomain.io/docs/webhooks) 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](https://dodomain.io/docs/webhooks#automatic-dns-drift-monitoring).

## 4. Where TLS terminates on a Rails stack [#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](https://dodomain.io/docs/ssl).

## 5. Apex survival: what to say to "can I use acme.com itself?" [#5-apex-survival-what-to-say-to-can-i-use-acmecom-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:

1. **Ask for a subdomain** (`app.acme.com`, `links.acme.com`). One `CNAME`, every provider, every one-click path, and `set_current_tenant` needs no change. `POST /api/v1/domains/check` returns both the `domain` and the `zone` that 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.
2. **Serve the apex from `A`/`AAAA` records** if, and only if, your ingress has stable, documented IPs (an anycast edge, a load balancer with reserved addresses). Request them at `"@"` on `acme.com`. Those addresses become a contract with every connected customer.
3. **`www` plus a redirect at the provider.** The customer points `www.acme.com` at you with a `CNAME` (verified and monitored), and sets apex-to-`www` forwarding 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](https://dodomain.io/docs/apex-domains) page.

## Checklist [#checklist]

- `set_current_tenant` resolves the tenant from `request.host` and serves only **active** rows.
- `config.host_authorization` excludes active custom domains, so hardening `config.hosts` later cannot 403 your customers.
- Domains enter as **pending**; only `connection.verified` activates them; `connection.failed` (scope `connection`) and `connection.disconnected` deactivate them.
- The webhook controller verifies `request.raw_post` with the twenty-line verifier above and dedupes on `id`.
- 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`/`AAAA` only with stable IPs, `www` + provider redirect as the fallback.

## See also [#see-also]

- [The connect flow](https://dodomain.io/docs/connect-flow): what your customer sees on the hosted page, provider by provider.
- [DNS provider setup guides](https://dodomain.io/docs/guides/dns-providers): the same panel steps the hosted flow shows, one page per provider, for your own support docs.
- [Free tools](https://dodomain.io/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](https://dodomain.io/compare/cloudflare-for-saas): the seam between the DNS half and the TLS half, if that is your edge.
