Run Sheshnag for your institution¶
Who this is for: you are standing Sheshnag up on hardware your organisation controls, so that your researchers can submit batches and your GPUs can serve them. Budget an afternoon for the first one.
Verified against code: 2026-08-26. The database, service and TLS sections are
carried over unchanged from the former setup.md and services.md, which were audited
against live code on 2026-08-02.
What you are standing up¶
Three pieces, and only the first two are the control plane:
| Piece | Port | What it is |
|---|---|---|
| Backend | 8000 | FastAPI. Validates batches, schedules them, tracks workers, serves results. Owns the database. |
| Frontend | 3000 | Next.js dashboard. What your users and providers log into. |
| Worker daemon | — | Runs on GPU machines, not usually this one. Polls the backend for work. |
The control plane needs no GPU. It needs Postgres, a domain, and a certificate. GPUs arrive later, one provider at a time, and each one installs itself — see Lend your GPU.
You are done when: the dashboard is reachable over HTTPS, the default admin password is changed, the model catalogue is loaded, and one worker has connected and claimed a job.
Before you start¶
| You need | Notes |
|---|---|
| A Linux host | Everything below is rootless except TLS. |
| Python 3.10+, Node.js 20+, npm 10+ | Backend and frontend respectively. |
| PostgreSQL 14+ | A reachable server and an account on it. You do not need to own or administer it — see Create the database. |
| A domain and DNS | Pointing at this host, so certbot can issue a certificate. |
| Administrator help, once | For nginx, the certificate, and loginctl enable-linger. Nothing else needs root. |
If you only want to try it on a laptop, you do not need this guide — the localhost path in Change the code is shorter and skips TLS entirely.
1. Create the database¶
Do this once per environment, before the backend starts for the first time.
Base.metadata.create_all() creates the app's tables, but never the
database or the role — those must already exist or startup fails on connect.
Which path you take depends on what your Postgres account is allowed to do. Find out first:
psql "postgresql://USER:PASSWORD@HOST:PORT/EXISTING_DB" \
-tAc "select rolsuper, rolcreatedb from pg_roles where rolname = current_user;"
Two booleans come back, superuser and createdb — e.g. f|t.
Password in the URL: URL-encode any of
@ : / ? # % &in it —p@ssmust be writtenp%40ssor the URL parses as a different host.
A. You can create databases (rolcreatedb = t)¶
The normal case, and the one to prefer — the app gets a database it owns.
createdb -h HOST -p PORT -U USER sheshnag
# backend/.env
DATABASE_URL=postgresql://USER:PASSWORD@HOST:PORT/sheshnag
B. You cannot create databases (rolcreatedb = f)¶
Ask whoever administers the server for a database of your own — it keeps the app's 15 tables isolated and makes backups and restores independent:
CREATE ROLE sheshnag LOGIN PASSWORD '<strong-password>';
CREATE DATABASE sheshnag OWNER sheshnag;
If that isn't available, a dedicated schema inside a database you already
have works without any elevated privilege — creating a schema needs only
CREATE on the database, which an ordinary application account usually has:
psql "postgresql://USER:PASSWORD@HOST:PORT/EXISTING_DB" \
-c "CREATE SCHEMA IF NOT EXISTS sheshnag AUTHORIZATION USER;"
Then point the app at that schema through the connection URL. No code or
model changes are needed — create_all() follows search_path:
# backend/.env — note the URL-encoded '=' (%3D)
DATABASE_URL=postgresql://USER:PASSWORD@HOST:PORT/EXISTING_DB?options=-csearch_path%3Dsheshnag
Every table then lives in the sheshnag schema, invisible to anything using
that database's public schema.
Confirm before moving on¶
Connection problems are the most common first-run failure, and they are much easier to read here than in a uvicorn traceback:
psql "postgresql://USER:PASSWORD@HOST:PORT/DATABASE" -c '\conninfo'
Resetting¶
There is no migration tool, so dropping and recreating is also how you pick up a schema change. Match it to the path you used:
dropdb -h HOST -p PORT -U USER sheshnag && createdb -h HOST -p PORT -U USER sheshnag # path A
psql "$DATABASE_URL" -c "DROP SCHEMA sheshnag CASCADE; CREATE SCHEMA sheshnag;" # path B, schema
2. Configure¶
Two env files, one per component. Copy the examples and edit:
cp .env.example .env.local # frontend
cp backend/.env.example backend/.env # backend
The full variable reference lives in Configuration. These are the ones that separate a deployment from a laptop:
| Variable | File | Why it matters here |
|---|---|---|
SECRET_KEY |
backend/.env |
Signs every session token. Generate one: openssl rand -hex 32. The shipped default is for development; the backend logs a warning at startup if you leave it. |
DATABASE_URL |
backend/.env |
From section 1. |
CORS_ORIGINS |
backend/.env |
Defaults to *, which is wrong the moment the service is reachable. Set it to your dashboard's URL. |
GOOGLE_CLIENT_ID |
backend/.env |
Must be identical to NEXT_PUBLIC_GOOGLE_CLIENT_ID in the frontend file, and registered against your production domain. |
NEXT_PUBLIC_BACKEND_URL |
.env.local |
Your HTTPS URL, not localhost — the browser calls this directly. |
NEXT_PUBLIC_GOOGLE_CLIENT_ID |
.env.local |
See above. |
FRONTEND_URL |
backend/.env |
Used to build password-reset and invite links. Wrong value here means users get emails pointing at localhost. |
MAILGUN_* |
backend/.env |
Optional. Without them, password resets and invitations are silently skipped — which on a real deployment means users cannot recover accounts. |
The frontend bakes its variables in at build time
NEXT_PUBLIC_* values are compiled into the bundle by next build, not read
at runtime. Editing .env.local does nothing until the frontend is rebuilt —
and its systemd unit rebuilds on restart, so systemctl --user restart
sheshnag-frontend is how you apply a change.
Google sign-in has its own setup — see Google OAuth.
3. First start, and securing the admin account¶
On first startup Base.metadata.create_all() creates every table and the
model catalogue is seeded. A default superadmin is created too:
admin@platform.com / admin.
Change that password immediately. It is published in this documentation, so until you change it anyone who can reach your dashboard has superadmin.
4. Run it as services¶
Everything in this section is rootless — systemctl --user, no sudo. Only
section 6 needs an administrator.
systemd user session & linger¶
All three units use systemctl --user. To have them survive logout you need linger enabled:
loginctl enable-linger "$USER"
If that fails (common on managed distros), ask an admin to run:
sudo loginctl enable-linger "$(whoami)"
Without linger the services stop when your session ends. They still work fine for interactive development.
See Start on boot below for the full boot checklist.
Backend¶
The backend (FastAPI + uvicorn) needs a virtual environment and an env file with at least SECRET_KEY, DATABASE_URL, and GOOGLE_CLIENT_ID.
Create the unit¶
mkdir -p ~/.config/systemd/user/
cp scripts/sheshnag-backend.service ~/.config/systemd/user/
Edit the unit to match your paths, then:
systemctl --user daemon-reload
systemctl --user enable sheshnag-backend
systemctl --user start sheshnag-backend
Status and logs:
systemctl --user status sheshnag-backend
journalctl --user -u sheshnag-backend -f
Setup checklist for backend service¶
| Step | Command |
|---|---|
| Create venv in your home dir | python3 -m venv ~/.sheshnag/venv |
| Install deps | ~/.sheshnag/venv/bin/pip install -r backend/requirements.txt |
| Copy & edit env file | Edit ~/.sheshnag/backend/.env — set SECRET_KEY, DATABASE_URL, GOOGLE_CLIENT_ID |
The unit assumes:
- Repo cloned at ~/.sheshnag/
- venv at ~/.sheshnag/venv/
- Backend env file at ~/.sheshnag/backend/.env
Adjust the paths in the unit if your layout differs.
Production note: CORS¶
CORS origins are configured through CORS_ORIGINS in the backend env file — no
code change is needed. It defaults to *, which is fine on a loopback dev box
but should be narrowed before the service is reachable from anywhere else:
# in ~/.sheshnag/backend/.env
CORS_ORIGINS=https://sheshnag.example.com
Comma-separate multiple origins. Setting concrete origins also flips
allow_credentials on — browsers reject a wildcard origin when credentials are
allowed, so the backend derives the two together rather than letting them be set
independently. Restart the unit to pick up the change:
systemctl --user restart sheshnag-backend
See the environment variable reference in the configuration guide for the full list.
Frontend¶
Next.js in production runs next build once (ahead of time) then next start. The systemd unit wraps both steps: the ExecStartPre builds, then ExecStart serves on port 3000.
Create the unit¶
mkdir -p ~/.config/systemd/user/
cp scripts/sheshnag-frontend.service ~/.config/systemd/user/
Edit the unit to match your paths, then:
systemctl --user daemon-reload
systemctl --user enable sheshnag-frontend
systemctl --user start sheshnag-frontend
Status and logs:
systemctl --user status sheshnag-frontend
journalctl --user -u sheshnag-frontend -f
Setup checklist for frontend service¶
| Step | Command |
|---|---|
| Install node modules | cd ~/.sheshnag && npm install |
| Copy & edit env file | cp .env.example ~/.sheshnag/.env.local — set NEXT_PUBLIC_BACKEND_URL, NEXT_PUBLIC_GOOGLE_CLIENT_ID |
The unit assumes:
- Repo cloned at ~/.sheshnag/
- Production env file at ~/.sheshnag/.env.local
Rebuild after env changes: whenever you edit .env.local, restart the service to trigger a fresh build:
systemctl --user restart sheshnag-frontend
Optional — a worker on this machine¶
Only if the control-plane host also has a GPU you intend to lend. Most institutions run workers on separate machines; see Lend your GPU for the provider-side install.
The daemon already has a unit template at scripts/gpu-daemon.service. Install it:
mkdir -p ~/.config/systemd/user/
cp scripts/gpu-daemon.service ~/.config/systemd/user/
systemctl --user daemon-reload
systemctl --user enable gpu-daemon
systemctl --user start gpu-daemon
Status and logs:
systemctl --user status gpu-daemon
journalctl --user -u gpu-daemon -f
Configuration layers (highest → lowest)¶
- CLI args passed in the unit's
ExecStart - Environment variables (
DAEMON_*) set in~/.gpu-daemon/.env - YAML config:
~/.gpu-daemon/config.yaml - Defaults (see Configuration)
The unit reads EnvironmentFile=-%h/.gpu-daemon/.env so you can override any daemon setting without editing the unit.
Ollama runtime as a user service¶
If the worker machine doesn't have Ollama installed, install it rootlessly via scripts/install.sh which places a user-local binary at ~/.gpu-daemon/bin/ollama. The companion unit scripts/ollama.service runs it:
cp scripts/ollama.service ~/.config/systemd/user/
systemctl --user daemon-reload
systemctl --user enable --now ollama
The gpu-daemon unit lists After=ollama.service so Ollama starts first.
5. Start on boot¶
Two things have to be true for a unit to come up on boot:
- The unit is enabled —
systemctl --user enable <unit>links it intodefault.target, which every unit in this repo declares under[Install]. - Linger is on for your user — without it, your user's systemd instance is only started when you log in, so an enabled unit still waits for a login.
Enable everything you intend to run at boot, then turn on linger:
loginctl enable-linger "$USER"
systemctl --user daemon-reload
systemctl --user enable ollama gpu-daemon # worker machine
systemctl --user enable sheshnag-backend sheshnag-frontend # control plane
Use enable --now instead of enable to start the units in the same command
rather than issuing a separate start.
Ordering is already encoded in the units — gpu-daemon declares
After=ollama.service and sheshnag-frontend declares
After=sheshnag-backend.service — so you don't need to sequence the enables.
Note that After= only orders startup; it does not pull in a unit you forgot to
enable.
Verify¶
Confirm linger is on and each unit is enabled:
loginctl show-user "$USER" --property=Linger # expect Linger=yes
systemctl --user is-enabled gpu-daemon sheshnag-backend sheshnag-frontend
systemctl --user list-unit-files --state=enabled
The real test is a reboot. Afterwards, without logging in interactively:
systemctl --user status gpu-daemon sheshnag-backend sheshnag-frontend
Note that sheshnag-frontend runs next build in ExecStartPre, so it can sit
in activating for several minutes after boot before it reports active — that
is expected, and TimeoutStartSec=900 in the unit allows for it.
Troubleshooting boot-time starts¶
| Symptom | Cause | Fix |
|---|---|---|
| Unit only runs while you're logged in | Linger not enabled | sudo loginctl enable-linger "$(whoami)" |
is-enabled reports disabled |
Never enabled, or unit copied after the last enable |
systemctl --user enable <unit> |
| Unit not found after copying it in | systemd hasn't rescanned unit files | systemctl --user daemon-reload |
| Starts but immediately fails at boot | Env file or venv path wrong, or a dependency (network, DB) not up yet | journalctl --user -u <unit> -b |
journalctl --user -u <unit> -b shows logs from the current boot only, which is
the quickest way to see why something failed to come up.
Disabling startup¶
To stop a service from starting at boot while keeping it installed:
systemctl --user disable <unit> # leave it running for now
systemctl --user disable --now <unit> # stop it as well
6. Put it behind TLS¶
Everything above this point runs without root. The items below need an administrator, and they are what make the deployment reachable by anyone other than you.
Nginx reverse proxy¶
To serve both frontend and backend from one domain on port 80/443:
server {
listen 80;
server_name sheshnag.example.com;
location /api/ {
proxy_pass http://127.0.0.1:8000/v1/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
location /v1/ {
# Direct backend API access (e.g. daemon calls)
proxy_pass http://127.0.0.1:8000/v1/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
location /workers/ {
proxy_pass http://127.0.0.1:8000/workers/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
# This documentation, built as static HTML and served alongside the
# product. See "Serve the documentation" below.
#
# The exact-match redirect matters: `location /docs/` does not match a
# bare `/docs`, which would otherwise fall through to the frontend and
# 404 — the one URL people type by hand.
location = /docs { return 301 /docs/; }
location /docs/ {
alias /home/sheshnag/.sheshnag/site/;
index index.html;
}
location / {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
Adjust port mappings and paths for your deployment. The backend serves on :8000, frontend on :3000.
Apache reverse proxy¶
Institutional machines often already run Apache, and often already terminate TLS
with a commercial certificate rather than Let's Encrypt. This is the same
deployment on httpd, split across two subdomains — frontend and API — which is
the other shape these installs tend to take:
<VirtualHost *:80>
ServerName sheshnag.example.edu
Redirect permanent / https://sheshnag.example.edu/
</VirtualHost>
<VirtualHost *:443>
ServerName sheshnag.example.edu
SSLEngine on
SSLCertificateFile /etc/httpd/ssl/commercial.crt
SSLCertificateKeyFile /etc/httpd/ssl/commercial.key
SSLCertificateChainFile /etc/httpd/ssl/chain.crt
ProxyPreserveHost On
ProxyRequests Off
RequestHeader set X-Forwarded-Proto "https"
RequestHeader set X-Forwarded-Port "443"
# Documentation — see "Serve the documentation" below.
# This exclusion must come BEFORE "ProxyPass /", which is greedy:
# placed after it, every /docs request is proxied to Next.js and 404s.
ProxyPass /docs !
Alias /docs /var/www/sheshnag-docs
<Directory /var/www/sheshnag-docs>
Options -Indexes +FollowSymLinks
AllowOverride None
Require all granted
DirectoryIndex index.html
</Directory>
ProxyPass / http://127.0.0.1:3000/
ProxyPassReverse / http://127.0.0.1:3000/
ErrorLog logs/sheshnag_error.log
CustomLog logs/sheshnag_access.log combined
</VirtualHost>
<VirtualHost *:443>
ServerName sheshnag-api.example.edu
SSLEngine on
SSLCertificateFile /etc/httpd/ssl/commercial.crt
SSLCertificateKeyFile /etc/httpd/ssl/commercial.key
SSLCertificateChainFile /etc/httpd/ssl/chain.crt
ProxyPreserveHost On
ProxyRequests Off
RequestHeader set X-Forwarded-Proto "https"
RequestHeader set X-Forwarded-Port "443"
ProxyPass / http://127.0.0.1:8000/
ProxyPassReverse / http://127.0.0.1:8000/
</VirtualHost>
Two details in the docs block are load-bearing, and both fail quietly:
ProxyPass /docs !must precedeProxyPass /. Apache takes the first matching rule. Below it, the exclusion never fires.Alias /docscarries no trailing slash on either side. Written asAlias /docs/ /var/www/sheshnag-docs/a bare/docsstops matching, falls through to the proxy and 404s. The slashless form matches both, andmod_dirredirects/docsto/docs/for you — Apache's equivalent of the nginxlocation = /docs { return 301 /docs/; }above.
On a split-subdomain layout like this one, set NEXT_PUBLIC_BACKEND_URL to the
API subdomain (https://sheshnag-api.example.edu) rather than a path on the
frontend host, and set the backend's CORS_ORIGINS to the frontend origin —
both in Configuration. Leaving CORS_ORIGINS
at its * default disables credentialed requests, because the two are mutually
exclusive in the CORS spec.
Serve the documentation¶
Your deployment serves its own copy of these docs, at /docs/. That is
deliberate, and it is the answer to a question the documentation plan left open.
Two reasons it works this way rather than from one central website:
- The docs match the code you are actually running. A deployment on an older version serves that version's documentation. A single central site would describe whatever is newest, which is not what your users have.
- It is the only channel that reaches your providers and batch users. They cannot clone the repository — most of them will never see it. What they do have is your URL, because you gave it to them.
Build the site once, from the repository you already cloned:
python3 -m venv .venv-docs
.venv-docs/bin/pip install -r docs/requirements.txt
.venv-docs/bin/mkdocs build # writes ./site/
site/ is plain static HTML — no process to run, nothing to keep alive. Rebuild
it whenever you pull:
git pull --ff-only
.venv-docs/bin/mkdocs build
Where to put the built site¶
The alias in the nginx block, or the Alias in the Apache one, must point at
that site/ directory. Point it at site/, never at the repository root —
the working tree holds .env files and database dumps, and aliasing its parent
would serve all of them over HTTPS.
Two placements, and the choice is really about who needs root:
Build straight into /var/www/ (recommended). Ask your administrator, once,
to create the directory and hand you ownership:
mkdir -p /var/www/sheshnag-docs
chown sheshnag:sheshnag /var/www/sheshnag-docs
restorecon -Rv /var/www/sheshnag-docs # RHEL/SELinux; harmless elsewhere
After that you rebuild with no privileges at all, and never need them again:
git pull --ff-only
.venv-docs/bin/mkdocs build -d /var/www/sheshnag-docs
No reload after a rebuild — the web server reads the files off disk, so the update is live immediately.
Or alias the repository's own site/. This works, and keeps everything in
one place, but the web server then has to reach into a home directory. That
costs the same single root visit and adds two failure modes:
- Traversal. Every component of the path needs
o+x, and files needo+r. Diagnose withnamei -om <repo>/site/index.html. RHEL creates home directories as700;chmod o+x ~fixes it — prefer711over755so other local users can pass through without listing your files. -
SELinux. On RHEL the files carry the wrong label and home-directory serving is off. Both need root, once:
semanage fcontext -a -t httpd_sys_content_t "/home/sheshnag/sheshnag/site(/.*)?" restorecon -R /home/sheshnag/sheshnag/site setsebool -P httpd_enable_homedirs 1Use
semanage fcontextrather than a barechcon, which a filesystem relabel discards. The label survives rebuilds:mkdocs buildclears the contents ofsite/but keeps the directory itself, so new files inherit its context.
A fresh /docs/ that returns 403 is almost always one of the two above —
check /var/log/audit/audit.log for an avc: denied line to tell SELinux from
plain permissions. A 404 rendered in the app's own styling means the request
reached the frontend instead: the proxy rule is matching before the docs rule.
Verify all three cases:
curl -sI https://your-host/docs/ | head -1 # 200
curl -sI https://your-host/docs | head -1 # 301 -> /docs/
curl -sI https://your-host/ | head -1 # 200, still the app
Once it is up, hand people deep links rather than the repository:
https://your-host/docs/provider/ is the provider guide,
https://your-host/docs/ is the audience fork.
The dashboard already links here. Each portal carries a Documentation
entry in its sidebar footer, pointed at the guide for that audience — the user
portal at Run your prompts, the provider portal at
Lend your GPU, the admin portal at this page — and the worker-key
screen deep-links to the install command at the moment an operator is handing a
key over. All of them resolve against /docs/ on your own host, so building the
site is what makes them work; skip the build and they lead to a 404.
If you would rather not build the site, point the frontend elsewhere instead of leaving the links broken:
# in your frontend .env.local, then rebuild
NEXT_PUBLIC_DOCS_URL=https://sheshnag.io/
That is a deliberate trade: the links work immediately, but they describe the
newest published version rather than the one you deployed. See
NEXT_PUBLIC_DOCS_URL — like every
NEXT_PUBLIC_* value it is baked in at build time, so changing it means
npm run build, not a restart.
HTTPS (certbot)¶
sudo certbot --nginx -d sheshnag.example.com
After obtaining the certificate, set NEXT_PUBLIC_BACKEND_URL=https://sheshnag.example.com in your frontend .env.local so the Next.js client calls the HTTPS endpoint instead of localhost.
loginctl enable-linger for multiple users¶
If you're setting up services for a team (e.g., each worker user runs their own daemon), enable linger once per user:
sudo loginctl enable-linger worker-user
7. Load models, then onboard a provider¶
A deployment with no catalogue entries can accept batches but can never run
them: body.model in a batch must name a catalogue entry, and there is
deliberately no path to run an uncatalogued model.
- Load the catalogue. Curation, pinning and digests are covered in Model catalogue. A default set is seeded on first startup; anything beyond it is your decision.
- Create an organisation in the dashboard, if the one your users belong to does not exist yet.
- Generate a worker key — Provider portal → Worker keys. It is shown exactly once.
- Hand the provider that key and your platform URL, and point them at Lend your GPU. They need nothing else from you.
Check it worked¶
In order. Each one depends on the last.
Services are up:
systemctl --user status sheshnag-backend sheshnag-frontend
Both active (running). The frontend may sit in activating for some minutes
on a cold start while next build runs — that is expected.
The dashboard answers over HTTPS: open your domain in a browser. A
certificate warning here means certbot has not run or nginx is serving the wrong
server_name.
The admin password is no longer admin. Sign in as
admin@platform.com and complete the forced change if you have not already.
A worker connects: have one provider run the installer, then check Provider portal → Workers. The machine appears with its GPU and VRAM, and heartbeats every thirty seconds.
The docs are served: open https://your-host/docs/. A 404 means the nginx
alias path is wrong or mkdocs build has not run; a 403 means nginx cannot
read along that path.
A batch completes end to end: submit a small JSONL batch from the dashboard
and watch it reach completed. This is the only check that exercises the whole
chain — validation, scheduling, a worker claiming it, results coming back.
Until that last one passes, you have a deployment that is running, not a deployment that works.
Production checklist¶
Before deploying to production, address each item and note whether it requires a code change or just configuration.
- [ ]
SECRET_KEY— set a strong random value (e.g.,openssl rand -hex 32). At startup the app logs a WARNING if the default is still in use. - [ ] Database — provision the role and database as in Create the database, then point
DATABASE_URLat it. The schema is created byBase.metadata.create_all()on first startup; there is no migration tool, so an existing database is never altered in place. Set up backups before the first real batch lands. - [ ] CORS origins — set
CORS_ORIGINSto your frontend URL(s) instead of"*". Also reviewallow_credentials=True— browsers reject wildcard origins with credentials enabled, so you must list concrete origins when using cookies or auth headers. - [ ] HTTPS — put a reverse proxy (Nginx, Caddy) in front of both frontend and backend. See Put it behind TLS.
- [ ] Google OAuth — register your production domain with Google Cloud Console. Set the same
GOOGLE_CLIENT_IDin both.envandbackend/.env. - [ ] Email (Mailgun) — configure
MAILGUN_*vars for password reset, invites, and notifications. Gracefully skipped if unset, but you lose email functionality. - [ ] Reverse proxy — route
/api/to backend :8000,/to frontend :3000. See Put it behind TLS. - [ ] Documentation — run
mkdocs buildand servesite/at/docs/, so your providers and users have something to read that matches what you deployed. See Serve the documentation.
See also¶
- Lend your GPU — hand this to every provider you onboard
- Model catalogue — curation and pinning
- Google OAuth — sign-in setup
- Data model — what the database holds
- Change the code — the development path
- Configuration — every environment variable