Subdomain-Based Multi-Tenancy with Domain Configuration in PropertyWebBuilder

PropertyWebBuilder routes every incoming request to a specific Pwb::Website tenant by parsing the Host header or an X-Website-Slug header, automatically scoping all database queries via ActsAsTenant while supporting both custom domains and platform subdomains.

PropertyWebBuilder (PWB) is an open-source Rails platform for real-estate websites that implements robust subdomain-based multi-tenancy with domain configuration. The architecture isolates each tenant's data by resolving the target Pwb::Website from the request host, then wiring that instance into the ActsAsTenant framework to ensure all models automatically filter by website_id.

How Tenant Resolution Works

The resolution pipeline lives in app/controllers/concerns/subdomain_tenant.rb and follows a strict priority order to determine the current tenant.

Header-Based Resolution

For API or GraphQL requests, PWB accepts an X-Website-Slug header to bypass host parsing. In set_current_website_from_request, the concern checks this header first:

slug = request.headers["X-Website-Slug"]
if slug.present?
  Pwb::Current.website = Pwb::Website.find_by(slug: slug)
  if Pwb::Current.website.present?
    ActsAsTenant.current_tenant = Pwb::Current.website
    return
  end
end

This allows direct tenant access without DNS configuration.

Host Parsing and Domain Classification

If no header is present, the system extracts request.host, downcases it, and delegates to Pwb::Website.find_by_host (defined in app/models/concerns/pwb/website_domain_configurable.rb). This method implements a two-phase lookup:

  1. Custom domain matching: If the host does not end with a configured platform domain, the system searches the custom_domain column (case-insensitive, stripping optional www. prefixes).
  2. Subdomain extraction: If no custom domain matches, the method strips the platform suffix and queries the subdomain column for the left-most segment.

# Phase 1: Custom domain (lines 71-75)

website = find_by_custom_domain(host) unless platform_domain?(host)

# Phase 2: Subdomain fallback (lines 77-80)

website ||= find_by_subdomain(extract_subdomain_from_host(host))

The Fallback Mechanism

When no tenant matches the incoming host—common in development or testing environments—the controller concern falls back to the first record in the database:

Pwb::Current.website ||= Pwb::Website.first

This ensures the application remains functional even without perfect DNS alignment.

Core Architectural Components

SubdomainTenant Controller Concern

The SubdomainTenant concern is mixed into ApplicationController and automatically executes set_current_website_from_request before every action. It sets two critical thread-local variables:

  • Pwb::Current.website – accessible globally during the request cycle
  • ActsAsTenant.current_tenant – drives the automatic query scoping

The unified lookup method ensures consistent resolution across the entire application, from web controllers to background job contexts.

WebsiteDomainConfigurable Model Logic

The WebsiteDomainConfigurable concern (included in Pwb::Website) provides the class methods that power tenant resolution:

Method Purpose
find_by_host(host) Unified entry point that routes to custom domain or subdomain lookup
find_by_custom_domain(host) Case-insensitive search handling www. prefixes
find_by_subdomain(sub) Case-insensitive subdomain column lookup
platform_domains Returns array of platform suffixes from ENV['PLATFORM_DOMAINS']
extract_subdomain_from_host(host) Strips platform suffix and returns the tenant identifier

Platform Domain Configuration

The system distinguishes between custom domains and platform subdomains using the PLATFORM_DOMAINS environment variable. If unset, it defaults to:

ENV.fetch('PLATFORM_DOMAINS',
  'propertywebbuilder.com,pwb.localhost,e2e.localhost,localhost')
         .split(',').map(&:strip)

Adding a new brand suffix (e.g., mybrand.com) requires only updating this environment variable; no code changes are needed.

Database Schema and Tenant Isolation

The pwb_websites table stores tenant identifiers in two columns:

  • subdomain – the platform subdomain (e.g., myagency)
  • custom_domain – the owned domain (e.g., myagency.com)

Uniqueness is enforced at the database level via index_pwb_websites_on_subdomain and a unique partial index on custom_domain (defined in app/models/pwb/website.rb, lines 85-111).

All tenant-specific models include ActsAsTenant::Multi (via the PwbTenant namespace). Because SubdomainTenant sets ActsAsTenant.current_tenant to the resolved Pwb::Website, every query automatically receives a WHERE website_id = ? clause without manual scoping.

Practical Usage Examples

Creating a Subdomain Tenant

Reserve a subdomain and create the website via Rails console:


# Generate a unique subdomain

sub = Pwb::Subdomain.create!(name: "myagency-#{SecureRandom.hex(3)}")

# Create the tenant

website = Pwb::Website.create!(
  subdomain: sub.name,
  company_display_name: "My Agency",
  default_currency: "USD"
)

# Accessible immediately at https://myagency-xxxx.propertywebbuilder.com

Configuring a Custom Domain

Assign a custom domain and verify DNS ownership:

website = Pwb::Website.find_by(subdomain: "myagency")
website.update!(custom_domain: "myagency.com")

# Generate verification token

website.generate_domain_verification_token!
puts "Add TXT record: _pwb-verification.myagency.com = #{website.custom_domain_verification_token}"

# After DNS propagation

website.verify_custom_domain!  # => true/false

Accessing the Current Tenant

In controllers or views, access the resolved tenant via the thread-local store:

def dashboard
  @website = Pwb::Current.website
  @listings = @website.rental_listings  # Automatically scoped

end

Manual Resolution in Tests

Simulate requests by calling the resolution method directly:

host = "myagency.propertywebbuilder.com"
website = Pwb::Website.find_by_host(host)

# => #<Pwb::Website id: 12, subdomain: "myagency", ...>

Client-Side Rendering Constraints

When a website enables client-side rendering (Astro), the ClientRenderingConstraint (in app/constraints/client_rendering_constraint.rb) reuses the same host-lookup logic. It calls Pwb::Website.find_by_host to obtain the website, then checks website.client_rendering? to determine whether to proxy the request to the Astro server (lines 51-63).

Summary

  • Request Resolution: SubdomainTenant parses X-Website-Slug headers or the Host header, delegating to WebsiteDomainConfigurable#find_by_host to locate the correct Pwb::Website.
  • Dual Domain Support: The system handles both custom domains (direct DNS) and platform subdomains (under configured PLATFORM_DOMAINS suffixes).
  • Automatic Scoping: Resolved tenants are wired into ActsAsTenant.current_tenant, ensuring all database queries automatically filter by website_id.
  • Zero-Code Configuration: Adding new platform domains requires only updating the PLATFORM_DOMAINS environment variable.
  • Consistent API: The same find_by_host method powers controller concerns, routing constraints, and manual lookups.

Frequently Asked Questions

How does PropertyWebBuilder determine which tenant to load for a request?

The system checks for an X-Website-Slug header first, then falls back to parsing the Host header. It uses WebsiteDomainConfigurable#find_by_host to classify the host as either a custom domain or a platform subdomain, querying the custom_domain or subdomain columns respectively.

Can tenants use their own custom domains instead of subdomains?

Yes. Each Pwb::Website record stores a custom_domain value. When the incoming host does not match a configured platform domain suffix, the system searches this column (case-insensitive, handling www. prefixes). DNS verification tokens can be generated via generate_domain_verification_token! to confirm ownership.

What happens if no matching tenant is found for the request host?

If find_by_host returns nil, the SubdomainTenant concern executes a fallback: Pwb::Current.website ||= Pwb::Website.first. This loads the first website in the database, which is useful for development environments without full DNS setup.

How do I add support for additional platform domain suffixes?

Set the PLATFORM_DOMAINS environment variable to a comma-separated list of domain suffixes (e.g., mybrand.com,staging.dev). If omitted, the system defaults to propertywebbuilder.com,pwb.localhost,e2e.localhost,localhost. The platform_domains method in WebsiteDomainConfigurable parses this list at runtime.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →