All LayBuild workspaces share one Postgres database, one Qdrant collection and one Redis instance. Nothing at the infrastructure level separates your knowledge base from another company's. The separation is in the application: every tenant-owned row and vector carries an orgId, queries and retrieval filters include it, and uploaded files are encrypted with a key derived for your organization. This post shows how that works, why we chose it over a collection or database per tenant, and where the approach is weakest.
The path of a request
request (JWT)
|
v
resolve org from current membership (not from the token's claim)
|
+--> Postgres WHERE "orgId" = :org conversations, knowledge, settings, tools
+--> Qdrant filter must: orgId = :org one collection: laybuild_knowledge_docs
+--> Postgres FTS WHERE k."orgId" = :org full-text and Q&A retrieval legs
+--> Redis keys include org and agent id response cache
+--> file storage orgs/{orgId}/... AES-256-GCM, per-org derived keyThe organization comes from the user's current membership record. A JWT can outlive a membership (someone is removed from a workspace, their token still has days to run), so routes that care resolve the org from the membership table on each request rather than trusting the orgId claim in the token. If a request names a workspace with an x-organization-id header, the user must be a member of it (or the platform owner) or the request is refused. The chat widget works differently because visitors aren't members: it resolves the org from the embedded agent or workspace ID (or the page's origin) and checks that origin against the widget's allowed-domain list.
Retrieval: one Qdrant collection, filtered by payload
Every point we write to Qdrant has orgId and agentId in its payload, and both fields have keyword indexes. Search builds a must filter before anything else. This is the shape of it, simplified from our Qdrant client:
const must: Array<Record<string, unknown>> = [];
// No org means platform-level docs only. There is no branch that
// searches without an org condition.
if (orgId) {
must.push({ key: 'orgId', match: { value: orgId } });
} else {
must.push({ is_null: { key: 'orgId' } });
}
// Docs for this AI agent, or docs shared across the whole workspace.
if (orgId && agentId) {
must.push({
should: [
{ key: 'agentId', match: { value: agentId } },
{ is_null: { key: 'agentId' } },
],
});
}
const body = { vector, limit: topK, filter: { must }, with_payload: true };The detail that matters is the else branch. A missing org ID doesn't fall through to an unfiltered search; it restricts results to documents that belong to no org. A bug that loses the org ID gives you worse answers, not someone else's documents.
The Postgres legs of hybrid retrieval (full-text search over chunks and over Q&A pairs) apply the same conditions in SQL: k."orgId" = ${orgId} AND (k."agentId" = ${agentId} OR k."agentId" IS NULL), passed as bound parameters. The response cache in Redis keys entries by org and agent, so a cached answer for one workspace can't be served to another that asks the same question.
Files and secrets: keys bound to the org
Uploaded files are stored under orgs/{orgId}/{category}/{key} and encrypted with AES-256-GCM. The key for each org is derived with HKDF-SHA256 from a master key (your ENCRYPTION_KEY, or JWT_SECRET if that's unset) using the org ID as input. The org ID and storage path are also bound in as additional authenticated data, so a file copied to another org's path fails to decrypt rather than decrypting as the wrong tenant's data.
Stored secrets (LLM API keys, SMTP passwords, widget keys) use AES-256-GCM with the org ID as additional authenticated data. Our isolation tests check that a secret encrypted for one org can't be decrypted as another.
Conversations and messages are not encrypted at the application level. They're protected by database access control and by whatever disk encryption your database host provides.
Why a shared collection instead of one per tenant
Qdrant's own multitenancy guide recommends a single collection partitioned by payload for many tenants, noting that "each collection carries its own resource overhead" (Qdrant docs, checked September 2026). Plan limits cap a workspace at 10 to 50 uploaded documents and 5 to 50 URLs, so each tenant's slice of the collection is small, which is the case that guidance is written for.
The trade-offs, as we see them:
| Shared collection, payload filter (what we do) | Collection per tenant | Database per tenant | |
|---|---|---|---|
| What a missed filter exposes | Other tenants' data | Nothing outside the collection | Nothing outside the database |
| Per-tenant overhead | None | Collection metadata, index memory | A whole database to run |
| Onboarding a tenant | Write rows | Create collection, indexes | Provision, migrate |
| Offboarding a tenant | Delete by filter | Drop collection | Drop database |
| Schema or index changes | Once | Once per collection | Once per database |
| Noisy neighbours | Shared | Shared node, separate indexes | Can be fully separate |
We picked the left column because of the operating cost at our size, and we accept its main risk: isolation depends on every code path remembering the filter. That's the honest summary of shared-infrastructure multi-tenancy, ours included.
Qdrant also offers an is_tenant flag on keyword payload indexes, which co-locates each tenant's vectors for faster filtered search. We index orgId as a plain keyword field today.
Where this approach is weakest
- No row-level security in Postgres. The database itself doesn't stop a query that forgets
WHERE "orgId" = .... Correctness depends on application code, code review and tests. Row-level security would add a second line of defence at the cost of setting a session variable on every connection, and we haven't done it. - Shared capacity. The AI reply queue, Redis and the database are shared, so a burst in one workspace can slow others. Plan limits and rate limits bound it; they don't remove it.
- One master key. Per-org keys are derived from a single master secret. Anyone with the master key and the database can decrypt every tenant's files. There are no customer-managed keys.
- Plaintext conversations. See above.
If you need stronger isolation
Run your own deployment. LayBuild's source is on GitHub, and a self-hosted instance with one workspace on it gives you a database, a Qdrant instance and a master key that serve no one else. Our self-hosting guide covers the docker-compose stack and the settings to change before production.
If you stay on the hosted service, the useful questions for your own review are the ones above: what's encrypted, who holds the key, and what enforces the tenant boundary. Ask us at [email protected] for anything this post doesn't answer.
