Skip to content

Multi-Tenant Host-Header Routing

HomeStar’s multi-tenant architecture automatically scopes lead capture and public listing/search results to the correct brokerage based on the request domain, enabling white-label deployments without frontend configuration.

Routing is automatic and transparent across public endpoints:

  1. Client submits form / requests listings on kroetch.homestar.ink
  2. Request includes X-Forwarded-Host: kroetch.homestar.ink (set by Caddy)
  3. API resolves host via brokerage_domainsbrokerage_id: 2
  4. Lead is created — or listings are filtered — scoped to brokerage_id: 2

No frontend configuration is required. The shared resolver lives in idx_api/utils/brokerage_host.py and is used by every public router that needs per-tenant scoping (/api/contact, /api/listings, search facets, etc.).

A short list of hosts intentionally serves every brokerage’s listings together rather than being scoped to one tenant. These are the unbranded platform surfaces:

  • homestar.ink — marketing site
  • www.homestar.ink
  • app.homestar.ink — unbranded SaaS app where logged-in users land before picking a tenant

When a request arrives on one of these hosts, resolve_brokerage_id_from_host returns None and the listing query runs unfiltered (or rejects public lead submissions outright, since “platform host” is not a valid brokerage to route leads to).

The resolver checks headers in this order:

  1. X-Forwarded-Host — Set by Caddy / any TLS-terminating reverse proxy
  2. Host — Standard HTTP header, used as fallback for direct API traffic

X-Forwarded-Host is checked first because the Astro SSR middleware proxies /api/* to http://idx-api:8000 and replaces Host with idx-api:8000 along the way. Caddy preserves the user-typed hostname in X-Forwarded-Host, so that’s where the real tenant identity lives by the time the API sees the request.

The host string is normalized before lookup: port stripped, lowercased, and a leading www. removed so a domain row stored as elevateidaho.com matches a request to www.elevateidaho.com.

Domains are stored in a separate brokerage_domains table so one brokerage can own multiple hostnames (apex, www, branded subdomain):

SELECT brokerage_id
FROM brokerage_domains
WHERE domain = 'kroetch.homestar.ink';

What happens when no row matches depends on the endpoint:

  • Public listings / search (/api/listings, /api/listings/facets) return results with no brokerage filter applied. Useful for the platform-host case, harmless for a misconfigured tenant.
  • Contact form (/api/contact) returns 404 Not Found with "Domain '...' is not configured." — we deliberately won’t accept an inbound lead we can’t route to anyone.

The resolver also returns None (no scoping) when the matched brokerage has been soft-deleted (disabled_at IS NOT NULL), so disabling a brokerage immediately stops both lead capture and tenant-scoped listing visibility for that domain.

Zero frontend configuration:

  • No hardcoded brokerage IDs in JavaScript
  • Same frontend code deployed across all brokerages
  • White-label sites work immediately after DNS configuration

Security:

  • Brokerages cannot accidentally access each other’s leads
  • Domain ownership proves authorization
  • No API key management for public endpoints

Scalability:

  • Add new brokerages by configuring DNS and database entry
  • No code changes required per deployment
  • Supports unlimited white-label instances

Domain required:

  • Cannot submit leads without a valid domain mapping
  • Testing requires domain configuration or hosts file modification

DNS dependency:

  • Domain must resolve to the API server
  • DNS changes have propagation delay

No IP-based routing:

  • Cannot route based on client IP geolocation
  • Requires domain per brokerage (subdomains acceptable)

If the request domain has no brokerage mapping:

{
"detail": "Domain 'unknown.example.com' is not configured. Unable to process lead."
}

Status: 404 Not Found

Resolution: Add domain mapping in admin panel or database.

If neither Host nor X-Forwarded-Host is present:

{
"detail": "Missing Host header"
}

Status: 400 Bad Request

Resolution: Check reverse proxy configuration. Ensure headers are forwarded correctly.

If the brokerage exists but is disabled:

{
"detail": "Brokerage not found"
}

Status: 404 Not Found

Resolution: Re-enable brokerage in admin panel.

Map a test domain to localhost:

/etc/hosts
127.0.0.1 test.homestar.local

Then submit requests with that domain:

Terminal window
curl -H "Host: test.homestar.local" \
-X POST \
-H "Content-Type: application/json" \
-d '{"email": "test@example.com", ...}' \
http://localhost:8000/api/contact

Override the host header directly:

Terminal window
curl -H "X-Forwarded-Host: www.your-domain.com" \
-X POST \
-H "Content-Type: application/json" \
-d '{"email": "test@example.com", ...}' \
http://localhost:8000/api/contact

Domains are kept in a separate table from brokerages so one brokerage can own apex + www + a branded subdomain:

CREATE TABLE brokerage_domains (
id SERIAL PRIMARY KEY,
brokerage_id INTEGER REFERENCES brokerages(id),
domain VARCHAR(255) UNIQUE NOT NULL,
is_verified BOOLEAN DEFAULT false,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_brokerage_domains_domain ON brokerage_domains(domain);

is_verified records DNS verification state but does not gate routing — the row’s existence is enough to say “this domain is allowed to scope to this brokerage.” This lets you onboard a tenant the same day you receive the DNS request, without waiting for propagation.

CREATE TABLE leads (
id SERIAL PRIMARY KEY,
brokerage_id INTEGER REFERENCES brokerages(id),
email VARCHAR(255) NOT NULL,
first_name VARCHAR(100),
last_name VARCHAR(100),
message TEXT,
source_page VARCHAR(500),
source_domain VARCHAR(255),
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_leads_brokerage_id ON leads(brokerage_id);

The brokerage_id foreign key ensures referential integrity and efficient filtering. source_domain captures the host the lead came in on — useful when a brokerage runs multiple branded domains and wants to see which one converted.

Caddy automatically forwards the Host header:

your-domain.com {
reverse_proxy backend:8000
}

No additional configuration needed.

Ensure X-Forwarded-Host is set:

location / {
proxy_pass http://backend:8000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Real-IP $remote_addr;
}

A brokerage that wants both kroetchpropertygroup.com and kroetch.homestar.ink to scope to the same tenant simply gets two brokerage_domains rows pointing at the same brokerage_id. The resolver doesn’t care whether a hostname is apex, www, or a HomeStar subdomain — the lookup is the same.

The www. stripping in _normalize_host means you don’t have to store both example.com and www.example.com separately; either one matches.