Self-hosting & licensing
Institflow is sold self-hosted only — one codebase, one API contract, running entirely on infrastructure you control.
Deployment
A single-tenant Docker bundle (api, worker, web, caddy, plus Postgres/Redis/Garage) you run on your own server, gated by a signed license issued from your provider's platform. There is no vendor-hosted, multi-tenant alternative to opt into instead.
Self-hosted install
Prerequisites: a fresh Ubuntu 22.04, Ubuntu 24.04, Ubuntu 26.04 or Debian 12 server, with ports 80/443 free and reachable, and an install token from your provider's console (Customer → Install → Generate install command — shown once).
A server image with Apache/nginx already on port 80
Some VPS images or control-panel stacks ship Apache or nginx listening on port 80 out of the box — this installer needs 80/443 free for Caddy. If it finds one, it names the exact service and offers to stop and disable it for you (y/N, default No); non-interactive/automated runs never stop anything on their own and instead print the command to pass, --stop-conflicting-services. If you'd rather not let the installer touch it, answer No (or omit the flag) and stop the conflicting service yourself first, or use a clean server.
One command
curl -fsSLo institflow-install.sh https://license.institflow.com/api/v1/install/script
sudo bash institflow-install.shIt asks for your install token (hidden input, so it never lands in your shell history — --token exists too, for automation, deliberately not in the command above), then: installs Docker from Docker's own apt repository if it is missing, claims your token at the control plane, generates every other secret on this box itself (nothing secret ever travels from the vendor to you except the license and a registry credential, exactly as before), writes your docker-compose.yml/.env/Caddyfile, starts the stack, waits for every service to be healthy and for your address to resolve, and prints your setup address, a one-time SETUP_TOKEN and a one-time backup private key. It refuses to run as anything but root, on any OS but the four above, and it never pipes the network straight into a shell — you download the script, then run it, so you can read it first.
Your server gets its own address under the vendor's institflow.app domain automatically (<your-slug>.institflow.app, with its DNS record and certificate handled for you). Pass --domain your-own-domain.org instead to use a domain you already control — point its DNS A/AAAA record at this server first; the installer checks and refuses otherwise.
Re-running the script on a box that already has /opt/institflow/.env refuses unless you add --reinstall, which never touches your data volumes and keeps every value already in .env.
Two secrets, printed once — save them properly
The SETUP_TOKEN and the backup private key each print to your terminal exactly once, at the end of this run. The backup private key is also written to /opt/institflow/BACKUP-KEY-COPY-ME-THEN-DELETE.txt (0600) — copy it off this server, then delete that file. The SETUP_TOKEN is also saved in /opt/institflow/.env (0600), so it is not lost if you close the terminal before reading it. Neither secret is ever written to /opt/institflow/install.log.
Updates arrive on their own afterward — see "Updating" below; nothing further to run by hand.
First login
Open the setup address the installer printed, paste the SETUP_TOKEN, and create your institute and your own owner account there. That page then stops existing: the token cannot be replayed to create a second institute.
Manual install (appendix)
The one command above is the supported path. If your threat model requires doing every step by hand instead — no script fetched and run as root — the same install, step by step:
-
Get a license. Your provider creates your tenant on their own platform and issues a license for it — you receive a
LICENSE_KEY, shown once. There is no verification key to fill in: the key your install checks that license against is built into the published images, so nobody — including you — can replace it. -
Fill in the environment. Copy
docker/self-hosted.env.exampletodocker/.envand fill in every value — secrets are generated withopenssl rand -base64 32, the JWT keypair with a one-timekeys:generatescript. -
Start the bundle.
bashdocker compose -f docker/compose.self-hosted.yml --env-file docker/.env up -d docker compose -f docker/compose.self-hosted.yml --env-file docker/.env psapi-initis a one-shot migration runner and is expected to exit0; every other service stays running. -
First login — the same setup page above.
Updating is a version bump plus docker compose pull && up -d — api-init re-runs (idempotently) on every start and blocks the API from serving traffic until migrations finish.
Server sizing
The API measures the CPU and memory it can actually use — the container's own limit, not the host's full hardware — and compares that against what your institute's active student and staff counts call for. This is a warning, never a block: install, start and every feature keep working regardless of the size of the box underneath them. When your server sits below the recommended size, a banner appears on the app's Settings → License page for the owner/manager, and api-init prints the same check to its own log on every start.
Every row below is measured, not estimated: the write path (marking a class's attendance and recording a batch of payments — the two operations a busy front desk actually contends on) was run against a simulated customer server built from this project's own images, at increasing numbers of staff writing at the same moment, until each size's p95 latency held under 800 ms (docs/reports/sizing-2026-09-22.md, K-32).
| Institute size (active students) | Recommended | Measured concurrent staff writing at once |
|---|---|---|
| Small — up to 300 | 2 vCPU / 4 GB | 10, p95 288–462 ms across 3 runs |
| Medium — up to 1,000 | 2 vCPU / 8 GB | 10, p95 414–636 ms across 3 runs |
| Large — up to 3,000 | 4 vCPU / 8 GB | 20, p95 456–744 ms across 3 runs |
| More than 3,000 | 4 vCPU / 8 GB (a floor, not a proven ceiling) | not measured past the row above |
Extra RAM alone did not move the write-path number
2 vCPU / 4 GB and 2 vCPU / 8 GB measured the same at 10 concurrent writers — the CPU count is what determined whether a size held under 800 ms, not the memory. The larger memory figure for the medium tier is recommended for headroom this report did not measure (bigger tables, concurrent backups and reports), not because the write path itself needed it.
Object storage
Uploaded files (student and staff documents, invoices, course materials) live in Garage — a single static binary run as its own service, never as a host-level install. It publishes no host port: the app reaches it over the compose network only (its S3 API on 3900, an admin API on 3903, both internal), and a one-shot object-store-init service creates its bucket and a scoped application key on every boot, idempotently — nothing to run by hand. That key can read and write inside that one bucket and nothing else: it cannot create, list or delete a bucket.
Your browser uploads and downloads files through your own domain, never a separate storage host: https://<your domain>/institflow-objects/…, routed by Caddy and capped at 20 MiB there. The API signs every URL for that same address.
A fresh install generates four secrets into .env — GARAGE_RPC_SECRET, GARAGE_ADMIN_TOKEN, OBJECT_STORE_ACCESS_KEY_ID, OBJECT_STORE_SECRET_ACCESS_KEY — and writes empty S3_BUCKET/S3_ACCESS_KEY_ID/S3_SECRET_ACCESS_KEY placeholders that only the one-time migration below ever reads. To use an S3-compatible provider of your own instead of the bundled Garage, set OBJECT_STORE_ENDPOINT, OBJECT_STORE_PUBLIC_ENDPOINT, OBJECT_STORE_BUCKET and OBJECT_STORE_REGION. This release requires Docker Compose 2.23.1 or newer — the installer checks it before doing anything else.
Upgrading from 1.5.x
Installs on 1.5.x kept their files in a bundled MinIO. Move them into Garage with:
curl -fsSLo institflow-install.sh https://license.institflow.com/api/v1/install/script
sudo bash institflow-install.sh --reinstall --adoptThis is the same one-time command that moves an install to the self-healing updater (see Automatic updates and repairs below; --refresh-bundle is its old name and still works). On a 1.5.x install it also adds the four secrets above to .env without changing any value already set, and turns on the one-time copy: api-init copies every uploaded file from MinIO into Garage and verifies each one — size, checksum, and the matching database row — before the API and worker are allowed to start. Your old MinIO stays untouched and authoritative until the copy fully verifies, and keeps running as a minio-legacy service so the files can still be copied back if you ever roll back.
This refuses before changing anything if the MinIO image is no longer on your server (upstream stopped publishing it) — restore it from a backup of /var/lib/docker, or copy it from another server with docker save … | docker load, then re-run. Until you run it, a 1.6.0 install on the old bundle keeps working on MinIO exactly as before, and the owner sees a "Storage upgrade needed" notice in Settings → License → Automatic updates.
Rolling back after an upgrade
If you need to return to a release before 1.6.0 after your users have already uploaded new files into Garage, copy them back first:
cd /opt/institflow
sudo docker compose run --rm api-init node dist/scripts/storage-migrate.js --reverseThis copies every object from Garage back into MinIO and verifies it the same way as the upgrade did, without changing your database. Only then put back docker-compose.yml and Caddyfile from state/snapshots/*/ and roll back. Keep your current .env: what the upgrade added to it is ignored by the old bundle, and the new storage secrets must stay as they are for a later upgrade.
Automatic updates and repairs
From 1.7.0 every install runs a small guardian on the server itself — a script started every two minutes by a systemd timer (institflow-guardian.timer) — and the updater, a container the guardian runs with docker run. The guardian makes sure the updater is present, running, and exactly the image the vendor signed for your release, and puts it back if it is not; the updater puts the guardian back if it is damaged.
Every release carries a signed description of a healthy install of that release: which files, with which content and permissions, which services, which .env keys. The updater repairs only what differs from it, and only from a fixed list of safe actions — write back a missing vendor file, set a permission back, start a stopped service, add a missing .env key that can safely be generated, remove the old updater container. It never deletes data, a volume, a database or a backup; never changes a value you wrote in .env (it only adds missing keys); never generates a password or key whose loss would lock your data away (it reports a missing one instead); never touches the licence; and never overwrites a file it cannot prove the vendor wrote. A file you changed by hand is reported, not repaired — if you want the vendor's version back, run sudo institflow-guardian accept-vendor-files (your copy is kept under state/quarantine/). Put your own Caddy settings in caddy/custom.d/ and your own compose changes in docker-compose.override.yml; both are yours and never judged.
Before any repair or update the updater takes a full backup — the database, your files and the server's own configuration — and checks it; without a verified backup it changes nothing. An update starts the next release's updater while the current one stays in charge: if the new release does not come up healthy, the previous one is started again, and the server keeps serving. The database is never restored automatically.
Useful commands, as root: institflow-guardian status (what runs, the pinned release, an update in progress), institflow-guardian plan (what the updater would repair right now), institflow-guardian maintenance on|off (nothing is started, recreated or updated while on; backups continue), institflow-guardian check (look for damage and a new release within a minute). The owner can switch automatic repairs to report only in Settings → License → Automatic updates.
Moving an install made before 1.7.0
An install made before 1.7.0 runs the old updater, which can neither replace itself nor install the guardian. Run this once, as root, on the server — nothing else is ever needed afterwards:
curl -fsSLo institflow-install.sh https://license.institflow.com/api/v1/install/script
sudo bash institflow-install.sh --reinstall --adoptIt verifies the newest signed release before changing anything, takes a full backup, installs the guardian, removes the old updater container (its data volume is kept), and hands over to the new updater, which moves the install under a second, verified backup and puts everything back as it was if anything fails. An install laid out by hand in /opt/institflow/docker is moved into /opt/institflow first, after its own backup; any other layout is refused with what was found. Until it is run, the install keeps working on its current version.
What your install talks to
Exactly three outside hosts, and nothing else — nothing here is a proxy for your traffic and nothing routes end-user requests through us:
- The license server (
license.institflow.com) — an hourly heartbeat, and the signed update manifest your updater polls. Unreachable changes nothing on its own; see License lifecycle below. - The messaging relay — the same host as the license server, not a separate one. On boot your install generates its own key pair and enrols itself with your
LICENSE_KEY; from then on it can ask the relay to send your system mail (password resets, staff/portal invitations — free, never blocked by balance) and, if you turn it on, your institute's own paid mail (Settings → Notifications → Providers). Nothing to configure for this — there is no relay-specific environment variable. The first server of your licence is approved at once; a reinstall on another server waits until Institflow approves it, and so does a server first installed with an installer older than 1.6.4 after it registers again — Settings → Notifications → Providers shows that state, and mail resumes by itself once it is approved. - The vendor's image registry (
registry.institflow.com) — pull-only, reached only bydocker login/docker compose pullfor a first install or an update, never by the running system in between.
SMTP_URL in your .env is required for the API to start, but as of this release nothing is actually sent through it — every system mail and every institute notification email goes through the relay above unless you deliberately add your own SMTP credentials in the app (Settings → Notifications → Providers), which is the one outside mail host you would ever choose yourself.
License lifecycle
Licenses are set manually by your provider — there's no automatic billing. When the paid period ends, the system only warns; it never blocks access on its own.
- Valid — full functionality, nothing visible to end users.
- Expired — still fully functional, indefinitely. Every response carries an
X-Subscription-Warning: expired; since=<date>header, and the web app shows an owner/manager-only banner naming the date. Nothing else changes — an expired institute keeps full access until someone locks it. - Locked — the only state that blocks access, and it is always the result of an explicit, reason-carrying lock command from your provider — expiry alone never produces one. Tenant-scoped requests get
402 SUBSCRIPTION_SUSPENDED, except authentication, health checks, and the billing page itself. Data is never deleted or modified — unlocking restores full access immediately, exactly where you left off.
An optional platform-wide auto-lock policy can lock an expired tenant automatically after a configurable delay — off by default, so nothing is ever locked unless someone turns it on. A per-tenant override can also opt any single institute in or out of that policy, regardless of the platform-wide default.
A self-hosted install keeps working with no time limit while it can't reach the license server — being offline changes nothing on its own, no matter how many consecutive checks fail. An install reports tamper signals (a clock rolled back, a missing heartbeat, and similar) to your provider, but these are alert-only: nothing is ever locked automatically because a signal fired, only ever through the same explicit signed command a human issues. If an install is locked and the license server is unreachable, your provider can mint a one-time unlock token for you to paste into the self-hosted admin and restore access offline.
Security notes
- Change every secret in the example env file before going live — none of its blank values are safe defaults.
- TLS is automatic (Caddy) as long as your domain resolves and port 80 is reachable for the initial ACME challenge.
- Only 80/443 need to be internet-reachable — Postgres/Redis/Garage/the API itself are never published to the host.
MASTER_KEYencrypts tenant-supplied provider credentials (WhatsApp/SMS/email API keys) at rest — treat it like a database password, and back it up. Losing it makes any saved credentials permanently undecryptable.
Back up more than the database
Uploaded files live in the bundled garage service now, not MinIO — the automated backup already mirrors that bucket and verifies every object before it archives, so back up your .env (secrets) with the same discipline as your Postgres dump.
Disk space and the storage meter
Settings → Storage shows how much space your institute's files use and how much the server's disk has left. Uploaded files (lesson files and every other upload) live in the bundled garage service, on the server's own disk.
The server keeps a reserve free for the database, backups and updates: the larger of 10 % of the disk and 5 GiB. Uploads are refused with a clear message before they would use that reserve, and the object store itself holds a hard quota at the same limit, so even a direct upload cannot fill the disk. The meter turns yellow at 80 % and red at 95 % of what can still be stored, and the page shows a banner from 80 %. If your install uses an external S3 provider instead of the bundled store, the meter shows used space only and uploads are not capped.
To add room, enlarge the server's disk with your hosting provider, then grow the partition and file system on the server so the larger size is visible to it (the exact steps depend on the provider and the image). Nothing needs to be reinstalled or reconfigured: the API reads the new free space within a minute and the meter and the limit follow.
Third-party notices
This bundle runs the following third-party software unmodified, each as its own container — these are notices, not legal advice:
- Garage v2.4.1 (object storage) — AGPL-3.0. Complete corresponding source: https://git.deuxfleurs.fr/Deuxfleurs/garage/src/tag/v2.4.1
- rclone 1.75.1 (backup transport) — MIT. https://github.com/rclone/rclone/tree/v1.75.1
- Caddy v2.11.4 (reverse proxy / TLS) — Apache-2.0. https://github.com/caddyserver/caddy/tree/v2.11.4
- PostgreSQL 16.15 (database) — PostgreSQL License. https://www.postgresql.org/ftp/source/v16.15/
- Redis 7.4.11 (cache and queues) — dual RSALv2/SSPLv1, not BSD (Redis relicensed starting at 7.4). https://github.com/redis/redis/tree/7.4.11