Framework guides
Ruby on Rails 8.1 custom domains guide
Versions
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.
- Register the customer hostname with the server or hosting service that receives public traffic.
- Show the exact A, AAAA, or CNAME value that service gives you. Do not copy an example IP into production.
- Have the customer add the record at their DNS provider.
- Wait until the receiver confirms both routing and a valid HTTPS certificate for that hostname.
- Mark your row
activewhen 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_resolvingmeans an HTTP response was observed, even if it came from somewhere else.apx_hitmeans the request reached Approximated.has_sslmeans 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