Engineering

How to Add Custom Domains to a Ruby on Rails App

Drago Crnjac
Drago Crnjac
CEO & Founder
Cover Image for How to Add Custom Domains to a Ruby on Rails App

To add custom domains to a Ruby on Rails app, your customer points a CNAME (or A records for a root domain) at your servers, you store the domain against their account, and a controller looks up the tenant from the request's hostname. Then you need an HTTPS certificate for every one of those domains, which is where most of the work is.

The Rails part is small: a model, a route constraint and a before_action. This guide walks through it step by step for Rails 7.1 and 8, and covers the things that trip up Rails apps in production: "Blocked host" errors, forms that fail the CSRF check, and redirects that send visitors back to your own domain. There's an example GitHub repo you can use to kickstart your own implementation.

In this guide:

How custom domains reach your Rails app

The first thing you may ask yourself is how requests from custom domains even end up hitting your Rails app. The answer is DNS: your customer adds a record that points their domain at your servers. Which record depends on whether it's a subdomain or a root domain.

Let's use an example. Your Rails app is a status page product running on app.statuspage.com, on a server with the IP 192.0.2.1. Intercom is a customer, and wants its status page on its own domain.

Subdomains: a CNAME record

A CNAME maps one domain name to another, like an alias. Intercom creates a CNAME at status.intercom.com pointing to app.statuspage.com. When someone visits status.intercom.com, DNS follows the alias, finds your server's IP, and the request lands on your Rails app.

Example showing how status.intercom.com points to app.statuspage.com

This is the option you want customers to use. If your servers move, you update app.statuspage.com once and every customer's domain follows.

Root domains: A records

Now imagine Intercom wants the status page on a root domain, intercomstatus.com. DNS doesn't allow a CNAME at the root of a domain, so Intercom has to create an A record that points intercomstatus.com straight at your IP, 192.0.2.1.

An example image showing intercomstatus.com pointing directly to an IP address.

Some DNS providers offer ALIAS records or CNAME flattening, which act like a CNAME at the root. Plenty don't, so you need the A record option either way.

IMPORTANT: If you hand out an IP address, it must never change, ever! If it does, every A record your customers created now points at an IP that isn't yours. You'll have to reach out to all of them and ask them to update their DNS. Get ready for a sleepless night. Use a static IP (an Elastic IP on AWS, a reserved IP elsewhere), or better, a pair of anycast IPs in front of a load balancer.

Here are the records your customers add, in zone-file form:

; Subdomain
status.intercom.com.   CNAME  app.statuspage.com.

; Root domain
intercomstatus.com.    A      192.0.2.1

In practice, use a dedicated CNAME target such as in.statuspage.com instead of your app's own hostname. Then you can move custom domain traffic onto separate infrastructure later without touching your main app.

Store your customers' domains

Your app needs to know which domain belongs to which customer. A CustomDomain model keeps that mapping, with a status so you only serve domains that are actually pointed at you. In this example, a Company is the tenant:

db/migrate/20261023000000_create_custom_domains.rb
class CreateCustomDomains < ActiveRecord::Migration[8.0]
  def change
    create_table :custom_domains do |t|
      t.references :company, null: false, foreign_key: true
      t.string :host, null: false
      t.string :status, null: false, default: "pending"
      t.timestamps
    end

    add_index :custom_domains, :host, unique: true
  end
end

The unique index matters. Without it, two companies can claim the same domain, and whichever row the database returns first gets the other one's traffic.

app/models/custom_domain.rb
class CustomDomain < ApplicationRecord
  belongs_to :company

  enum :status, { pending: "pending", active: "active" }

  normalizes :host, with: ->(host) { host.strip.downcase.chomp(".") }

  validates :host, presence: true, uniqueness: true,
    format: { with: /\A[a-z0-9-]+(\.[a-z0-9-]+)+\z/ }
  validate :not_one_of_our_domains

  def self.host_from(request)
    request.host.downcase
  end

  private

  def not_one_of_our_domains
    ours = ENV.fetch("APP_DOMAIN") # "statuspage.com"
    if host == ours || host.to_s.end_with?(".#{ours}")
      errors.add(:host, "can't be one of our own domains")
    end
  end
end

A few notes:

  • Normalize before you save. Status.Intercom.com. and status.intercom.com are the same domain. normalizes (Rails 7.1+) lowercases it and strips the trailing dot, so lookups and the unique index both work. If you accept international domain names, convert them to punycode first; the simpleidn gem does this.
  • Block your own domains. Otherwise a customer can claim app.statuspage.com or admin.statuspage.com.
  • host_from is the one place that reads the hostname. The route constraint and controller below both call it. If you put a proxy in front of your app later, this is the only method you change.

Check that the DNS points at you

A domain should stay pending until its DNS actually points at you. Ruby's standard library can check that with Resolv:

app/models/custom_domain.rb
require "resolv"

class CustomDomain < ApplicationRecord
  CNAME_TARGET = "in.statuspage.com"
  OUR_IPS = %w[192.0.2.1].freeze

  def dns_points_to_us?
    Resolv::DNS.open do |dns|
      cnames = dns.getresources(host, Resolv::DNS::Resource::IN::CNAME)
      ips = dns.getaddresses(host).map(&:to_s)

      cnames.any? { |r| r.name.to_s.casecmp?(CNAME_TARGET) } ||
        ips.intersect?(OUR_IPS)
    end
  end
end

Run it from a background job every few minutes after a domain is added, and call active! once it passes. Re-check active domains every day too. Customers change DNS and forget to tell you.

Route custom domain requests to the right tenant

Every request to your app includes a Host header, which tells you which domain the visitor typed. When someone opens status.intercom.com, request.host is "status.intercom.com". All you have to do is look it up.

Start with a route constraint, so requests on custom domains go to the customer-facing pages and requests on your own domain go to your app as usual:

app/constraints/custom_domain_constraint.rb
class CustomDomainConstraint
  def self.matches?(request)
    CustomDomain.host_from(request) != ENV.fetch("APP_HOST")
  end
end
config/routes.rb
Rails.application.routes.draw do
  constraints(CustomDomainConstraint) do
    root "status_pages#show", as: :custom_domain_root
  end

  root "home#index"
  # ...the rest of your app
end

The constraint doesn't touch the database, so it's cheap to run on every request. Rails autoloads everything under app/, so app/constraints works without extra configuration.

Then find the tenant. A controller concern keeps it in one place, and Current makes the company available everywhere in the request:

app/models/current.rb
class Current < ActiveSupport::CurrentAttributes
  attribute :company
end
app/controllers/concerns/custom_domain_tenant.rb
module CustomDomainTenant
  extend ActiveSupport::Concern

  included do
    before_action :set_company_from_domain
  end

  private

  def set_company_from_domain
    host = CustomDomain.host_from(request)
    domain = CustomDomain.active.find_by!(host: host)
    Current.company = domain.company
  end
end
app/controllers/status_pages_controller.rb
class StatusPagesController < ApplicationController
  include CustomDomainTenant

  def show
    @status_data = Current.company.fetch_status_data
  end
end

find_by! raises ActiveRecord::RecordNotFound for a domain you don't know, or one that isn't active yet, and Rails turns that into a 404 in production. That's what you want: the page shouldn't render for someone who pointed a random domain at your IP.

If you scope every model by tenant with the acts_as_tenant gem, call set_current_tenant(domain.company) in the same before_action.

Here's how the request travels through the system so far:

Shows a simple status.intercom.com flow from browser to the Rails application.

Fix "Blocked host" errors

The first time you open a custom domain in development, you'll probably see this:

Blocked hosts: status.intercom.com

To allow requests to these hosts, make sure they are valid
hostnames (containing only numbers, letters, dashes and dots),
then add the following to your environment configuration:

    config.hosts << "status.intercom.com"

That's Rails' ActionDispatch::HostAuthorization middleware, which protects against DNS rebinding attacks. It checks both the Host and X-Forwarded-Host headers against config.hosts.

  • In development, Rails allows localhost, any *.localhost and *.test name, and IP addresses. Anything else is blocked.
  • In production, config.hosts is empty by default, so nothing is checked. But new Rails apps ship with a commented-out config.hosts example in config/environments/production.rb, and if you've enabled it, every custom domain gets blocked.

Don't add each domain to the list by hand. config.hosts also accepts a lambda, so let the database decide:

config/environments/production.rb
config.hosts = [
  "app.statuspage.com",
  ->(host) {
    CustomDomain.exists?(host: host.split(":").first.downcase)
  }
]

# Let health checks and your proxy's ask endpoint through.
config.host_authorization = {
  exclude: ->(request) { request.path.in?(["/up", "/caddy/ask"]) }
}

The lambda runs on every request to a host that isn't in the list, so keep the unique index on host. Add a short Rails.cache around it if the query shows up in your metrics.

Add HTTPS for every custom domain

If your customers just point their domains at your app and try to visit them, they're in for a nasty surprise. Your app doesn't support HTTPS for these domains yet, so the browser shows a terrifying warning and blocks the page.

Where are the good ol' days when everything ran on HTTP and people simply trusted each other? Oh well, let's get on with it.

Every custom domain needs its own TLS certificate. Let's Encrypt issues them for free, but you need something that requests a certificate when a new domain shows up, renews it before it expires, and terminates TLS in front of Rails. That's a TLS-terminating reverse proxy. You have three realistic options.

Option 1: Caddy with on-demand TLS

Caddy can get a certificate during the first TLS handshake for a new hostname. This is called on-demand TLS. Without a check, anyone could point a domain at your IP and make you request certificates for it, which burns through your Let's Encrypt rate limits. So Caddy asks your Rails app first:

Caddyfile
{
	on_demand_tls {
		ask http://localhost:3000/caddy/ask
	}
}

https:// {
	tls {
		on_demand
	}
	reverse_proxy localhost:3000
}

The ask endpoint returns 200 for active domains and 404 for everything else:

app/controllers/caddy_controller.rb
# config/routes.rb: get "/caddy/ask", to: "caddy#ask"
class CaddyController < ActionController::API
  def ask
    host = (params[:domain] || params[:host]).to_s.downcase

    if CustomDomain.active.exists?(host: host)
      head :ok
    else
      head :not_found
    end
  end
end

Caddy sends the hostname as domain; Kamal (below) sends it as host, so this endpoint accepts both. Caddy passes the original Host through to Rails, so request.host is the customer's domain and the routing above works as is.

That gets you working HTTPS on one server. For production, you also need shared certificate storage across servers, a second certificate authority for when Let's Encrypt rate limits or fails, monitoring for failed renewals, and a plan for abuse. We cover all of that in our guide to custom domains for SaaS.

What about Kamal?

Kamal is the default way to deploy Rails 8 apps, and its proxy handles Let's Encrypt for you. For custom domains, there's a catch.

kamal-proxy added on-demand TLS in v0.10.0 (September 2026). You give it a --tls-on-demand-url instead of a list of hosts, and it calls that URL with ?host=status.intercom.com before issuing a certificate:

kamal-proxy deploy web --target web-1:3000 \
  --tls --tls-on-demand-url="/caddy/ask"

If the URL is a path, kamal-proxy sends the check through to your Rails app, so the ask endpoint above works unchanged. But as of October 2026, Kamal's config/deploy.yml doesn't have an option for it yet (the pull request is still open). Kamal's automatic SSL also only works when you deploy to a single server. For now, the practical route on Kamal is to run Caddy or a managed proxy in front.

Option 2: a managed proxy

Running a TLS-terminating proxy fleet means owning certificate storage, renewals, rate limits, monitoring and on-call. If you'd rather not, a managed proxy does it for you. That's what we built: the rest of this guide shows how to set it up with SaaS Custom Domains, for $29/month for your first 100 domains.

Set it up with SaaS Custom Domains

SaaS Custom Domains sits in front of your Rails app. Your customers point their domains at us, we issue and renew their certificates, and we proxy every request to your app with the original hostname in a header. That covers:

  • We issue certificates for new domains, renew them before they expire, and store them securely, using both Let's Encrypt and ZeroSSL.
  • Our proxies run on AWS in 119 locations, close to your users. If a region has problems, traffic goes to the nearest healthy one.
  • Root domains point at two static anycast IPs that won't change.
  • An API adds or removes a domain in one call, and webhooks tell you when DNS changes or a certificate is issued.
  • We can email your customers step-by-step instructions for the exact records to add, and check the records for you.

Pricing is $0.29 per domain per month with a 100-domain minimum ($29/month), with volume discounts from 1,000 domains. There's a 7-day free trial; you'll need a card to start it, and you can cancel any time.

Here are the steps:

  1. Create an API token (1 minute)
  2. Create an upstream for your Rails app (1 minute)
  3. Add custom domains from your Rails app (5 minutes)
  4. Point the customer's DNS at us (1 minute)
  5. Read the custom domain in Rails and verify the proxy's token (5–15 minutes)

1. Create an API token

Go to API tokens in your account settings and generate a new token.

Screenshot showing the API tokens settings in the UI.

You'll also need your account UUID, which is in your account settings:

Screenshot of the account settings page in the UI

Store both as environment variables, SCD_API_TOKEN and SCD_ACCOUNT_UUID. Treat the token like a password: it gives full access to your SaaS Custom Domains account.

2. Create an upstream

An upstream is the server we forward requests to: your Rails app. Here's a small API client with nothing but the standard library:

app/services/saas_domains.rb
require "net/http"

class SaasDomains
  class Error < StandardError; end

  BASE = "https://app.saascustomdomains.com/api/v1/accounts"

  def initialize
    @account = ENV.fetch("SCD_ACCOUNT_UUID")
    @token = ENV.fetch("SCD_API_TOKEN")
  end

  def create_upstream(host)
    request(:post, "upstreams", host: host)
  end

  def create_domain(host, **options)
    request(:post, domains_path, { host: host, **options })
  end

  def verify_dns(uuid)
    request(:post, "#{domains_path}/#{uuid}/verify_dns_records")
  end

  def delete_domain(uuid)
    request(:delete, "#{domains_path}/#{uuid}")
  end

  private

  def domains_path
    "upstreams/#{ENV.fetch("SCD_UPSTREAM_UUID")}/custom_domains"
  end

  def request(verb, path, body = nil)
    uri = URI("#{BASE}/#{@account}/#{path}")
    req = Net::HTTP.const_get(verb.capitalize).new(uri)
    req["Authorization"] = "Bearer #{@token}"
    req["Content-Type"] = "application/json"
    req.body = body.to_json if body

    res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http|
      http.request(req)
    end
    unless res.is_a?(Net::HTTPSuccess)
      raise Error, "#{res.code}: #{res.body}"
    end

    JSON.parse(res.body)
  end
end

You only create the upstream once, so do it from the Rails console:

SaasDomains.new.create_upstream("app.statuspage.com")
# => {"uuid"=>"your_upstream_uuid",
#     "host"=>"app.statuspage.com",
#     "port"=>443,
#     "tls"=>true,
#     "auth_token"=>"3f9c...",
#     ...}

Save the uuid as SCD_UPSTREAM_UUID and the auth_token as PROXY_AUTH_TOKEN. You'll use the token in step 5. You can also create the upstream in the dashboard. See the upstreams API docs for the other options.

3. Add custom domains from your app

When a customer adds a domain in your app, register it with us from a background job. Store the UUID we return so you can check and remove the domain later:

db/migrate/20261023000001_add_provider_uuid_to_custom_domains.rb
class AddProviderUuidToCustomDomains < ActiveRecord::Migration[8.0]
  def change
    add_column :custom_domains, :provider_uuid, :string
    add_index :custom_domains, :provider_uuid, unique: true
  end
end
app/jobs/register_custom_domain_job.rb
class RegisterCustomDomainJob < ApplicationJob
  def perform(domain)
    res = SaasDomains.new.create_domain(
      domain.host,
      # Whoever manages their DNS gets step-by-step instructions
      instructions_recipient: domain.company.dns_contact_email
    )
    domain.update!(provider_uuid: res["uuid"])
  end
end
app/models/custom_domain.rb
class CustomDomain < ApplicationRecord
  after_create_commit -> { RegisterCustomDomainJob.perform_later(self) }
end

The instructions_recipient is optional. When you set it, we email that address the exact DNS records to add for this domain. That helps a lot when the person adding the domain in your app isn't the person who manages DNS. More options are in the custom domains API docs.

4. Point the customer's DNS at us

Your customer adds one of these records:

; Subdomain
status.intercom.com.   CNAME  in.saascustomdomains.com.

; Root domain
intercomstatus.com.    A      75.2.96.173
intercomstatus.com.    A      99.83.186.151

Any old A records on the domain have to go, or some visitors will still end up on the old server. You can also white-label the CNAME target, so customers point at in.statuspage.com instead of our hostname.

DNS changes usually take a few minutes, sometimes longer while old records are cached.

Special note for Cloudflare users: if your customer manages DNS on Cloudflare, the record's proxy status must be DNS only (the grey cloud). When it's proxied, Cloudflare answers with its own IPs, so DNS checks fail and HTTPS breaks.

DNS records should have the DNS Only option enabled.

If they need Cloudflare's proxy, pass challenge_type: "dns01" to create_domain. The API response then includes one extra CNAME for them to add, under _acme-challenge, so the certificate can be issued through DNS instead.

5. Read the custom domain in Rails

Behind our proxy, the Host header is your app's own hostname, app.statuspage.com, so your hosting setup keeps routing requests to the right place. The customer's domain arrives in the X-Served-For header instead.

Remember host_from? That's the one method to change:

app/models/custom_domain.rb
def self.host_from(request)
  host = request.headers["X-Served-For"].presence || request.host
  host.downcase
end

The route constraint and the tenant lookup now read the customer's domain from the header, and requests to your own domain carry on as before. Because Host is your own hostname, you don't need the config.hosts lambda from earlier either.

Check that the request came through the proxy

There's a catch. Your app now trusts X-Served-For to decide whose data to show. Anyone who reaches your app directly could send X-Served-For: status.intercom.com and see Intercom's pages, from any domain.

So every request we forward includes your upstream's auth token in the X-SaaS-Domains-Auth-Token header (docs). Reject any request that claims a custom domain without it, and compare in constant time so the check doesn't leak how much of a guess was right:

app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
  # Runs before every other before_action, including the tenant lookup
  prepend_before_action :verify_proxy_token

  private

  def verify_proxy_token
    return unless request.headers["X-Served-For"]

    token = request.headers["X-SaaS-Domains-Auth-Token"].to_s
    expected = ENV.fetch("PROXY_AUTH_TOKEN")
    valid = ActiveSupport::SecurityUtils.secure_compare(token, expected)
    head :forbidden unless valid
  end
end

Keep the token out of your repo (Rails credentials or an environment variable both work), and change it in the dashboard if it ever leaks.

Forms, redirects and sessions

For read-only pages, you're done. If customers' visitors log in or submit forms on their custom domain, there's one more thing. Rails builds absolute URLs from request.host, which behind the proxy is your own hostname. That causes three problems:

  • Redirects leave the custom domain. redirect_to dashboard_path sends the visitor from status.intercom.com to https://app.statuspage.com/dashboard. So do _url route helpers.
  • Form posts fail the CSRF check. Rails compares the browser's Origin header with request.base_url and raises ActionController::InvalidAuthenticityToken with "HTTP Origin header (https://status.intercom.com) didn't match request.base_url (https://app.statuspage.com)".
  • Shared session cookies get rejected. If your session store uses domain: :all, Rails computes the cookie domain from request.host. The browser on status.intercom.com refuses a cookie for .statuspage.com, so logins silently fail.

The fix is to have Rails see the customer's domain. Email support and we'll pass the custom domain through in X-Forwarded-Host for your account. Rails uses X-Forwarded-Host for request.host, so redirects, URL helpers and the CSRF check all use the customer's domain. Some of our Rails customers run exactly this setup.

Two things to adjust when you do:

  • Host stays your own hostname, but HostAuthorization also checks X-Forwarded-Host. If you've set config.hosts in production, add the lambda from Fix "Blocked host" errors.
  • Leave the session cookie's domain unset. Rails' default cookie is host-only, which is what you want on a domain you don't own.

Show DNS status and react to webhooks

Your customers will want to know whether their domain works yet. Ask us to re-check the DNS when they click a "Check DNS" button:

app/models/custom_domain.rb
def check_dns!
  res = SaasDomains.new.verify_dns(provider_uuid)
  active! if res["dns_status"] == "valid"
  res["dns_status"] # "valid", "pending" or "invalid"
end

Instead of polling, you can let webhooks tell you when something changes. Add a webhook in the dashboard pointing at your app. Every webhook includes its security token in the token query parameter, so check that first:

app/controllers/saas_domains_webhooks_controller.rb
# config/routes.rb:
# post "/webhooks/saas_domains", to: "saas_domains_webhooks#create"
class SaasDomainsWebhooksController < ActionController::API
  before_action :verify_webhook_token

  def create
    uuid = params.dig(:domain, :uuid)
    domain = CustomDomain.find_by(provider_uuid: uuid)
    return head :ok unless domain

    case params[:event]
    when "domain_dns_proxy_record_valid" then domain.active!
    when "domain_dns_proxy_record_invalid" then domain.pending!
    end

    head :ok
  end

  private

  def verify_webhook_token
    expected = ENV.fetch("SCD_WEBHOOK_TOKEN")
    token = params[:token].to_s
    valid = ActiveSupport::SecurityUtils.secure_compare(token, expected)
    head :unauthorized unless valid
  end
end

Other events include domain_created, domain_deleted and tls_certificate_issued, which you can use to show "HTTPS ready" in your UI. The full list is in the webhooks docs. Return a 2xx quickly. Failed deliveries are retried up to 10 times.

Remove a domain

When a customer removes a domain or cancels, delete it from the proxy first, then from your database:

app/jobs/remove_custom_domain_job.rb
class RemoveCustomDomainJob < ApplicationJob
  def perform(domain)
    SaasDomains.new.delete_domain(domain.provider_uuid)
    domain.destroy!
  end
end

In this order, if the API call fails, your record is still there and you can retry the job. In the other order, a failed call leaves a domain on the proxy that no longer belongs to anyone in your app. If you run Caddy yourself, removing the row is enough: the ask endpoint starts returning 404, so the certificate isn't renewed.

Test custom domains locally

You don't need a real domain to test the routing. Chrome, Firefox and curl resolve any *.localhost name to your own machine, and Rails allows *.localhost in development, so there's nothing to configure:

# bin/rails console
company = Company.first
company.custom_domains.create!(
  host: "intercom.localhost", status: :active
)

Open http://intercom.localhost:3000 and you'll see that company's status page, while http://localhost:3000 still shows your app. Set APP_HOST=localhost in development so the route constraint knows which host is yours.

To simulate the proxy, send the headers yourself:

curl http://localhost:3000/ \
  -H "X-Served-For: intercom.localhost" \
  -H "X-SaaS-Domains-Auth-Token: $PROXY_AUTH_TOKEN"

Try it without the token too. You should get a 403.

Frequently asked questions

How do I get the custom domain in a Rails controller?

Use request.host. It returns the hostname the visitor typed, without the port. Behind SaaS Custom Domains, read request.headers["X-Served-For"] instead, and check the X-SaaS-Domains-Auth-Token header first.

Why does Rails say "Blocked host" for my custom domain?

Rails' HostAuthorization middleware only allows hosts listed in config.hosts. It's on by default in development and off in production unless you've set config.hosts. Add a lambda that checks your custom_domains table instead of listing every domain.

Do I need a gem to support custom domains in Rails?

No. A model, a route constraint and a before_action are enough. Gems like acts_as_tenant help with scoping queries to the current tenant, but you still look up the tenant from the hostname yourself.

Can Kamal issue SSL certificates for my customers' domains?

kamal-proxy supports on-demand TLS since v0.10.0, but as of October 2026 Kamal's deploy.yml can't configure it yet. Run Caddy with on-demand TLS, or a managed proxy, in front of your Kamal app.

Can customers use a root domain like intercomstatus.com?

Yes, with A records pointing at your static IPs, because DNS doesn't allow a CNAME at the root. Some DNS providers support ALIAS records or CNAME flattening, which work like a CNAME there.

Will Devise and OmniAuth work on custom domains?

Devise works: each custom domain gets its own host-only session cookie, so users sign in separately on each domain. OAuth providers only redirect to callback URLs you registered in advance, so route "Sign in with Google" through your main domain and send the user back to the custom domain afterwards.

Next steps

You've got custom domains working in Rails: DNS pointing at you, a model to store them, routing by hostname, HTTPS and the security check on the proxy's token. For the parts that only show up at scale, read the full guide to custom domains for SaaS. If you work in other stacks too, we have guides for Django and Node.js.

If you have any questions, reach out via email or the chat widget on saascustomdomains.com. If you don't have an account yet, start your 7-day free trial, or book a 15-minute demo.

More Articles

Cover Image for Custom Domains for SaaS: How to Add Them (2026 Guide)
Drago Crnjac

Custom Domains for SaaS: How to Add Them (2026 Guide)

How to add custom domains to your SaaS app: DNS records, domain verification, HTTPS certificates, tenant routing and running it in production.

Read more
Cover Image for How to Add Custom Domains to a Django App
Drago Crnjac

How to Add Custom Domains to a Django App

How to add custom domains to a Django SaaS app: DNS records, storing domains, resolving the tenant from the hostname, and HTTPS for every domain.

Read more
Cover Image for How to Add Custom Domains to a Node.js App
Drago Crnjac

How to Add Custom Domains to a Node.js App

How to add custom domains to a Node.js SaaS app with Express or Next.js: DNS records, storing domains, routing by hostname, and HTTPS for every domain.

Read more