INSTALL GUIDE

Install guide.

The guide your IT team would follow, as it ships in every install bundle. Read it before you book the demo.

For the IT administrator of the site that hosts HOLOIN. You need to know Linux and Docker; you don't need to know HOLOIN. Everything here runs inside your network: HOLOIN never connects to the internet, sends no telemetry and needs no licence server.


1. What HOLOIN is, in one page

HOLOIN is an IoT platform for fleets, facilities and farms: trackers, sensors and valves report to it, and people use it in a browser (and drivers on a phone).

What runs: four containers (Docker Compose):

ServiceImageRoleListens
webholoin/webnginx: HTTPS, the web app and the driver app, proxies the API443 (80 only redirects)
appholoin/backendthe HOLOIN server (one process: API, ingestion, rules, alarms, workers)8000 on this host's loopback only
dbtimescale/timescaledbPostgreSQL 16 with TimescaleDBnot published
backupholoin/backupthe nightly backup, the monthly restore test, restores on demandnothing

What it stores, and where (all under the install folder unless noted):

WhatWhere
The databaseDocker volume holoin_db_data
Uploaded files (photos, scans, yard layouts)Docker volume holoin_storage
Backups./backups/ — copy this folder off the host (§8)
The licence./licence/licence.json
The certificate./certs/holoin.crt, ./certs/holoin.key
Configuration and secrets./.env (private: escrow its secrets, §6)

What it never does: connect to the internet, phone home, download anything at install or at run time. The only outbound connections are the ones you configure: your mail server, your webhook receivers, your Traccar or ChirpStack server (for device commands).

2. Sizing

What counts is packets per second: the number of devices × how often each reports (500 trackers every 30 s = about 17 per second). Slower sensors count the same way: add up everyone's packets per second. Each threshold rule that applies to a device adds work per packet, so a device with many rules counts for more.

Measured (load-test run 36822098734, 2026-10-01): a GitHub Ubuntu machine with 2 vCPU and 8 GB RAM, the release bundle installed by install.sh in production mode, 500 trackers in 500 vehicles with 20 geofences, sent through nginx over TLS. Each packet went through the whole pipeline (position, geofences, rules). Each rate was held for 5 minutes (the first for 3). A rate "kept up" when, at its end, under 10 s of its arrivals were still waiting. Afterwards the queue drained to zero in 63 s: every accepted packet was processed, and none was refused.

500 trackers report everyPackets/s offeredKept upReceived → processed p50 / p95 / p99Dashboard reads p95App / database CPU (of one core, average)
30 s16.7yes44 ms / 121 ms / 191 ms90 ms33 % / 31 %
20 s25yes58 ms / 212 ms / 1.1 s131 ms53 % / 52 %
15 s33.3yes (processed = arrived, minute by minute)140 ms / 718 ms / 1.5 s243 ms72 % / 76 %
12 s41.7no: the queue grew about 170 a minute17 s / 24 s / 27 s3.9 s91 % / 86 %
10 s50no54 s / 83 s / 85 s4.1 s90 % / 87 %

The ceiling of one server, measured: 33.3 packets per second kept up; 41.7 did not. Plan for at most 25 packets per second, the highest measured rate with headroom (p99 1.1 s, half the CPU). Above the ceiling, packets are still accepted and stored on arrival, but alarms and the map run late until the backlog clears.

Converted to other intervals (arithmetic from the measured rates above; only 500 devices were tested):

Each tracker reports everyTrackers at the measured ceiling (33.3 packets/s)Plan for at most (25 packets/s)
10 s333250
30 s1 000750
1 min2 0001 500
5 min10 0007 500
SizePackets per secondCPURAMDisk (database + files + 14 days of backups)
Smallup to 25 (measured on 2 vCPU: this size doubles the CPU for headroom)4 vCPU8 GBSSD: 200 GB, plus about 2 GB per tracker reporting every 30 s per year kept (arithmetic, below)
Largermore than 25not measured: talk to the vendor before sizing (the server is one process; running more than one has not been measured)

Disk, measured: about 2 KB per packet including its readings and indexes (that run: 99 MB for 48 000 packets).

The arithmetic (not measured), if your devices differ. Positions and readings are kept 395 days by default. A tracker reporting every 30 s sends about 2 900 packets a day. At the measured 2 KB per packet, that is roughly 2 GB per tracker per year: an upper bound, since raw packets are kept 30 days only. A soil probe reporting every 15 min is about 1/30 of that. Retention is configurable (RETENTION_*_DAYS in .env). Backups are compressed, typically 20–30 % of the database.

3. Network

  • Inbound 443 from users' browsers and phones, device gateways, the Traccar server and the ChirpStack server. Inbound 80 only redirects to 443.
  • Outbound: none required. Optional: your SMTP server (alarm emails), your webhook receivers, your Traccar/ChirpStack API (device commands).
  • A DNS name for the host (it's in the certificate).
  • NTP time sync is required. Command confirmation, schedules, ETAs and the audit log all depend on the clock.
  • Gateways that cannot do TLS (some older ones): an internal plain-HTTP listener for ingestion only, off by default (INGEST_HTTP=on, INGEST_HTTP_BIND=<an internal interface IP> in .env). Never bind it to a public interface.

4. Prerequisites checklist

  • A Linux host (Ubuntu 22.04/24.04, RHEL 9 or similar) sized per §2 — or Windows Server with Docker Desktop (WSL2 engine): use install.ps1.
  • Docker Engine 24+ with the compose plugin (docker compose version).
  • The install bundle holoin-install-<version>.tar.gz and its .sha256, from the vendor.
  • The licence file from the vendor.
  • A TLS certificate and key for the host name, from your CA (or accept a self-signed one for a pilot).
  • Your SMTP server's details (host, port, security, account) for alarm emails.
  • A secrets vault to escrow the secrets the installer generates (§6).
  • A place off this host for backups (an NFS share, your backup system).

5. Installing

sha256sum -c holoin-install-1.0.0.tar.gz.sha256
tar -xzf holoin-install-1.0.0.tar.gz && cd holoin-install-1.0.0
# your certificate (skip for a pilot and add --self-signed below)
mkdir -p certs && cp /path/holoin.crt certs/holoin.crt && cp /path/holoin.key certs/holoin.key
sudo ./install.sh --server-name holoin.example.com --licence /path/licence.json --timezone <your IANA zone> \
  --company "Acme Logistics" --admin-email it@example.com --super-admin-email owner@example.com --operator "IT: J. Smith"

What each step prints:

  1. Prerequisites: the Docker version, CPU, RAM and free disk; a warning below the Small size.
  2. Images: every file checked against SHA256SUMS, then docker load. A mismatch stops the install.
  3. Configuration: asks for the company's time zone (an IANA name such as UTC; the host's zone is offered, or pass --timezone). Every schedule runs in it: rule schedules, quiet hours, and the default for new farms and buildings, which can each set their own. A company admin can change it later (PUT /api/company-settings { "timezone": "…" }). Then it writes .env with generated SECRETS_KEY, JWT_SECRET, METRICS_TOKEN and DB_PASSWORD, shows them once and waits until you type ESCROWED (§6). An existing .env is never regenerated.
  4. Certificate: uses certs/holoin.crt and .key, or with --self-signed the web service generates one at start. To trust a self-signed certificate, import certs/holoin.crt into your browsers' or domain's trusted roots.
  5. Licence: copied to licence/licence.json. The app refuses to start without a valid one.
  6. Start: docker compose up -d, then waits (up to 5 min) until the app reports ready.
  7. First company and admin: creates the installation owner (super_admin: the licence, the mail server, retention) and the first company with its admin, and prints their one-time passwords (also written to install-output.txt; delete that file after the first sign-in).
  8. First backup and a restore test: proves backups work from day one.
  9. Done: the address to open.

Unattended (automation): add --non-interactive --escrow-confirmed only once your automation stores .env's secrets itself.

6. The first day

  1. Escrow the secrets: SECRETS_KEY, JWT_SECRET, METRICS_TOKEN and DB_PASSWORD from .env go into your vault. Without SECRETS_KEY a restored backup loses every stored secret: webhook signing secrets, the SMTP password and integration credentials (everything else restores).
  2. Mail server: sign in as the installation owner. Go to Admin → Mail Server, enter your SMTP server and send a test.
  3. Users: the company admin creates the other users (Admin → Users). Drivers get their logins from their driver record.
  4. Devices:
    • Traccar: in Traccar's traccar.xml, set forward.enable=true, forward.type=json, forward.url=https://<host>/api/ingest/traccar/<sourceId> and forward.header=Authorization: Bearer <token>. The source id and token come from Integrate → Sources in HOLOIN.
    • ChirpStack: add an HTTP integration to https://<host>/api/ingest/chirpstack/<sourceId> with the same Bearer token header.
  5. Backups: check that ./backups/ holds a folder from tonight's run, and set up the copy off the host (§8).

7. Operating

Status at a glance:

docker compose -f compose.yml exec app holoin status

Logs: JSON, one object per line, on the containers' stdout. docker compose -f compose.yml logs --since 1h app. They carry no personal data (no names, emails, request bodies). Docker rotates them (5 × 50 MB). To send them to your log platform, set Docker's logging driver. For a support session the installation owner can raise the level temporarily (PUT /api/ops/log-level); it reverts after an hour.

Health endpoints (no sign-in, for your monitoring):

  • GET https://<host>/api/health: 200 while the server is alive.
  • GET https://<host>/api/ready: 200 {"status":"ready"}, or 503 with the failing checks:
Failing checkMeaningWhat to do
databasePostgreSQL doesn't answer within 2 sdocker compose ps db, its logs, disk space
migrationsthe database is not at this version's schemaan upgrade didn't finish: holoin migrate --status; see §9
licenceno valid licence loadedinstall the vendor's licence (§10)
storageuploads not writable, or under 1 GB freefree disk space on the Docker volume host
workersa background worker stopped tickingdocker compose restart app; send a support bundle if it recurs

Prometheus (optional): set METRICS_ENABLED=true in .env and restart. Your Prometheus scrapes https://<host>/api/metrics with authorization: { credentials: <METRICS_TOKEN> }, from an address allowed by METRICS_ALLOW. Load the alert rules from ops/prometheus/holoin.rules.yml (not ready, lag, ingestion failures, backlogs, no backup in 26 h, licence expiring, read-only, audit write failures, storage). A Grafana dashboard is in ops/grafana/.

A company with no admin left (the only admin left the organisation): docker compose -f compose.yml exec app holoin create-admin --company <id> --email <new admin> --reason "<why, and who asked>" --as "IT: J. Smith". This is for recovery only: it is refused while the company still has an active admin, who adds admins in the app (Admin → Users). Each use is recorded in the installation's audit log with the reason, the installation owner is notified, and the company shows a permanent banner naming the date.

Alarms HOLOIN raises about itself: a failed backup, no successful backup in 26 hours, and a failed restore test. Each company's admins see these alarms in the app and get them by email. They clear themselves at the next good backup or test.

8. Backups

Where: ./backups/<UTC timestamp>/ holds db.dump (the whole database), storage.tar.gz (uploaded files), licence.json, and manifest.json (checksums and row counts). backups/last.json holds the latest result. One runs every night at BACKUP_AT (02:30, in BACKUP_TZ). Rotation keeps 7 daily, 4 weekly and 3 monthly backups.

Copy them off this host. A backup on the same disk as the database protects against mistakes, not against losing the host. Mount ./backups on NFS, or have your backup agent collect it. HOLOIN only writes to the folder; it never uploads anywhere.

Encryption at rest (if your backup target doesn't encrypt): set BACKUP_ENCRYPT=true and BACKUP_PASSPHRASE_FILE=<a file mounted into the backup service>. Escrow the passphrase as well.

Commands:

docker compose -f compose.yml run --rm backup now          # a backup now
docker compose -f compose.yml run --rm backup list         # what there is
docker compose -f compose.yml run --rm backup drill        # restore the latest into a scratch database, verify, drop it
docker compose -f compose.yml run --rm backup restore <folder>              # the same for any backup (scratch: nothing live is touched)

The monthly drill does the restore test automatically (BACKUP_DRILL=monthly). Its result is on the Compliance page ("Last restore test").

Restoring after a loss, step by step:

  1. Make sure .env holds the same SECRETS_KEY as the installation that made the backup. Restore it from your vault first. The restore warns if the key's fingerprint differs.
  2. On a new host, install the same version (§5), then copy the backup folder into ./backups/.
  3. Stop the app: docker compose -f compose.yml stop app.
  4. Restore: docker compose -f compose.yml run --rm backup restore <folder> --target live --confirm <INSTALLATION_NAME from .env>. This replaces the live database and the uploaded files, then verifies row counts, the audit guard and the migration level. The report is written next to the backup.
  5. Start the app: docker compose -f compose.yml up -d.

Recovery point: 24 hours with nightly backups. For less, PostgreSQL WAL archiving (point-in-time recovery) can be set up on the db service. The vendor documents this on request; it is not built in.

9. Upgrading

Each release is a new bundle. Read its RELEASE_NOTES.md first: required .env changes, and the minimum version to upgrade from. Migrations are applied one release at a time, so skipping releases is safe.

  1. docker compose -f compose.yml exec app holoin status: note the current version.
  2. Back up now: docker compose -f compose.yml run --rm backup now. Don't continue if it fails.
  3. Extract the new bundle next to the old one, and copy over .env, certs/, licence/ and backups/. Load the images: sha256sum -c SHA256SUMS && for t in images/*.tar; do docker load -i $t; done. Set HOLOIN_VERSION in .env to the new version.
  4. docker compose -f compose.yml up -d. Downtime is typically 1–3 minutes; the app migrates on start.
  5. Verify: holoin status shows the new version and ready. Then run docker compose -f compose.yml exec app holoin verify --key <an admin API key>, which reads about 30 routes and prints PASSED or the failures.
  6. If verification fails, roll back: stop the app, restore the step-2 backup with --target live (§8), set HOLOIN_VERSION back, and up -d with the previous bundle's images. Migrations only go forward, so a rollback is always a restore.

10. Licences

  • The licence names your company, the modules (Fleet, Agriculture…) and their device limits, and an expiry date. Renewals come from the vendor as a new file.
  • Installing a new one:
    • Sign in as the installation owner: Admin → Licence → upload.
    • Or from the host: docker compose -f compose.yml exec app holoin licence install /app/licence/new.json --as "IT: J. Smith", after copying the file into ./licence/. The server picks it up within a minute.
  • Expiry: 14 days' grace, then read-only. Monitoring, alarms, ingestion and PDPL duties continue; changes (new vehicles, rules, users) are refused until a valid licence is installed.
  • Device limits are soft: devices above the limit keep working and are reported to the vendor at renewal. New placements are refused only at the grace ceiling (110 % of the limit unless your licence says otherwise). The first time a module goes over its limit, every company admin is notified once, in the app and by email.

11. Getting help

The support bundle is a file to attach to a ticket:

  • As the installation owner: GET /api/ops/support-bundle. This version includes the last 24 h of logs.
  • Or from the host: docker compose -f compose.yml exec app holoin support-bundle --out /app/storage (the logs come separately: docker compose logs --since 24h app).

What's in it, so your security team can approve sending it:

Included
Versionsapp, migrations, Node, PostgreSQL, TimescaleDB, OS, host name
Configurationevery variable's name; values only for ports, intervals and flags. Secrets appear as <set>/<unset>
Licenceid, customer, type, expiry, modules and limits — not the signature
Readiness, metricsthe current values
Logsthe last 24 h, already free of personal data
Databasetable sizes, connection counts, long-running queries (timings only, never query text), locks, the migrations table
Ingestionpacket counts per source per hour for 48 h, the 50 most frequent errors (masked)
Workers, integritypending deliveries, audit-log write failures (counts), the audit guard present
Backups, diskthe last backup and restore test, the backup folder listing, free space

Never included: names, emails, phone numbers, positions, readings, packet payloads, audit rows, uploaded files, or any secret. The bundle is refused outright if a known secret would end up in it.

12. Security notes

  • Ports: 443 (HTTPS) and 80 (redirect only) on the web service. The app's 8000 is published on the host's loopback only (for holoin and local tools). The database is not published at all (docker compose exec db psql -U postgres holoin for a one-off query). The optional plain-HTTP ingestion listener (§3) is off by default.
  • TLS: only TLS 1.2 and 1.3, modern ciphers, HSTS. Certificates from your CA; no ACME.
  • Client addresses: the app trusts the X-Forwarded-For header only from TRUST_PROXY (default: the private ranges, where Docker's networks live). That keeps sign-in records and the audit log showing each user's own address. If other hosts on those ranges could reach the app's port directly, narrow TRUST_PROXY in .env to the web container's network (docker network inspect shows it).
  • Content Security Policy: the web app loads scripts, styles, images, fonts and data from its own address only. Map tiles must therefore be served from the same host (a tile server behind this nginx); external tile services are blocked by design.
  • The audit log is append-only, enforced in the database, even against the database superuser: every change and every read of personal data is recorded with who and when. Rows are kept at least 7 years.
  • PDPL duties are built in:
    • erasure of a user's personal data (Admin → Users), and export and erasure of a driver's (GET /api/drivers/<id>/export, POST /api/drivers/<id>/erase);
    • retention per data class (Admin → Data Retention);
    • the compliance report (Admin → Compliance, with a PDF).
  • Database role (recommended): run the app as a non-owner role. Create one with INSERT and SELECT on the audit log, set DB_APP_ROLE and point DATABASE_URL at it. Then even the application's own account can't alter the audit trail through grants, as well as through the trigger.
  • Secrets:
    • SECRETS_KEY encrypts the stored webhook, SMTP and integration secrets. The server refuses to start without one (32+ characters).
    • JWT_SECRET signs sign-in tokens. A template default is refused.
    • METRICS_TOKEN guards /api/metrics.
    • Escrow all three (§6).

13. Uninstalling

Keep:

  • the backups (copied off the host);
  • the licence file;
  • the escrowed secrets — a restore needs SECRETS_KEY.

Then:

docker compose -f compose.yml down           # stops and removes the containers
docker volume rm holoin-install-<version>_holoin_db_data holoin-install-<version>_holoin_storage   # the data — only once you are sure