All LayBuild workspaces share one Postgres database, one vector table 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 separate table or database per tenant, and where the approach is weakest.

The path of a request

text
request (JWT)
    |
    v
resolve org from current membership  (not from the token's claim)
    |
    +--> Postgres      WHERE "orgId" = :org          conversations, knowledge, settings, tools
    +--> pgvector      WHERE "orgId" = :org          one table: KnowledgeVector
    +--> 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 key

The 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 vector table, filtered by orgId

Every vector we write to the KnowledgeVector table has orgId and agentId columns, and both fields are indexed. Search builds a WHERE clause before anything else. This is the shape of it, simplified from our vector store:

ts
function scopeClauses(options: VectorSearchOptions): Prisma.Sql {
  const orgClause = options.orgId
    ? Prisma.sql`"orgId" = ${options.orgId}`
    : Prisma.sql`"orgId" IS NULL`;
  if (options.orgId && options.agentId) {
    return Prisma.sql`${orgClause} AND ("agentId" = ${options.agentId} OR "agentId" IS NULL)`;
  }
  if (options.orgId) return Prisma.sql`${orgClause} AND "agentId" IS NULL`;
  return orgClause;
}

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 table instead of one per tenant

pgvector's multitenancy pattern recommends a single table partitioned by orgId for many tenants, since each collection (or table) carries its own resource overhead. Plan limits cap a workspace at 25 to 300 uploaded documents and 50 to 500 URLs, so each tenant's slice of the table is small, which is the case that guidance is written for.

The trade-offs, as we see them:

Shared table, orgId filter (what we do)Table per tenantDatabase per tenant
What a missed filter exposesOther tenants' dataNothing outside the tableNothing outside the database
Per-tenant overheadNoneTable metadata, index memoryA whole database to run
Onboarding a tenantWrite rowsCreate table, indexesProvision, migrate
Offboarding a tenantDelete by filterDrop tableDrop database
Schema or index changesOnceOnce per tableOnce per database
Noisy neighboursSharedShared node, separate indexesCan 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.

pgvector also supports partial HNSW indexes per dimension (e.g., CREATE INDEX ... WHERE "dimension" = 384), which co-locates vectors of the same dimension for faster filtered search. We index orgId as a plain btree field and use partial HNSW indexes per embedding dimension.

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

LayBuild runs only as a hosted service, so every workspace sits on the shared setup described above. 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.

Frequently asked questions

What hosting setup keeps tenant data isolated for Next.js and PostgreSQL?

The hosting platform matters less than the isolation model you pick: a shared table filtered by orgId, a table per tenant, or a database per tenant, each with the trade-offs in the table above. LayBuild runs only as a hosted service on the first model, with the filter enforced in application code and no row-level security or customer-managed keys. If you build your own, row-level security in Postgres adds a second line of defence at the cost of setting a session variable on every connection.

How do you enforce tenant isolation in pgvector embeddings?

Give the vector table indexed orgId and agentId columns, and build the tenant filter before anything else in every search, passing IDs as bound parameters. Make a missing org ID restrict results to documents that belong to no org instead of falling through to an unfiltered search, so a bug gives worse answers rather than someone else's documents. Apply the same conditions to the full-text legs and key any response cache by org and agent.

What are the trade-offs between shared database vs. database-per-tenant?

A shared database with orgId scoping costs less to run: schema changes happen once, one connection pool serves every tenant, and there is no database to provision per customer. A database per tenant separates data physically, so a missed filter exposes nothing outside that database, and each tenant can have its own backup schedule. The price is more infrastructure to pay for and every schema change repeated across databases.