Back to blog

Building Multi-Tenant SaaS with Rails 8, Caddy, and Kamal

22 min read
Building Multi-Tenant SaaS with Rails 8, Caddy, and Kamal

This is Part 2 of the Deploying Rails with Kamal series.

If you are building a SaaS where each customer gets their own subdomain — and can optionally connect a custom domain — you have probably discovered that Kamal’s built-in proxy cannot issue SSL certificates on demand. Every new domain means editing deploy.yml and redeploying. That does not scale.

This guide solves that problem. We will build a multi-tenant Rails 8 application where tenants get subdomains automatically, can connect custom domains, and SSL certificates are provisioned on the fly — no manual intervention or redeployment required. It assumes you have a Rails 8 application deployed with Kamal. If you have not set up your server yet, start with The Complete Guide to Deploying Rails 8 with Kamal on Hetzner.

The architecture described here is based on a real production application: Dinehere, an AI-powered restaurant website builder where each restaurant gets a subdomain (e.g. joes-bistro.dinehere.ai) and can optionally connect their own domain.

What Multi-Tenant SaaS Needs

The goal is straightforward:

  • Subdomains for every tenant. When a tenant called “Acme” signs up, they immediately get acme.myapp.com.
  • Optional custom domains. Acme can later connect acme.com or www.acme.com so their site is served from their own domain.
  • Automatic SSL. Certificates are provisioned on the fly — no manual steps, no config changes, no redeployments.
  • Single server, single Rails app. All tenants are served by the same application instance, differentiated by the incoming hostname.

This is a common pattern for website builders, storefront platforms, and any SaaS where tenants have a public-facing presence. Here is how to build it.

The Limitation of kamal-proxy

If you have followed the Kamal deployment guide, you know that kamal-proxy handles TLS termination by default. You list your domains in deploy.yml:

proxy:
  ssl: true
  hosts:
    - myapp.com
    - www.myapp.com

This works perfectly for a fixed set of domains. But for a multi-tenant application, the problems become apparent quickly:

  • Adding a domain requires redeployment. Every time a tenant connects a custom domain, you would need to edit deploy.yml and redeploy. That is not scalable.
  • Wildcard certificates do not solve it. Let’s Encrypt wildcards require the DNS-01 challenge, which kamal-proxy cannot perform. And even if you obtained a wildcard for *.myapp.com, it would not cover custom domains like acme.com.
  • kamal-proxy was not designed for this. It is an excellent reverse proxy for single-application deployments, but dynamic multi-tenant SSL is outside its scope.

We need something that can issue certificates on demand, the moment a new domain connects for the first time.

Dynamic SSL with Caddy’s On-Demand TLS

Caddy is an open-source web server written in Go, best known for its automatic HTTPS capabilities. The feature that makes it perfect for multi-tenant SaaS is on-demand TLS.

Here is how it works:

  1. A request arrives for a domain Caddy has never seen before — say acme.com.
  2. Before issuing a certificate, Caddy makes an HTTP request to your Rails app (the ask directive) to verify this domain is legitimate.
  3. If your app responds with 200, Caddy obtains a Let’s Encrypt certificate and serves the request. All subsequent requests use the cached certificate.
  4. If your app responds with 404, Caddy rejects the connection. This prevents abuse — random domains pointed at your IP will not trigger certificate issuance.

Caddy works perfectly alongside Kamal. You run it as a Kamal accessory. kamal-proxy still handles application deployment and health checks, but you disable its SSL and bind it to localhost. Caddy sits in front, handling ports 80 and 443, terminating TLS, and proxying to kamal-proxy. You manage it with kamal accessory boot caddy and kamal accessory reboot caddy — no separate infrastructure needed.

Multi-Tenant Request Flow Architecture

The system uses constraint-based routing. Every incoming request passes through Rails route constraints that determine whether the request is for a tenant or the main application.

Here is how requests flow through the system:

Multi-tenant request flow architecture Requests from tenant subdomains, the main domain, and custom domains flow through Caddy for on-demand TLS, then kamal-proxy, then the Rails router which evaluates TenantConstraint and MainConstraint to route to the appropriate controller. acme.myapp.com myapp.com custom.com Caddy On-demand TLS · Let's Encrypt /internal/tls/verify Caddy asks Rails before issuing a cert ✓ 200 = issue cert ✗ 404 = deny kamal-proxy SSL disabled · 127.0.0.1 · Routes to app Rails Router Evaluates constraints: Tenant → Main TenantConstraint Subdomains + custom domains Tenants::PagesController MainConstraint myapp.com Main Application

The constraints are evaluated in order — tenant first, then main — so tenant subdomains and custom domains are resolved before the main application routes. Before forwarding a request, Caddy checks your Rails app’s /internal/tls/verify endpoint to decide whether to issue a certificate for that domain.

Now let us build each component, starting with the data model.

The Subdomain Data Model

Start with the data model. Your Tenant model needs a subdomain column:

class AddSubdomainToTenants < ActiveRecord::Migration[8.1]
  def change
    add_column :tenants, :subdomain, :string
    add_index :tenants, :subdomain, unique: true
  end
end

DomainService

The DomainService centralises all domain logic — generating subdomains, checking availability, resolving tenants from hostnames, and building URLs.

# app/services/domain_service.rb
class DomainService
  APP_DOMAIN = Rails.application.routes.default_url_options[:host] # "myapp.com"

  RESERVED_SUBDOMAINS = %w[
    www admin api cdn assets mail ftp smtp pop imap
    staging dev test beta app dashboard billing support
    help docs status blog news shop store static media
    ns1 ns2 mx autoconfig autodiscover
  ].freeze

  SUBDOMAIN_FORMAT = /\A[a-z0-9]([a-z0-9-]*[a-z0-9])?\z/
  SUBDOMAIN_LENGTH = (3..63)

  class << self
    def generate(name)
      base = name.parameterize[0, 60]
      return base if available?(base)

      counter = 2
      loop do
        candidate = "#{base[0, 57]}-#{counter}"
        return candidate if available?(candidate)
        counter += 1
      end
    end

    def available?(subdomain)
      return false if subdomain.blank?
      return false unless subdomain.match?(SUBDOMAIN_FORMAT)
      return false unless SUBDOMAIN_LENGTH.cover?(subdomain.length)
      return false if RESERVED_SUBDOMAINS.include?(subdomain)
      return false if Tenant.exists?(subdomain: subdomain)

      true
    end

    def public_url(subdomain)
      "https://#{subdomain}.#{APP_DOMAIN}"
    end

    def find_tenant(host)
      return nil if host.blank?

      host = host.downcase.strip

      # Check if this is a known application domain
      extracted = extract_subdomain(host)
      if extracted
        return nil if RESERVED_SUBDOMAINS.include?(extracted)
        return Tenant.find_by(subdomain: extracted)
      end

      # Otherwise, check custom domains
      custom_domain = CustomDomain.find_by(domain: host)
      custom_domain&.tenant
    end

    private

    def extract_subdomain(host)
      [APP_DOMAIN, "lvh.me"].each do |base|
        if host.end_with?(".#{base}")
          sub = host.chomp(".#{base}")
          return sub unless sub.include?(".")
        end
      end

      nil
    end
  end
end

The APP_DOMAIN reads from your default URL options, which you set in config/environments/production.rb:

# config/environments/production.rb
routes.default_url_options = { host: "myapp.com", protocol: "https" }
config.assume_ssl = true
config.hosts.clear

The config.hosts.clear line is important. Rails 8 blocks requests from unknown hostnames by default. Since every tenant subdomain and custom domain is a different hostname, you need to disable host authorization entirely. Caddy already handles traffic gating through the TLS verification endpoint — if a domain is not in your database, Caddy will not issue a certificate for it, so the request never reaches Rails.

SubdomainValidator

Use a custom validator to keep the model clean:

# app/validators/subdomain_validator.rb
class SubdomainValidator < ActiveModel::EachValidator
  def validate_each(record, attribute, value)
    return if value.blank?

    unless value.match?(DomainService::SUBDOMAIN_FORMAT)
      record.errors.add(attribute, "must contain only lowercase letters, numbers, and hyphens")
    end

    unless DomainService::SUBDOMAIN_LENGTH.cover?(value.length)
      record.errors.add(attribute, "must be between 3 and 63 characters")
    end

    if DomainService::RESERVED_SUBDOMAINS.include?(value)
      record.errors.add(attribute, "is reserved")
    end
  end
end

Then in your model:

class Tenant < ApplicationRecord
  validates :subdomain, presence: true, uniqueness: true, subdomain: true
end

Constraint-Based Subdomain Routing

The heart of the multi-tenant system lives in config/routes.rb. Two constraint classes determine which routes apply to each request:

# config/routes.rb
Rails.application.routes.draw do
  # These routes must be outside all constraints.
  # Caddy calls /internal/tls/verify from 127.0.0.1, which won't
  # match any constraint. The health check also needs to be
  # accessible regardless of the host header.
  get "/up", to: "rails/health#show", as: :rails_health_check
  get "/internal/tls/verify", to: "internal/tls#verify"

  # Tenant website routes
  constraints(TenantConstraint.new) do
    scope module: :tenants do
      root "pages#show"
      # Add other tenant-specific routes here
    end
  end

  # Main application routes
  constraints(MainConstraint.new) do
    # Your main app routes — admin, auth, dashboards, etc.
    resource :session, only: %i[new create destroy]
    resources :tenants do
      resources :custom_domains, only: %i[new create destroy]
    end

    root "pages#home"
  end
end

The /internal/tls/verify route must be outside all constraints. Caddy makes this request from 127.0.0.1 using the domain it wants to verify as a query parameter — not as the host header. If this route were inside MainConstraint, the request would never match because 127.0.0.1 is not in the allowed hosts list. The same applies to the health check endpoint.

Ordering matters. Rails evaluates constraints top to bottom. The tenant constraint is checked first because subdomains and custom domains are the most common requests. The main constraint comes last as a catch-all for known application hosts.

TenantConstraint

This is where the domain resolution happens. It uses the domain_extractor gem for reliable parsing of multi-part TLDs (e.g. .co.uk, .com.au). Add it to your Gemfile:

gem "domain_extractor"

This gem handles the tricky business of parsing hostnames correctly. Without it, you would need to maintain your own list of public suffixes to know that acme.co.uk has no subdomain while acme.myapp.com has subdomain acme.

# app/constraints/tenant_constraint.rb
class TenantConstraint
  KNOWN_HOSTS = %w[myapp.com lvh.me].freeze

  def matches?(request)
    host = request.host.downcase

    if known_host?(host)
      # Extract subdomain from known application domains
      subdomain = extract_subdomain(host)
      return false if subdomain.blank?
      return false if DomainService::RESERVED_SUBDOMAINS.include?(subdomain)

      Tenant.exists?(subdomain: subdomain)
    else
      # Unknown host — check custom domains
      CustomDomain.exists?(domain: host)
    end
  end

  private

  def known_host?(host)
    KNOWN_HOSTS.any? { |h| host == h || host.end_with?(".#{h}") }
  end

  def extract_subdomain(host)
    parsed = DomainExtractor.parse(host)
    sub = parsed&.subdomain
    sub.presence
  rescue StandardError
    nil
  end
end

MainConstraint

A whitelist of known application hosts:

# app/constraints/main_constraint.rb
class MainConstraint
  MAIN_HOSTS = %w[
    myapp.com
    www.myapp.com
    localhost
    lvh.me
    127.0.0.1
  ].freeze

  def matches?(request)
    MAIN_HOSTS.include?(request.host.downcase)
  end
end

Resolving the Current Tenant

With routing in place, tenant controllers need to resolve the current tenant from the request hostname. The BaseController uses DomainService.find_tenant and exposes current_tenant to all views via helper_method:

# app/controllers/tenants/base_controller.rb
module Tenants
  class BaseController < ApplicationController
    layout "tenant"

    private

    def current_tenant
      @current_tenant ||= DomainService.find_tenant(request.host)
    end
    helper_method :current_tenant
  end
end

The PagesController handles the main tenant page:

# app/controllers/tenants/pages_controller.rb
module Tenants
  class PagesController < Tenants::BaseController
    def show
      return head(:not_found) unless current_tenant

      render :show
    end
  end
end

Custom Domains

The Model

# app/models/custom_domain.rb
class CustomDomain < ApplicationRecord
  belongs_to :tenant, touch: true

  validates :domain, presence: true,
                     uniqueness: { case_sensitive: false },
                     custom_domain_format: true

  normalizes :domain, with: ->(d) { d.strip.downcase }
end

Migration:

class CreateCustomDomains < ActiveRecord::Migration[8.1]
  def change
    create_table :custom_domains do |t|
      t.references :tenant, null: false, foreign_key: true
      t.string :domain, null: false

      t.timestamps
    end

    add_index :custom_domains, :domain, unique: true
  end
end

CustomDomainFormatValidator

This validator blocks obviously invalid input — domains with protocols, ports, paths, or domains belonging to your own application:

# app/validators/custom_domain_format_validator.rb
class CustomDomainFormatValidator < ActiveModel::EachValidator
  OWN_DOMAINS = %w[myapp.com].freeze

  def validate_each(record, attribute, value)
    return if value.blank?

    domain = value.to_s.strip.downcase

    if domain.include?("://")
      record.errors.add(attribute, "should not include a protocol (remove http:// or https://)")
      return
    end

    if domain.include?("/")
      record.errors.add(attribute, "should not include a path")
      return
    end

    if domain.match?(/:\d+/)
      record.errors.add(attribute, "should not include a port number")
      return
    end

    if OWN_DOMAINS.any? { |d| domain == d || domain.end_with?(".#{d}") }
      record.errors.add(attribute, "cannot be a #{DomainService::APP_DOMAIN} domain")
      return
    end

    unless domain.match?(/\A[a-z0-9]([a-z0-9.-]*[a-z0-9])?\.[a-z]{2,}\z/)
      record.errors.add(attribute, "is not a valid domain name")
    end
  end
end

DNS Instructions for Users

When a tenant adds a custom domain, you need to tell them how to configure their DNS. The instructions depend on whether they are connecting a subdomain or a root domain:

For subdomains (e.g. www.acme.com): Create a CNAME record pointing to their tenant subdomain.

Type:  CNAME
Name:  www
Value: acme.myapp.com

For root domains (e.g. acme.com): Root domains cannot use CNAME records (per the DNS specification). The tenant needs an ALIAS, ANAME, or CNAME-flattening record pointing to your server. Support for this varies by DNS provider:

  • Cloudflare: CNAME flattening — just add a CNAME at the root and Cloudflare handles it
  • Route 53: ALIAS record
  • DNSimple: ALIAS record
  • Others: Some providers do not support this. The fallback is an A record pointing to 203.0.113.10

Display these instructions clearly in your UI after a domain is added. You might also want to add a DNS verification check that runs periodically to confirm the domain is pointed correctly before showing it as active.

Replacing kamal-proxy with Caddy

This is where the pieces come together. We configure Caddy as a Kamal accessory, disable SSL on kamal-proxy, and let Caddy handle all TLS termination.

The Caddyfile

Create config/Caddyfile in your Rails project:

{
  email deploy@myapp.com
  on_demand_tls {
    ask http://127.0.0.1:3000/internal/tls/verify
  }
}

# Direct IP access — redirect to main domain
http://203.0.113.10 {
  redir https://myapp.com permanent
}

# HTTP catch-all — redirect to HTTPS
:80 {
  redir https://{host}{uri} permanent
}

# HTTPS — the main handler
:443 {
  tls {
    on_demand
  }

  request_body {
    max_size 15MB
  }

  reverse_proxy 127.0.0.1:3000 {
    header_up Host {host}
    header_up X-Real-IP {remote_host}
    flush_interval -1
    transport http {
      read_timeout 120s
      write_timeout 120s
      compression off
    }
  }
}

Let us walk through each block:

  • on_demand_tls { ask ... }: The global directive telling Caddy where to verify domains before issuing certificates. It calls your Rails endpoint on 127.0.0.1:3000 — the loopback address, so this never leaves the server.
  • http://203.0.113.10: Anyone hitting the server by IP gets redirected to your main domain. Without this, the bare IP would show an error.
  • :80: All HTTP traffic redirects to HTTPS. Simple and universal.
  • :443: The main HTTPS handler. tls { on_demand } enables on-demand certificate issuance. The reverse_proxy block forwards requests to kamal-proxy on port 3000, preserving the original Host header and client IP. The flush_interval -1 ensures streaming responses (like Turbo Streams) are forwarded immediately. Compression is disabled because Thruster (which sits between kamal-proxy and Puma) already handles gzip/brotli compression.

deploy.yml Changes

proxy:
  ssl: false
  run:
    http_port: 3000
    https_port: 3001
    bind_ips:
      - 127.0.0.1

accessories:
  caddy:
    image: caddy:2
    host: 203.0.113.10
    network: host
    options:
      cap-add: NET_ADMIN
    files:
      - config/Caddyfile:/etc/caddy/Caddyfile:ro
    volumes:
      - caddy_data:/data
      - caddy_config:/config

Key changes:

  • ssl: false: kamal-proxy no longer handles TLS. It listens on plain HTTP.
  • http_port: 3000: kamal-proxy listens on port 3000 instead of 80.
  • bind_ips: [127.0.0.1]: kamal-proxy only accepts connections from localhost. It is not directly accessible from the internet — all external traffic goes through Caddy.
  • network: host: Caddy uses the host network stack so it can bind to ports 80 and 443 directly.
  • cap-add: NET_ADMIN: Required for Caddy to bind to privileged ports.
  • volumes: The caddy_data volume stores certificates. This must persist across accessory reboots — without it, Caddy would re-issue certificates every time, potentially hitting Let’s Encrypt rate limits.

Boot the accessory:

kamal accessory boot caddy

To update the Caddyfile after changes:

kamal accessory reboot caddy

The TLS Verification Endpoint

This is the endpoint Caddy calls before issuing a certificate. It reuses the same constraint classes your routes use, ensuring perfect consistency — if a domain can reach your app through routing, Caddy will issue a certificate for it.

# app/controllers/internal/tls_controller.rb
module Internal
  class TlsController < ApplicationController
    skip_before_action :authenticate_user!

    def verify
      domain = params[:domain].to_s.downcase.strip

      if allowed_domain?(domain)
        expires_in 1.day, public: true
        head :ok
      else
        head :not_found
      end
    end

    private

    def allowed_domain?(domain)
      request = Struct.new(:host).new(domain)

      MainConstraint.new.matches?(request) ||
        TenantConstraint.new.matches?(request)
    end
  end
end

As noted in the routing section, this route must be outside all constraints — Caddy calls it from 127.0.0.1, not from a matching host.

The expires_in 1.day header tells Caddy to cache the response. Without it, Caddy would call the endpoint on every request for every domain, adding unnecessary load.

Since this endpoint is called by Caddy from localhost, make sure to whitelist the /internal/ path in whatever rate limiting you use. If you use Rack::Attack:

# config/initializers/rack_attack.rb
Rack::Attack.safelist("internal") do |req|
  req.path.start_with?("/internal/")
end

Cloudflare Integration

If you use Cloudflare (and you should — it provides CDN caching, DDoS protection, and CNAME flattening for free), there are a few things to configure.

Trusted Proxies

When Cloudflare proxies your traffic, request.remote_ip will show Cloudflare’s IP instead of the visitor’s real IP. Add Cloudflare’s IP ranges to your trusted proxies:

# config/initializers/cloudflare.rb
CLOUDFLARE_IPS = %w[
  173.245.48.0/20
  103.21.244.0/22
  103.22.200.0/22
  103.31.4.0/22
  141.101.64.0/18
  108.162.192.0/18
  190.93.240.0/20
  188.114.96.0/20
  197.234.240.0/22
  198.41.128.0/17
  162.158.0.0/15
  104.16.0.0/13
  104.24.0.0/14
  172.64.0.0/13
  131.0.72.0/22
].map { |ip| IPAddr.new(ip) }

Rails.application.config.action_dispatch.trusted_proxies =
  ActionDispatch::RemoteIp::TRUSTED_PROXIES + CLOUDFLARE_IPS

DNS Configuration

Set up two DNS records in Cloudflare:

  1. myapp.com — A record pointing to 203.0.113.10, proxied (orange cloud)
  2. *.myapp.com — A record pointing to 203.0.113.10, proxied (orange cloud)

Both records can be proxied through Cloudflare. The wildcard record means every subdomain automatically gets Cloudflare’s CDN, DDoS protection, and caching.

Set the SSL/TLS encryption mode to Full in the Cloudflare dashboard. Do not use Flexible — it causes redirect loops because Caddy redirects HTTP to HTTPS while Cloudflare sends HTTP.

Caddy’s on-demand TLS works behind Cloudflare because Cloudflare forwards the TLS handshake with SNI (Server Name Indication) intact. For subdomains under *.myapp.com, Cloudflare handles TLS on its edge and Caddy handles TLS between Cloudflare and your origin. For custom domains (which do not go through Cloudflare), Caddy handles TLS directly with the client.

Local Development with lvh.me

Testing multi-tenant routing locally is straightforward thanks to lvh.me. This domain resolves to 127.0.0.1 — and so do all its subdomains. No hosts file editing required.

With your Rails server running on port 3000:

  • http://lvh.me:3000 — Hits the main application (MainConstraint matches)
  • http://acme.lvh.me:3000 — Hits the tenant site for “acme” (TenantConstraint matches)

Make sure lvh.me appears in all your constraint host lists alongside the production domain. You saw this in the constraint classes above — KNOWN_HOSTS includes both myapp.com and lvh.me, and MAIN_HOSTS includes lvh.me and localhost.

This gives you a local development experience that closely mirrors production. You can test subdomain routing, custom domain resolution (by adding entries to /etc/hosts if needed), and the full constraint chain without deploying anything.

The Complete Multi-Tenant File Structure

Here is a summary of all the files involved:

Models and migrations:

  • app/models/tenant.rb — with subdomain column and validation
  • app/models/custom_domain.rb — belongs to tenant, stores custom domains
  • Migrations for both tables

Services:

  • app/services/domain_service.rb — subdomain generation, availability checks, tenant resolution

Validators:

  • app/validators/subdomain_validator.rb — format, length, reserved words
  • app/validators/custom_domain_format_validator.rb — blocks invalid domain input

Constraints:

  • app/constraints/tenant_constraint.rb — matches tenant subdomains and custom domains
  • app/constraints/main_constraint.rb — matches main application hosts

Controllers:

  • app/controllers/tenants/base_controller.rb — tenant resolution
  • app/controllers/tenants/pages_controller.rb — serves tenant content
  • app/controllers/internal/tls_controller.rb — Caddy verification endpoint

Configuration:

  • config/routes.rb — three constraint blocks
  • config/Caddyfile — on-demand TLS configuration
  • config/deploy.yml — Kamal with Caddy accessory
  • config/initializers/rack_attack.rb — safelist internal endpoints
  • config/initializers/cloudflare.rb — trusted proxies

Dinehere uses this exact architecture in production — every restaurant gets a subdomain on signup, and connecting a custom domain is a self-service operation that takes effect within seconds. No redeployment, no manual certificate management, no downtime.

Production Considerations

A few things to keep in mind once this is running in production:

Let’s Encrypt rate limits. You can issue a maximum of 50 certificates per registered domain per week. This applies to myapp.com (covering all *.myapp.com subdomains) as a single registered domain. Custom domains each count against their own registered domain, so those are effectively unlimited. In practice, Caddy caches certificates and renews them automatically — you will only hit the limit if you are provisioning more than 50 new subdomains per week.

Certificate storage. The caddy_data Docker volume stores all issued certificates. This volume must persist across accessory reboots. If it is lost, Caddy will need to re-issue every certificate, which could hit rate limits. Back up this volume regularly.

Monitoring. Check Caddy’s logs with:

kamal accessory logs caddy
kamal accessory logs caddy --follow

Look for certificate issuance errors, failed ask requests, and TLS handshake failures.

Removing custom domains. When a tenant removes a custom domain, the certificate remains in Caddy’s cache until it expires. This is harmless — Caddy will simply not renew it. The domain will stop resolving to your server once the DNS record is removed.

Optional: CDN Subdomain for Assets

There is a subtle problem with custom domains: they do not go through Cloudflare. When a visitor loads acme.com, Caddy handles TLS directly and proxies to Rails. The page HTML is served fine, but all assets (images, stylesheets, scripts) are also served directly without CDN caching or edge delivery.

For tenants with significant traffic on custom domains, this can be a performance issue. The solution is to serve assets from a dedicated CDN subdomain — cdn.myapp.com — which always goes through Cloudflare regardless of which domain the tenant uses.

Add a CdnConstraint to match your CDN subdomain, and add it as the first constraint block in your routes. It proxies ActiveStorage blob requests and redirects everything else:

# app/constraints/cdn_constraint.rb
class CdnConstraint
  CDN_HOSTS = %w[cdn.myapp.com cdn.lvh.me].freeze

  def matches?(request)
    CDN_HOSTS.include?(request.host.downcase)
  end
end

In your routes:

constraints(CdnConstraint.new) do
  # Redirect root and all non-ActiveStorage paths to the main domain.
  # ActiveStorage proxy routes are auto-mounted by Rails and will still
  # match on this subdomain — the negative lookahead ensures we don't
  # redirect those requests.
  get "/", to: redirect("https://myapp.com", status: 301)
  get "/*path", constraints: { path: /(?!rails\/active_storage).+/ },
      to: redirect("https://myapp.com", status: 301)
end

Add a cdn_host method to your DomainService and use it when generating asset URLs in tenant views:

# Add to DomainService
def self.cdn_host
  "cdn.#{APP_DOMAIN}"
end
<%= image_tag rails_storage_proxy_url(image, host: DomainService.cdn_host) %>

This way, even when a visitor is on acme.com, all images and assets load from cdn.myapp.com through Cloudflare’s edge network. The tenant’s page loads over their custom domain (for branding), but assets benefit from CDN caching and global distribution.

This optimisation is only worth implementing if you have tenants using custom domains with meaningful asset traffic. For subdomain-only setups where everything goes through Cloudflare anyway, it is unnecessary.

Optional: Primary Domain Redirect

If tenants can connect multiple custom domains — say acme.com and www.acme.com — the same content becomes accessible at multiple URLs. Search engines treat these as duplicate pages, diluting page authority and potentially causing indexing issues. A 301 redirect to a primary domain consolidates link equity and establishes a single canonical URL.

Add a primary column to custom_domains:

class AddPrimaryToCustomDomains < ActiveRecord::Migration[8.1]
  def change
    add_column :custom_domains, :primary, :boolean, default: false, null: false
  end
end

When a tenant sets a primary domain, ensure only one is marked as primary:

# app/models/custom_domain.rb
class CustomDomain < ApplicationRecord
  belongs_to :tenant, touch: true

  validates :domain, presence: true,
                     uniqueness: { case_sensitive: false },
                     custom_domain_format: true

  normalizes :domain, with: ->(d) { d.strip.downcase }

  scope :primary, -> { where(primary: true) }

  before_save :ensure_single_primary

  private

  def ensure_single_primary
    if primary? && primary_changed?
      tenant.custom_domains.where.not(id: id).update_all(primary: false)
    end
  end
end

Then redirect non-primary domains in your tenant controller:

# app/controllers/tenants/pages_controller.rb
module Tenants
  class PagesController < Tenants::BaseController
    def show
      return head(:not_found) unless current_tenant

      if redirect_to_primary_domain?
        return redirect_to primary_domain_url, status: :moved_permanently, allow_other_host: true
      end

      render :show
    end

    private

    def redirect_to_primary_domain?
      primary = current_tenant.custom_domains.primary.first
      primary && request.host != primary.domain
    end

    def primary_domain_url
      "https://#{current_tenant.custom_domains.primary.first.domain}#{request.fullpath}"
    end
  end
end

When a visitor arrives on www.acme.com but the tenant has marked acme.com as primary, they get a 301 redirect. Search engines follow the redirect and index only the primary domain.


That covers the full stack — from data model to deployment. The key insight is that Caddy’s on-demand TLS turns what would be a complex certificate management problem into a single HTTP endpoint. Your Rails app already knows which domains are valid through its routing constraints, so the TLS verification endpoint is just a thin wrapper around the same logic. Kamal handles deployments, Caddy handles certificates, and Rails handles everything else.

If you have not set up your server yet, start with The Complete Guide to Deploying Rails 8 with Kamal on Hetzner — it covers everything from provisioning to production.