Skip to content
Back to Blog
Multi-Tenancy systemd SQLite Self-Hosted SaaS

How to Run a Multi-Tenant Bot on One Server With Env Vars

Nur Ikhwan Idris ·

Our first paying tenant runs on the same server as everything else I own. Two environment variables separate their config and their database from mine. No container per customer, no second VPS, no rewrite. The whole isolation layer is a one-line patch and a systemd drop-in.

This post covers what that buys you, the incident that showed me its weak point, and the exact moment you should stop doing it.

This follows on from the self-hosted AI stack and building the assistant itself. This one is about the first time someone paid to use it.

1. The problem

I built a personal assistant that lives on Telegram. It holds a memory database, a set of skills, a config directory, and a chain of model providers. It ran for months as a single-user system, and every path inside it was hardcoded to my home directory.

Then someone asked to use it. A real person, with their own bot, their own conversations, and a monthly fee attached. That turns a personal script into a product, and it raises one question immediately: where does their data live?

2. What I did not build

The obvious answers were all too expensive for one customer.

  • A second server. A new VPS per tenant destroys the margin at a low monthly fee, and it doubles the patching work.
  • A container per tenant. Cleaner isolation, but it means an image, a registry, a compose file, and a deploy story before the first customer is even onboarded.
  • A database per tenant with a tenancy package. Real infrastructure. Also a rewrite of a codebase that had no tenant concept at all.

Each of those is the right answer at some scale. None of them is the right answer at one customer, and building for a scale you do not have is how side projects die.

3. The two env vars

Every piece of per-tenant state in the system falls into one of two buckets: configuration and memory. So the whole isolation model is two variables.

  • HERMES_HOME points at the agent's config directory: skills, provider chain, cron definitions, credentials.
  • BRAIN_DB points at the memory database file.

My own instance keeps the defaults. Each customer gets a directory under customers/ and a systemd unit that sets both variables to point inside it.

# /etc/systemd/system/[email protected] (template unit)
[Service]
Environment=HERMES_HOME=/home/svc/customers/%i/.hermes
Environment=BRAIN_DB=/home/svc/customers/%i/brain.db
ExecStart=/home/svc/.venv/bin/gateway

A template unit means onboarding a tenant is one systemctl enable gateway@name and a directory. Nothing else in the codebase knows that tenants exist.

4. The one-line patch

The memory server had a hardcoded database path. Making it tenant-aware was a single line, written so the single-user install keeps working untouched.

# before
DB_PATH = Path.home() / "brain" / "data" / "brain.db"

# after
DB_PATH = Path(os.environ.get("BRAIN_DB", Path.home() / "brain" / "data" / "brain.db"))

Read the variable, fall back to the old path. Every existing deployment behaves exactly as before, and every new tenant gets its own file. There was no migration and no downtime.

This is the pattern worth taking away. A backward-compatible env var read is the cheapest tenancy seam in existence, and it costs you nothing if you never add a second tenant.

5. What this actually isolates

Be honest about the boundary you have bought, because it is narrower than it looks.

  • Isolated: conversations, memory, skills, credentials, cron jobs, model configuration.
  • Shared: the OS, the Python runtime, the CPU, the disk, and the outbound IP.
  • Not protected: a tenant that fills the disk affects everyone. A bug in the shared runtime affects everyone.

For a handful of tenants who all know you personally, that trade is fine. It stops being fine the moment a tenant is a stranger with a contract.

6. The incident

Three months in, my own instance started answering with a customer's context. Not their data, but their skills and their provider chain.

The cause was mundane. A service file edit had set HERMES_HOME on the wrong unit, so the shared gateway booted with a tenant's config directory. Nothing crashed. Nothing logged an error. The system did exactly what the environment told it to do.

That is the real weakness of env-var tenancy: the isolation lives outside the program, in a file the program never validates. A container gets this right by accident, because the wrong path simply does not exist inside it.

The fix was a startup assertion. If the resolved config path sits under customers/ and the unit is not a tenant unit, refuse to boot.

if "/customers/" in str(HERMES_HOME) and not TENANT_ID:
    raise SystemExit("refusing to boot: tenant path on a non-tenant unit")

Cheap check, and it converts a silent misroute into a loud failure at start time.

7. When to stop doing this

Move to real isolation when any of these becomes true:

  1. A tenant asks where their data is stored and expects a legal answer.
  2. One tenant's load starts affecting another tenant's response time.
  3. You need to restart or upgrade one tenant without touching the rest.
  4. You pass roughly five tenants, where onboarding by hand stops being pleasant.

None of those are true yet, so the two env vars stay. The first tenant pays a flat monthly fee that covers inference and hosting several times over, on hardware that was already running.

The takeaway

You do not need a tenancy framework to get a first customer. You need to find every place your code assumes one user, and put an environment variable in front of it with the old value as the fallback. Then write the assertion that catches the day someone points it at the wrong directory.