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:
- Custom domain matching: If the host does not end with a configured platform domain, the system searches the
custom_domaincolumn (case-insensitive, stripping optionalwww.prefixes). - Subdomain extraction: If no custom domain matches, the method strips the platform suffix and queries the
subdomaincolumn 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 cycleActsAsTenant.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:
SubdomainTenantparsesX-Website-Slugheaders or theHostheader, delegating toWebsiteDomainConfigurable#find_by_hostto locate the correctPwb::Website. - Dual Domain Support: The system handles both custom domains (direct DNS) and platform subdomains (under configured
PLATFORM_DOMAINSsuffixes). - Automatic Scoping: Resolved tenants are wired into
ActsAsTenant.current_tenant, ensuring all database queries automatically filter bywebsite_id. - Zero-Code Configuration: Adding new platform domains requires only updating the
PLATFORM_DOMAINSenvironment variable. - Consistent API: The same
find_by_hostmethod 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:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →