Adding custom domains to a Rails app has one gotcha that stops most people cold: Rails rejects hosts it does not recognise. Get past config.hosts and the rest — tenant routing, real client IP, SSL — is straightforward. Here is the complete DIY path and the shortcut.
# config/application.rb — allow verified customer domains dynamically
config.hosts << /.*/ unless Rails.env.production? # dev only; do NOT do this in prod
# Production: permit only hosts you have verified
config.hosts << ->(host) { Tenant.exists?(domain: host) }
config.host_authorization = { exclude: ->(req) { req.path == '/up' } } # health checkStep 1 — Point DNS at your app
The customer aims a record at infrastructure you control. Prefer a CNAME for subdomains:
app.customer.com. CNAME proxy.yourapp.com.Bare domains hit the CNAME-at-apex problem; use an A record or ALIAS. Confirm with the DNS propagation checker.
Step 2 — The config.hosts gotcha
Since Rails 6, ActionDispatch::HostAuthorization guards against DNS-rebinding by rejecting any host not in config.hosts. A brand-new customer domain will get a “Blocked host” response until you permit it. The right fix is a matcher that checks your verified tenants — not clearing the list, which would disable the protection entirely:
config.hosts << ->(host) { Rails.cache.fetch("host:#{host}", expires_in: 5.minutes) { Tenant.exists?(domain: host) } }Cache the lookup — it runs on every request.
Step 3 — Resolve the tenant
# app/controllers/concerns/tenant_scoped.rb
module TenantScoped
extend ActiveSupport::Concern
included { before_action :set_current_tenant }
private
def set_current_tenant
return if request.host.end_with?('yourapp.com')
Current.tenant = Tenant.find_by(domain: request.host)
head :not_found unless Current.tenant # fail closed
end
endStep 4 — Real client IP and SSL
Behind a proxy, tell Rails which hops to skip so request.remote_ip is the visitor, not your edge:
config.action_dispatch.trusted_proxies =
ENV['EDGE_CIDRS'].split(',').map { |p| IPAddr.new(p) }Rails does not terminate TLS. Put Caddy/Nginx in front with ACME (see the trade-offs in cnames.dev vs self-hosting Caddy on-demand TLS), or use a custom-domains service that issues certificates for you.
The one-API-call way
curl -X POST https://api.cnames.dev/v1/domains \
-H "Authorization: Bearer sk_live_..." \
-d '{"hostname":"app.customer.com","origin":"your-rails-app.internal:3000"}'cnames.dev handles SSL, renewals, and forwarding; your Rails app keeps its config.hosts matcher and tenant lookup. It is a natural fit for course and community platforms. Full architecture in the complete guide.