Framework guides

Ruby on Rails 8.1 custom domains guide

Ruby on Rails 8.1 · Ruby 4.0
Versions

Link to this edition

Example framework and runtime versions

Ruby
4.0.6
Rails
8.1.3.1

Your Rails app might host a storefront, publication, or public profile for each customer. With custom domains, customers can share those pages using an address they own. You keep serving the content from the same application.

This guide walks through adding custom domains to an existing Rails app, starting with a local example you can try straight away. We’ll connect each domain to a customer, cover the DNS and HTTPS setup that makes it work online, and remove a domain without leaving it connected to the wrong account.

Bring your existing customer accounts, login, and a page to display. You can use your app’s normal database and models; the examples show where the custom domain pieces fit.

In this guide

1. Match a hostname to a customer locally

Every HTTP request includes a Host header such as atlas.test. Rails exposes the normalized value as request.host, so your controller can use it to choose a customer page.

Start with two development-only names and small stand-ins for your existing customer records:

The examples call a customer’s account a Tenant. Use your existing account, workspace, or organization model wherever you see that name.

# app/controllers/public_pages_controller.rb
class PublicPagesController < ApplicationController
  LOCAL_TENANTS = {
    "atlas.test" => { name: "Atlas Press", headline: "Field notes" },
    "juniper.test" => { name: "Juniper Journal", headline: "Small stories" }
  }.freeze

  def show
    @tenant = LOCAL_TENANTS[request.host.downcase]
    return head :not_found unless @tenant
  end
end

# config/routes.rb (inside Rails.application.routes.draw)
get "/__domain_preview", to: "public_pages#show"

Create app/views/public_pages/show.html.erb. ERB escapes these values by default:

<h1><%= @tenant[:name] %></h1>
<p><%= @tenant[:headline] %></p>

The temporary preview route leaves your existing homepage in place. If ApplicationController requires login, exempt only this public show action using your app’s authentication setup.

Rails blocks unfamiliar hosts in development. Add only the three names used by this checkpoint:

# config/environments/development.rb
config.hosts += %w[atlas.test juniper.test unknown.test]
bin/rails server -b 127.0.0.1 -p 3000

# In another terminal:
curl -H 'Host: atlas.test' http://127.0.0.1:3000/__domain_preview
curl -H 'Host: juniper.test' http://127.0.0.1:3000/__domain_preview
curl -i -H 'Host: unknown.test' http://127.0.0.1:3000/__domain_preview

Never fall back to the first customer. A typo or unknown domain must not expose someone else’s page.

Checkpoint: the first two requests show different customers, and the unknown hostname returns HTTP 404. You do not need DNS, HTTPS certificates, or a provider account yet.

2. Save each customer’s domains

Replace the temporary hash with an app-owned model. Each normalized hostname belongs to one signed-in customer and has a small local status: pending, active, or removing.

Generate the migration, then keep the database-level unique indexes. SQLite is useful for this compact example; use your app’s normal database in production.

bin/rails generate model CustomerDomain \
  tenant:references hostname:string status:string reservation_key:string
# generated migration: edit the table before migrating
create_table :customer_domains do |t|
  t.references :tenant, null: false, foreign_key: true
  t.string :hostname, null: false
  t.string :status, null: false, default: "pending"
  t.string :reservation_key, null: false
  t.timestamps
end
add_index :customer_domains, :hostname, unique: true
add_index :customer_domains, :reservation_key, unique: true
add_check_constraint :customer_domains,
  "status IN ('pending', 'active', 'removing')"

# app/models/customer_domain.rb
class CustomerDomain < ApplicationRecord
  belongs_to :tenant
  enum :status, { pending: "pending", active: "active", removing: "removing" }

  before_validation(on: :create) { self.reservation_key ||= SecureRandom.uuid }
  before_validation :normalize_hostname
  validates :hostname, presence: true, uniqueness: true
  validates :reservation_key, presence: true, uniqueness: true

  private

  def normalize_hostname
    self.hostname = DomainName.normalize(hostname)
  rescue DomainName::Invalid => error
    errors.add(:hostname, error.message)
  end
end

Implement DomainName.normalize as one small service. It should trim whitespace and a final dot, convert internationalized names to ASCII, lowercase, and reject schemes, ports, paths, wildcards, IP addresses, local names, overlong labels, and invalid public suffixes. The companion shows a complete version using simpleidn and public_suffix.

Add has_many :customer_domains to your existing Tenant model, then run bin/rails db:migrate. Create records through current_tenant.customer_domains. The browser may submit a hostname or local row ID, but it must never choose the trusted tenant ID.

Replace the hash lookup with CustomerDomain.active.includes(:tenant).find_by(hostname: request.host.downcase), then assign its tenant to @tenant. Return 404 on a miss. Update the view to use your model’s fields, such as @tenant.name.

Seed the two .test rows as explicit active fixtures only in development, bypassing the public-domain form. New customer submissions stay pending.

Checkpoint: two signed-in customers can save different domains, the same normalized hostname cannot be claimed twice, and the controller now reads its customer from the database.

3. Get the domain to your app over HTTPS

DNS is the public address book for hostnames. Give the customer the record supplied by the service that receives traffic for your Rails app. That may be your hosting platform or a reverse proxy: a server that handles the public connection and passes the request to Rails.

An apex is the root domain, such as example.com; shop.example.com is a subdomain. Use a stable IPv4 address and an A record for an apex. Many DNS providers cannot put a normal CNAME there, so do not rely on CNAME flattening for onboarding. Use a CNAME for subdomains, and add AAAA only when your receiving service offers stable IPv6.

  1. Register the customer hostname with the server or hosting service that receives public traffic.
  2. Show the exact A, AAAA, or CNAME value that service gives you. Do not copy an example IP into production.
  3. Have the customer add the record at their DNS provider.
  4. Wait until the receiver confirms both routing and a valid HTTPS certificate for that hostname.
  5. Mark your row active when DNS reaches the receiver and HTTPS is ready.

DNS gets the request to the receiving server; the HTTP Host selects the customer. HTTPS happens first, before Rails sees the request, so the receiver must obtain and renew the certificate and pass along the original Host value.

curl -I https://customer.example

You’ll probably want to check that DNS points directly or indirectly to the service receiving traffic for your app. Tools and managed services can make DNS setup and HTTPS much easier; see the bonus section below for one option.

Checkpoint: the customer domain reaches your receiver over HTTPS, preserves its Host value, and has moved from pending to active in your app.

4. Serve the customer’s page

Set APP_HOST=app.example to your normal app hostname. Replace the preview route with the routes below, keeping all existing account and management routes inside the hostname constraint. The controller looks up an actual stored active domain and returns 404 on a miss:

# config/routes.rb
Rails.application.routes.draw do
  constraints ->(request) { request.host == ENV.fetch("APP_HOST") } do
    root "dashboard#show", as: :app_root
    resources :customer_domains, only: %i[create destroy]
  end
  root "public_pages#show"
end

# app/controllers/public_pages_controller.rb
class PublicPagesController < ApplicationController
  def show
    response.set_header("Cache-Control", "private, no-store")
    response.set_header("Vary", "Host")
    domain = CustomerDomain.active.includes(:tenant)
      .find_by!(hostname: request.host.downcase)
    @tenant = domain.tenant
  rescue ActiveRecord::RecordNotFound
    head :not_found
  end
end

Rails Host Authorization must let customer hostnames reach this controller. Configure your production receiver and config.hosts together: accept only syntactically valid hostnames at the Rails boundary, reject direct access that bypasses your known receiver, and let the database lookup return 404 for valid but unknown names.

# config/environments/production.rb
domain_host = /(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+
  [a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?/x
config.hosts << ENV.fetch("APP_HOST")
config.hosts << domain_host

Rails anchors Host Authorization regex entries. The expression above admits well-formed ASCII DNS names, including punycode; your normalized database lookup remains the allow-list for customer content.

Keep login, billing, and domain management on APP_HOST. Scope every mutation through current_tenant.customer_domains; a public Host match selects content and never authorizes an action.

Rails can prefer X-Forwarded-Host when building request.host. Configure your receiver to remove that header or overwrite it with the verified incoming Host, and block direct app-server access. Keep Rails session cookies host-only, and use configured default_url_options for management links.

Rails enables CSRF tokens for browser forms. Keep protect_from_forgery enabled, set config.action_controller.forgery_protection_origin_check = true, and do not skip either check on domain mutations. If you add a separate JSON API, apply its normal token authentication and origin policy instead of relying on a public Host match.

Checkpoint: active customer domains render the right page; unknown, pending, and removing domains return HTTP 404; management remains on the normal app hostname.

5. Remove a domain safely

Removal has two phases. First change the local row to removing, which makes the active scope return 404 immediately. Keep the unique hostname reserved while your hosting layer removes its route and certificate.

The receiver below is the boundary to your hosting platform. The final query checks the tenant, hostname, random reservation key, and removing state in one database write:

def destroy
  domain = current_tenant.customer_domains.find(params[:id])
  CustomerDomain.transaction do
    claimed = current_tenant.customer_domains.where(
      id: domain.id, hostname: domain.hostname,
      reservation_key: domain.reservation_key
    ).lock.first!
    claimed.update!(status: "removing")
  end

  DomainReceiver.detach(domain) # retry the same reservation
  if DomainReceiver.detached?(domain)
    current_tenant.customer_domains.where(
      id: domain.id,
      hostname: domain.hostname,
      reservation_key: domain.reservation_key,
      status: "removing"
    ).delete_all
  end
  redirect_to app_root_path, status: :see_other
end

Implement DomainReceiver with your hosting service’s API or operations workflow. It must target the original route and treat repeated removal as the same operation. A timeout is not confirmation, so leave the row in removing and retry.

A numeric row ID can be reused after deletion. The random reservation key means a delayed response cannot release or reactivate a newer claim that happens to receive the same ID.

Checkpoint: removal stops public routing at once, retries safely after a temporary failure, and releases the hostname only after the receiver confirms it is detached.

Optional bonus: let Approximated handle DNS onboarding, HTTPS, and forwarding

The five steps above work with any hosting setup. If you do not want to build the hostname receiver and certificate automation yourself, Approximated can accept customer hostnames, check their connection, return DNS instructions, issue and renew certificates, and forward requests to your Rails app.

Your app still owns customer authentication, the hostname reservation, page content, and the decision to activate. Here, a virtual host is the API record that maps one customer domain to your reachable Rails server.

Create the mapping from Rails

Add the faraday gem to your Gemfile and run bundle install. Keep the API key in server-only credentials or an environment variable. Add an optional provider_id to your domain row, then call POST /api/vhosts after reserving the normalized hostname:

connection = Faraday.new(url: "https://cloud.approximated.app") do |faraday|
  faraday.request :json
  faraday.options.open_timeout = 5
  faraday.options.timeout = 5
end

response = connection.post("/api/vhosts") do |request|
  request.headers["api-key"] = ENV.fetch("APPROXIMATED_API_KEY")
  request.headers["Accept"] = "application/json"
  request.body = {
    incoming_address: domain.hostname,
    target_address: ENV.fetch("APP_ORIGIN_HOST"),
    target_ports: "443",
    keep_host: true
  }
end
raise "Create failed: #{response.status}" unless response.success?

data = JSON.parse(response.body).fetch("data")
unless data.is_a?(Hash) && data["id"].is_a?(Integer) &&
       data["id"].positive? && data["incoming_address"].is_a?(String) &&
       DomainName.normalize(data["incoming_address"]) == domain.hostname
  raise "Unexpected virtual-host identity"
end
domain.update_provider_id_if_still_pending!(data["id"])

APP_ORIGIN_HOST must be a hostname that reaches your Rails server. keep_host: true preserves the customer Host for step 5. The final method must conditionally require the same tenant, hostname, reservation key, and pending row.

If creation times out, conflicts, or returns malformed data, keep the hostname reserved and unavailable. Never look up by hostname and adopt or delete an uncertain remote record.

Show DNS guidance and connection status

Create and get return {"data": virtual_host}. Render user_message as ordinary escaped text for the customer. Status strings are optional; the three monitoring fields may be absent or nil, which means unknown. Reject any present field with the wrong type:

  • is_resolving means an HTTP response was observed, even if it came from somewhere else.
  • apx_hit means the request reached Approximated.
  • has_ssl means HTTPS is ready.
response = connection.get("/api/vhosts/#{Integer(domain.provider_id)}") do |request|
  request.headers["api-key"] = ENV.fetch("APPROXIMATED_API_KEY")
  request.headers["Accept"] = "application/json"
end
raise "Refresh failed: #{response.status}" unless response.success?

data = JSON.parse(response.body).fetch("data")
unless data.is_a?(Hash) && data["id"] == domain.provider_id &&
       data["incoming_address"].is_a?(String) &&
       DomainName.normalize(data["incoming_address"]) == domain.hostname
  raise "Unexpected virtual-host identity"
end
domain.apply_provider_status_if_current!(data)

apply_provider_status_if_current! should accept only string-or-nil status messages and boolean-or-nil monitoring fields, then update the same tenant, hostname, reservation key, provider ID, and active local state. Render user_message as escaped text.

Refresh with GET /api/vhosts/{id} using the stored ID, then compare the returned ID and normalized hostname before saving. This GET reads the latest stored result from Approximated’s automatic checks; it does not start a fresh check. Show user_message when the customer needs to change DNS.

Route a real domain only when apx_hit == true and has_ssl == true. Use is_resolving as a separate diagnostic because an HTTP response may have come from somewhere other than Approximated.

Remove by the confirmed ID

Disable local routing first, then send DELETE /api/vhosts/{id}. A successful delete returns HTTP 200 with plain text, but removal still takes time. Keep the reservation and retry the same stored ID after a timeout or error.

Release the hostname only after GET /api/vhosts/{id} returns 404. A delayed status response must not reactivate a removing row. Bind every update or release to the original tenant, hostname, reservation key, provider ID, and local state.

See the complete Rails example

The snippets above are intentionally small adaptation points. The companion repository contains the full Rails integration, including two SQLite tenants, session-scoped controllers, Host routing, a Faraday client, lifecycle guards, and local tests.

git clone https://github.com/Approximated-Inc/rails-custom-domains-example.git
cd rails-custom-domains-example
bundle install
bin/rails db:prepare
bin/rails server -b 127.0.0.1 -p 3086

Useful references