# Multi-tenant Go with Postgres row-level security: how Hosbah is built on ToGO

> Hosbah is a self-hosted deploy platform written in Go on ToGO. This case study shows the real architecture: plugins, Postgres row-level security, signed agent operations and an MCP server, with code and counts taken from the repo.

*2026-10-19T12:49:05Z*

# Multi-tenant Go with Postgres row-level security: how Hosbah is built on ToGO

Hosbah is a self-hosted deploy platform: you connect your own servers, point it at a git repository, and every push builds and ships behind a health check. Underneath it is a multi-tenant Go application, and building a multi-tenant Go app with Postgres row-level security is most of what this case study is about.

Hosbah is built on [ToGO](https://github.com/togo-framework/togo), the Go + sqlc + Atlas + React framework. Everything below comes from the public repository, [github.com/fadymondy/hosbah](https://github.com/fadymondy/hosbah). Where I give a number, I say how I counted it.

![Hosbah on ToGO: case study cover](/uploads/sites/c797a67b-97b7-4ed5-8459-baafb6981307/media/hosbah-togo-case-study-en.png)

## 1. The problem

Hosbah has to hold other people's secrets and run commands on their servers. That sets a high bar for three things:

- **Tenant isolation.** A team must never see another team's servers, apps, environment variables or logs, even if one handler forgets a `WHERE` clause.
- **Trust between the control plane and the servers it manages.** The control plane tells an agent on a customer's server what to run. The agent has to be able to refuse anything it did not expect.
- **Agent access.** People want to ask an AI agent "is the deploy healthy?" and let it read logs or trigger a rollback, without handing the agent a root shell.

## 2. Why ToGO

Hosbah needed a Go backend with auth, authorization, an audit log, a queue, realtime and migrations, plus a typed way to generate database code. ToGO gives those as plugins and a `togo` CLI that runs `sqlc`, `gqlgen` and the OpenAPI export in one step (`togo generate`).

I also build other products on ToGO, so the same conventions (plugins, `togo.yaml`, the `.claude` tree for agents) carry over from one project to the next. That is a reason for me, not a general argument.

## 3. Architecture

**Plugins.** `togo.yaml` lists three: `auth`, `audit` and `authz`. The file `internal/plugins/plugins.gen.go` blank-imports nine:

```go
// internal/plugins/plugins.gen.go
import (
	_ "github.com/togo-framework/audit"
	_ "github.com/togo-framework/auth"
	_ "github.com/togo-framework/authz"
	_ "github.com/togo-framework/cache"
	_ "github.com/togo-framework/db-postgres"
	_ "github.com/togo-framework/i18n"
	_ "github.com/togo-framework/queue"
	_ "github.com/togo-framework/realtime"
	_ "github.com/togo-framework/storage"
)
```

`go.mod` also requires `orm`, `faker` and `validation` from the same organisation.

**Three binaries and a web app.** `cmd/api` is the control plane (REST at `/api`, docs at `/docs`, MCP at `/api/mcp`). `cmd/agent` is `hosbah-agent`, a static Go binary that runs under systemd on each managed server. `cmd/hosbah` is a CLI. The web UI is Next.js in `web/`, in English and Arabic (RTL).

**Tenancy.** Every tenant row has a `team_id`. Team routes are path-scoped (`/api/teams/{team}/...`) and a team you are not in answers 404, the same as one that does not exist. Every tenant query runs through one helper, `tenancy.Tx`, which pins the user and team on the transaction, and Postgres row-level security backs up every `WHERE team_id`.

**Agents.** The agent dials out to the control plane over a websocket and answers a signed challenge. The control plane signs each operation with ed25519; the agent verifies it against the key it pinned at enrolment and refuses tampered, unsigned, expired, misaddressed or unknown operations. Operation kinds are typed, not shell strings.

**MCP.** The team MCP endpoint takes team-bound tokens. Every tool declares a scope (read, deploy, write, secrets:read, destructive) and a test fails if one does not.

## 4. Real code

The transaction helper, from `internal/tenancy/tx.go`:

```go
func Tx(ctx context.Context, sqlDB *sql.DB, s Scope, fn func(q *db.Queries, tx *sql.Tx) error) error {
	tx, err := sqlDB.BeginTx(ctx, nil)
	if err != nil {
		return err
	}
	defer tx.Rollback() //nolint:errcheck // no-op after Commit

	if err := apply(ctx, tx, s); err != nil {
		return err
	}
	if err := fn(db.New(tx), tx); err != nil {
		return err
	}
	return tx.Commit()
}
```

In the same file, re-pointing an open transaction at a team uses `set_config` with its third argument `true`, which is the parameterised form of `SET LOCAL`:

```go
_, err := tx.ExecContext(ctx, `SELECT set_config('app.team_id', $1, true)`, teamID)
```

The setting disappears when the transaction ends, so a pooled connection cannot carry one team's context into the next request.

Team roles, from `internal/tenancy/roles.go`:

```go
const (
	RoleOwner    = "owner"
	RoleAdmin    = "admin"
	RoleDeployer = "deployer"
	RoleViewer   = "viewer"
)
```

These plug into the ToGO `authz` plugin, which is default-deny: an ability that is not granted is refused.

The route tiers in `internal/server/authorization.go` follow the same idea. Everything under `/api` is closed unless it is named, in one of five tiers: blocked, public, token (MCP), member, and platform admin as the default for anything unlisted. A newly generated route is therefore reachable only by a platform administrator until someone decides otherwise.

## 5. Numbers

Counted on the `main` branch of the repository on 2026-10-19:

| What | Count | How I counted |
|---|---|---|
| Versioned migrations | 124 | `.sql` files in `db/atlas/migrations` |
| Schema fragments | 103 | files in `internal/db/schema` |
| ToGO plugins loaded | 9 | blank imports in `internal/plugins/plugins.gen.go` |
| MCP tools, team server | 282 | `Name: "...", Scope:` definitions in `internal/mcp/*.go` (not tests) |
| Test files | 262 | `*_test.go` files in the repo |
| Team roles / abilities | 4 / 13 | constants in `internal/tenancy/roles.go` |

The MCP count only covers tools declared on one line in that pattern. The platform-admin server is declared differently and is not in the 282, so treat it as "at least".

I have no benchmark or user numbers to give, so I give none.

## 6. What was hard, and how ToGO helped

**Generated CRUD does not know about teams.** ToGO can generate REST and GraphQL for a resource. For a multi-tenant product that is a hazard, because the generated handlers have no team. Hosbah does not mount them for tenant tables; tenant data is reachable only through hand-written handlers in `internal/api`. This is work you do yourself.

**Removing a plugin is a manual edit.** The header of `plugins.gen.go` says it: `auth-dev` and `dashboard` were removed by hand because the CLI has no uninstall. `auth-dev` is a development login convenience and not something to ship. I also left `auth-mfa` out deliberately after reviewing it, and the file records the reason. If you use plugins, read their routes.

**SQLite default, Postgres reality.** The framework's built-in driver is SQLite. Hosbah uses `db-postgres`, and the workflow in the README is to generate a schema fragment with `togo make:resource`, convert it to Postgres by hand, then run `togo migrate:diff`. Row-level security policies are hand-written migrations (`*_row_level_security.sql`), since the generator does not know about them.

**Where ToGO helped.** One kernel, one config file and a plugin list meant that auth, audit, authz, queue, realtime and storage were already there, and `togo generate` kept the sqlc, gqlgen and OpenAPI outputs in step. A migration ledger (`schema_migrations`, with a checksum for each file) stops the run if an applied migration is edited.

## 7. Links

- Product: [hosbah.com](https://hosbah.com)
- Product article: [Self-hosted PaaS on Proxmox: push to deploy](https://hosbah.com/en/blog/self-hosted-paas-proxmox-push-to-deploy)
- Founder post: [Why I built Hosbah](https://fadymondy.com/en/blog/why-i-built-hosbah)
- GitHub: [fadymondy/hosbah](https://github.com/fadymondy/hosbah)
- Product Hunt: [Hosbah](https://www.producthunt.com/products/hosbah)
- ToGO: [github.com/togo-framework/togo](https://github.com/togo-framework/togo)
- Showcase: [to-go.dev/en/showcase](https://to-go.dev/en/showcase)

## FAQ

### How does Hosbah isolate teams from each other?
Every tenant row carries a `team_id`. Each query runs inside `tenancy.Tx`, which sets `app.user_id` and `app.team_id` for the transaction, and Postgres row-level security (FORCE, with a non-superuser role) enforces the policies. Handlers are team-scoped by path too, so there are two layers.

### Does ToGO generate the tenant-aware API?
No. Generated CRUD has no notion of a team, so Hosbah does not mount it for tenant tables.

### Which ToGO plugins does Hosbah load?
Nine: audit, auth, authz, cache, db-postgres, i18n, queue, realtime and storage.

### Is Hosbah open source?
Yes. The repository is public and GitHub lists the license as AGPL-3.0.

## Next steps

- Read the [ToGO repository](https://github.com/togo-framework/togo) and the [showcase](https://to-go.dev/en/showcase).
- See what Hosbah does for its users in the [product article](https://hosbah.com/en/blog/self-hosted-paas-proxmox-push-to-deploy).
- Read the code: [`internal/tenancy`](https://github.com/fadymondy/hosbah/tree/main/internal/tenancy).

---

Source: https://to-go.dev/en/blog/hosbah-togo-case-study
