# Subdomain-Based Multi-Tenancy with Domain Configuration in PropertyWebBuilder

> Implement subdomain-based multi-tenancy in PropertyWebBuilder. Safely route requests and scope database queries supporting custom domains and platform subdomains.

- Repository: [Ed Tee/property_web_builder](https://github.com/etewiah/property_web_builder)
- Tags: how-to-guide
- Published: 2026-03-01

---

**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`](https://github.com/etewiah/property_web_builder/blob/main/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:

```ruby
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`](https://github.com/etewiah/property_web_builder/blob/main/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.

```ruby

# 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:

```ruby
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:

```ruby
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`](https://github.com/etewiah/property_web_builder/blob/main/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:

```ruby

# 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:

```ruby
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:

```ruby
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:

```ruby
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`](https://github.com/etewiah/property_web_builder/blob/main/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.