Hyperlightweight process hypervisor for single-server deployments
Project description
tenement
Lightweight Rust hypervisor for single-server deployments of many single-tenant processes.
tenement is a process hypervisor for running multi-tenant services on a single server. It spawns one process per tenant, routes requests by subdomain, runs HTTP health checks, and stops idle instances automatically. When the next request arrives, it wakes them back up in under a second.
You write your app as if it serves one customer. tenement runs a copy for each of them.
alice.notes.example.com -> notes:alice -> isolated process + own database
bob.notes.example.com -> notes:bob -> isolated process + own database
Experimental. Actively developed. APIs may change.
Why this exists
systemd can run processes, but it doesn't route requests or stop idle ones. You'd write a unit file for each customer and wire up nginx yourself. Docker adds container overhead you don't need for trusted code on one machine. Kubernetes is absurd for a $5 VPS.
tenement is Fly Machines on your own hardware. Spawn a process, give it a subdomain, let it sleep when nobody's using it, wake it up on the next request.
| systemd | tenement | |
|---|---|---|
| Routing | You configure nginx per service | alice.notes.example.com just works |
| Scale to zero | Processes run forever | Idle processes stop, wake on first request |
| Per-tenant data | You manage it | Each instance gets its own data directory |
| New customer | Write a unit file, reload | ten spawn notes:alice |
| Health + restart | Basic restart-on-failure | HTTP health checks, exponential backoff |
| Deployment | Rolling restart scripts | ten deploy notes:v2 then ten route --from v1 --to v2 |
| Logs | journalctl | ten logs notes:alice with full-text search |
Quick start
Install the CLI and start the server:
cargo install tenement-cli
ten serve --port 8080 --domain localhost
ten token-gen
Here's a complete app. It's a notes API backed by SQLite, and it doesn't know anything about tenants. It just reads PORT from the environment and serves whoever's asking.
# app.py
import os, json, sqlite3
from http.server import HTTPServer, BaseHTTPRequestHandler
PORT = int(os.environ["PORT"])
DB = os.path.join(os.environ.get("DATA_DIR", "."), "notes.db")
def get_db():
os.makedirs(os.path.dirname(DB) or ".", exist_ok=True)
db = sqlite3.connect(DB)
db.execute("CREATE TABLE IF NOT EXISTS notes (id INTEGER PRIMARY KEY, text TEXT)")
return db
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
if self.path == "/health":
self.respond(200, {"status": "ok"})
else:
db = get_db()
notes = [{"id": r[0], "text": r[1]} for r in db.execute("SELECT * FROM notes").fetchall()]
db.close()
self.respond(200, notes)
def do_POST(self):
body = json.loads(self.rfile.read(int(self.headers.get("Content-Length", 0))))
db = get_db()
db.execute("INSERT INTO notes (text) VALUES (?)", (body["text"],))
db.commit()
db.close()
self.respond(201, {"ok": True})
def respond(self, code, data):
self.send_response(code)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(json.dumps(data).encode())
HTTPServer(("127.0.0.1", PORT), Handler).serve_forever()
The config is six lines. You tell tenement what command to run, where the health endpoint is, and what environment variables to set. The {id} in DATA_DIR gets replaced with the tenant name at spawn time.
# tenement.toml
[service.notes]
command = "python3 app.py"
health = "/health"
idle_timeout = 300
isolation = "process"
[service.notes.env]
DATA_DIR = "{data_dir}/{id}"
Now spawn a couple tenants and try it:
ten spawn notes:alice
ten spawn notes:bob
curl -X POST http://alice.notes.localhost:8080/notes \
-H "Content-Type: application/json" -d '{"text":"hello from alice"}'
curl http://alice.notes.localhost:8080/notes # alice's notes
curl http://bob.notes.localhost:8080/notes # bob's notes (empty, different database)
ten ps # list running instances
Alice and Bob each get their own process, their own SQLite database, their own data directory. After 5 minutes of no requests, tenement kills the process. The next request wakes it back up:
| Runtime | Cold wake (median) |
|---|---|
| Python | ~65ms |
| Node.js | ~105ms |
Go (go run) |
~140ms |
How it works
You define a service in your config (the command, health endpoint, and environment variables). When you spawn an instance, tenement allocates a TCP port, sets PORT in the environment, and starts the process. Requests to alice.notes.example.com get proxied to alice's port. tenement polls the health endpoint and restarts unhealthy instances with exponential backoff. When nobody's made a request for a while, it kills the process. When someone does, it spawns a fresh one.
Your app handles its own auth, business logic, and data. tenement handles routing, lifecycle, and isolation. These two layers are completely independent, which means tenement doesn't touch your Authorization headers or care what framework you're using. You can verify this yourself with the auth-test example.
The economics
Most SaaS customers aren't active simultaneously. If you have 1000 customers and only 20 are using the product at any given moment, the traditional approach keeps all 1000 processes running. That's 20GB of RAM across 10 machines at maybe $500/month. With tenement, the 980 idle instances cost nothing. You run 20 processes on one machine for $5/month. The wake-on-request latency is under a second, so users don't notice.
This pairs well with SQLite. Each customer gets their own database file, replicated to S3 with something like walrust or Litestream. No shared Postgres, no connection pooling, no schema migrations that touch everyone's data at once.
What's in the box
Tenement does subdomain routing (alice.api.example.com routes to api:alice), scale-to-zero with wake-on-request, per-tenant data directories, process isolation via Linux namespaces, HTTP health checks with exponential backoff, weighted routing for blue-green and canary deployments, built-in TLS via Let's Encrypt, Prometheus metrics, log capture with full-text search, and a bearer token auth system for the management API with both admin and tenant-scoped tokens.
Commands like uv run python app.py or go run main.go are shell-split automatically, and every instance runs in its own process group so killing it also kills any child processes. No orphans.
CLI
ten serve --port 8080 --domain localhost # start the server
ten spawn notes:alice # create a tenant
ten stop notes:alice # stop a tenant
ten ps # list everything
ten logs notes:alice # tail logs
ten logs -f # follow all logs
ten deploy notes:v2 # deploy a new version
ten route notes --from v1 --to v2 # blue-green swap
ten weight notes:alice 50 # canary: 50% traffic
ten token-gen # admin API token
ten token-gen --tenant alice # scoped token for alice
Set TENEMENT_SERVER to skip passing --server on every command:
export TENEMENT_SERVER=http://localhost:9090
ten ps # just works
Configuration
[settings]
data_dir = "./data"
[service.api]
command = "uv run python app.py"
health = "/health"
isolation = "process" # "process" (macOS/Linux) or "namespace" (Linux, PID isolation)
idle_timeout = 300 # stop after 5 min idle
startup_timeout = 10 # increase for go run (30s)
storage_persist = true # keep data across restarts
memory_limit_mb = 256 # cgroups limit (Linux)
[service.api.env]
DATA_DIR = "{data_dir}/{id}" # {name}, {id}, {data_dir}, {port} all interpolate
Full reference at tenement.dev/guides/03-configuration.
Examples
The examples/ directory has complete working setups you can run immediately:
- hello-world is the simplest possible setup, a bash script and netcat.
- python-fastapi and node-fastify and go-http show the same pattern in three languages.
- multi-runtime runs all three at once with a 56-test integration script that verifies auth, data isolation, and cross-service isolation.
- auth-test demonstrates that tenement passes all request headers through untouched, so your app's auth works exactly as it would without tenement.
- multi-tenant is a per-tenant notes API with SQLite, which is probably closest to what you'd actually build.
Production
For a Hetzner or DigitalOcean VPS with wildcard HTTPS:
# Point *.app.example.com at your server IP, then:
cargo install tenement-cli
cd /opt/myapp
ten init --name myapp --command "python3 app.py"
ten token-gen
ten install --domain app.example.com --caddy --dns-provider cloudflare
ten spawn myapp:customer1
ten spawn myapp:customer2
ten ps
The ten install command creates a systemd service and a Caddyfile with wildcard TLS. Caddy handles HTTPS, tenement handles everything else.
Development
cargo test # 566 tests
cargo bench
Full docs at tenement.dev. See ROADMAP.md for what's next.
License
Apache 2.0
Project details
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distributions
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file tenement-0.2.2.tar.gz.
File metadata
- Download URL: tenement-0.2.2.tar.gz
- Upload date:
- Size: 198.9 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
e647049dfa74b8c9bbe4f14ee1bb262ca7801d8be13bf60bc3232ac3f2da108c
|
|
| MD5 |
bb3c41b0269bb7632ade8785dfb0ce5f
|
|
| BLAKE2b-256 |
b9b5e2cd61ec2d68ebdf08f240066ddfa3b0724fa637e9ac16098be5ad0d9125
|
Provenance
The following attestation bundles were made for tenement-0.2.2.tar.gz:
Publisher:
release.yml on russellromney/tenement
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
tenement-0.2.2.tar.gz -
Subject digest:
e647049dfa74b8c9bbe4f14ee1bb262ca7801d8be13bf60bc3232ac3f2da108c - Sigstore transparency entry: 1461386760
- Sigstore integration time:
-
Permalink:
russellromney/tenement@a69f2e4c5acf879fcb1066559fb3b12e2169a90c -
Branch / Tag:
refs/tags/v0.2.2 - Owner: https://github.com/russellromney
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@a69f2e4c5acf879fcb1066559fb3b12e2169a90c -
Trigger Event:
push
-
Statement type:
File details
Details for the file tenement-0.2.2-py3-none-manylinux_2_28_aarch64.whl.
File metadata
- Download URL: tenement-0.2.2-py3-none-manylinux_2_28_aarch64.whl
- Upload date:
- Size: 7.5 MB
- Tags: Python 3, manylinux: glibc 2.28+ ARM64
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
17e3fb3b3b7108478da2214c5776067e4058f868f420da1f92f84ae28cc7b9c8
|
|
| MD5 |
1e34617a29f0b24337a9e1357678e2d7
|
|
| BLAKE2b-256 |
a87d3cb2e2d1c1e53bcf9e53efb288d9d7aedbe895a7f1922324b43e46d0a32e
|
Provenance
The following attestation bundles were made for tenement-0.2.2-py3-none-manylinux_2_28_aarch64.whl:
Publisher:
release.yml on russellromney/tenement
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
tenement-0.2.2-py3-none-manylinux_2_28_aarch64.whl -
Subject digest:
17e3fb3b3b7108478da2214c5776067e4058f868f420da1f92f84ae28cc7b9c8 - Sigstore transparency entry: 1461387225
- Sigstore integration time:
-
Permalink:
russellromney/tenement@a69f2e4c5acf879fcb1066559fb3b12e2169a90c -
Branch / Tag:
refs/tags/v0.2.2 - Owner: https://github.com/russellromney
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@a69f2e4c5acf879fcb1066559fb3b12e2169a90c -
Trigger Event:
push
-
Statement type:
File details
Details for the file tenement-0.2.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl.
File metadata
- Download URL: tenement-0.2.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
- Upload date:
- Size: 8.1 MB
- Tags: Python 3, manylinux: glibc 2.17+ x86-64
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
d7360fb68433c1f05ae928cb95dc2e14bae73e8c5e637507eb4870c9dec1b5a2
|
|
| MD5 |
5c84999a72df48b69f8cd960977e7d96
|
|
| BLAKE2b-256 |
1b8913c67945b8d41aaab09d385b56bc22f392c1494edd74d37bad060667c417
|
Provenance
The following attestation bundles were made for tenement-0.2.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl:
Publisher:
release.yml on russellromney/tenement
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
tenement-0.2.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl -
Subject digest:
d7360fb68433c1f05ae928cb95dc2e14bae73e8c5e637507eb4870c9dec1b5a2 - Sigstore transparency entry: 1461387380
- Sigstore integration time:
-
Permalink:
russellromney/tenement@a69f2e4c5acf879fcb1066559fb3b12e2169a90c -
Branch / Tag:
refs/tags/v0.2.2 - Owner: https://github.com/russellromney
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@a69f2e4c5acf879fcb1066559fb3b12e2169a90c -
Trigger Event:
push
-
Statement type:
File details
Details for the file tenement-0.2.2-py3-none-macosx_11_0_arm64.whl.
File metadata
- Download URL: tenement-0.2.2-py3-none-macosx_11_0_arm64.whl
- Upload date:
- Size: 7.4 MB
- Tags: Python 3, macOS 11.0+ ARM64
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
37c59ed016f32efbb4aee73a931e8bfb64e69b4474fa8fa2a0ca3ffce28d6f67
|
|
| MD5 |
7955a1270c4d35b6fc6b00b31fc624b0
|
|
| BLAKE2b-256 |
41fd0f7b8301694698c73b3544ae071a8a746601ed8946a76d2d76e55fc863a2
|
Provenance
The following attestation bundles were made for tenement-0.2.2-py3-none-macosx_11_0_arm64.whl:
Publisher:
release.yml on russellromney/tenement
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
tenement-0.2.2-py3-none-macosx_11_0_arm64.whl -
Subject digest:
37c59ed016f32efbb4aee73a931e8bfb64e69b4474fa8fa2a0ca3ffce28d6f67 - Sigstore transparency entry: 1461387028
- Sigstore integration time:
-
Permalink:
russellromney/tenement@a69f2e4c5acf879fcb1066559fb3b12e2169a90c -
Branch / Tag:
refs/tags/v0.2.2 - Owner: https://github.com/russellromney
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@a69f2e4c5acf879fcb1066559fb3b12e2169a90c -
Trigger Event:
push
-
Statement type:
File details
Details for the file tenement-0.2.2-py3-none-macosx_10_12_x86_64.whl.
File metadata
- Download URL: tenement-0.2.2-py3-none-macosx_10_12_x86_64.whl
- Upload date:
- Size: 7.8 MB
- Tags: Python 3, macOS 10.12+ x86-64
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
f457341af1ae0d72dbf610d9e79fa730c156979e1558f87756938ed0a3ca2eb5
|
|
| MD5 |
87b6c31dce9407de53f3d73f4e66f7d2
|
|
| BLAKE2b-256 |
5a4df2c4916017e4f05d5b54464dcfb94125a3593b8b977a221e46e4ca9cc1fc
|
Provenance
The following attestation bundles were made for tenement-0.2.2-py3-none-macosx_10_12_x86_64.whl:
Publisher:
release.yml on russellromney/tenement
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
tenement-0.2.2-py3-none-macosx_10_12_x86_64.whl -
Subject digest:
f457341af1ae0d72dbf610d9e79fa730c156979e1558f87756938ed0a3ca2eb5 - Sigstore transparency entry: 1461386893
- Sigstore integration time:
-
Permalink:
russellromney/tenement@a69f2e4c5acf879fcb1066559fb3b12e2169a90c -
Branch / Tag:
refs/tags/v0.2.2 - Owner: https://github.com/russellromney
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@a69f2e4c5acf879fcb1066559fb3b12e2169a90c -
Trigger Event:
push
-
Statement type: