Installing and running NEXT HMI
NEXT HMI ships in two distributable forms:
- Docker image — single container; runtime home (and any projects
under it) mounted as a volume. Pull it from
ghcr.io, or download it as an offline installer zip from the release. - Portable Mac/Windows/Linux binary — unzip + double-click. No installer, no admin rights, no Python or Node toolchain on the host.
Both formats run the same backend over the same on-disk layout. Move a project from one runtime to another with a zip export/import, a LAN push, or by copying its folder and pointing the destination runtime at it.
Before putting NEXT HMI on any network, read Network placement and threat model. A running project writes to PLCs, and editor access runs authored code.
On-disk model
There are two distinct kinds of state:
- Runtime home — per-installation bookkeeping (manifest, logs, widget build cache). One per runtime.
- Project folder — self-contained per-project state (page tree, datasources, widgets, assets, custom components, …). The operator can keep as many as they want, anywhere on disk, and run several at once — each running project is a separate backend instance behind the manager.
The process you launch is the manager (front door, default port
8000). It serves a password-gated dashboard, starts one child backend
per running project, and reverse-proxies each project to a browser
under /runtime/<slug>/ or /editor/<slug>/. The dashboard is at the
origin root; an individual project's HMI is at
http://<host>:8000/runtime/<slug>/ and its editor at
http://<host>:8000/editor/<slug>/.
Runtime home
<runtime_home>/
projects.json manifest: running[], projects[], peers[], defaultProjectId, defaultProjectsRoot
.manager-auth.json device-admin password digest, token secret, sign-ins
.logs/ rotating application logs
.widget-build/ compiled custom-widget JS (regenerated)
.restart-pending sentinel for /api/system/restart
.peer-tokens.json hashed manager peer tokens (no bearer plaintext)
.mcp-tokens.json hashed MCP and API tokens
.peer-trust.json peer TLS certificates you confirmed when pairing
.peer-pairings.json pairing keys for peers you chose to remember (owner-only)
.peer-identity.json this runtime's runtime ID, as peers pin it
.install-id.json random installation ID used by usage reporting
.telemetry.json usage-reporting on/off (absent means on), contact prompt dismissed
.telemetry-session.json whether the last run stopped cleanly, and its version
.telemetry-milestones/ a dated file per first time: connected, pageSaved, mcpUsed
.embedding.json sites that may show NEXT HMI in a frame (absent means none)
tls/ HTTPS setting + generated and uploaded certificates
.peer-transfer-sender.json outgoing push transfer phases/progress (no bearer plaintext)
.peer-transfer-receipts.json incoming push claims, recovery phases, and receipts
.peer-transfer-pull.json outgoing pull phases and receipts
Resolution order (highest precedence first):
NEXTHMI_DATA_DIRenv var — used by Docker and any explicit override.- Bootstrap config file:
- macOS / Linux:
~/.config/nexthmi/runtime.json - Windows:
%APPDATA%\NextHMI\runtime.json
- macOS / Linux:
- Dev fallback —
<repo>/.dev-runtime-home/, used only when running from a source checkout with neither of the above set, so dev state stays inside the repo tree. - Platform default —
~/Documents/NextHMI/(or%USERPROFILE%\Documents\NextHMI\on Windows).
The bootstrap config file is written on first run when nothing was found,
so subsequent launches are explicit about which path is active. Because it
outranks the dev fallback, a machine where a binary has ever started uses
that path from a source checkout too — read the startup banner's
Runtime home line rather than assuming .dev-runtime-home/.
The runtime home path is read-only from the UI — to move it, edit the env
var or the bootstrap file and restart. defaultProjectsRoot (where new,
imported and pulled projects land by default) is unset out of the box and
resolves to your Documents folder (%USERPROFILE%\Documents on Windows);
nothing is created for it. It is likewise not editable from the UI; every
dialog that creates a project lets you type or browse to a different folder,
and a permanent change means editing projects.json while the manager is
stopped.
Project folder
<project>/
.backups/ pre-upgrade project zips (installation-local)
config.json page tree + project metadata + global settings (incl. mcpEnabled)
pages/ one JSON per page
dialogs/ one JSON per page in the Dialogs folder
datasources/ one JSON per datasource
components/ reusable composite components
translations/ semicolon-separated CSVs, one per group
themes/ one JSON per theme (colors / typography / spacing)
alarms.json alarm definitions
alarm_state.json runtime alarm state (shipped with push/pull)
recipes.json recipe definitions and datasets
recipe_state.json loaded dataset per recipe type
users.json users + groups + access settings
custom-widgets/ user-authored TSX widgets (compiled on save)
external-libraries/ JS modules exposed via the import map
assets/
icons/ SVGs available to icon fields
images/ images available to image fields
videos/ video files available to video fields
certs/ per-project OPC-UA client certs
historian/ historian config + installation-local database
The backend creates any missing folders on startup. Everything in this tree is user state: an application upgrade never overwrites it. A project written by an older build is brought to the current file format on request, after a backup — see Managing projects.
Managing projects
The manager dashboard (the origin root, behind the device-admin password) is the operator surface:
- First-run setup — on first launch the dashboard asks for the setup
code and a device-admin password of at least 8 characters. Until a
password is set, the device prints the code (
XXXX-XXXX) in its console and its log at every start; with Docker,docker logs nexthmishows it. The code works once, and a restart prints a new one. The password is stored hashed in<runtime_home>/.manager-auth.jsonand gates every subsequent visit. Signing in lasts a day, counted from the moment you sign in and not from your last click: after that the dashboard asks for the password again, and an editor left open on the panel shows Signed out until someone does. Signing out ends the sign-in on the device too; Settings › Security › Sign out everywhere ends every browser's, and changing the password ends every one but your own. - Project credentials — a project brings its own
users.jsonand nothing else gates it: a project copied from the bundled seed holds only the anonymousguestuser, so it starts and opens with no password of its own. A project whoseusers.jsonis missing, unreadable or corrupt shows Credentials unavailable and will not start until the file is repaired. - What the password does and does not cover — the device-admin password
gates the dashboard and every project's editor (
/editor/<slug>/). A running project's live screens (/runtime/<slug>/) are open to anyone who can reach the host, deliberately: that is what an operator panel is. Operating is open too: anyone who can reach the panel can write writable tags, and blocking the button is how a project stops operators changing a value. The install answers on every interface by default, so put the manager on a trusted network and use HTTPS where it is not. What is reachable without a password lists what is open, what each credential adds, and how to place the device on the network. - Start / Stop — bring a project up or down. A running project gets
its own backend instance and becomes reachable at
/runtime/<slug>/(and/editor/<slug>/); the set of running projects is remembered and auto-resumed after a restart. Projects run side by side — starting or stopping one never touches another. - + New project — create at an absolute path, seeded from the bundled template.
- ⊕ Add existing — register a project folder that is already on disk (a Git clone, a folder copied off another machine).
- ↑ Import zip — upload a zip exported elsewhere into a new folder.
- ⇩ Pull from peer — fetch a project from another manager on the LAN.
- Transfer — send that explicit source project to another manager. Pair once with the destination's existing device-admin password; subsequent transfers use a revocable peer token. The destination path is always a folder directly below its configured projects root. Collisions reject by default; an administrator may instead copy with a new ID or replace a stopped project with backup/rollback. Starting the received project is an explicit opt-in.
- Export — download the project as
<name>.nexthmi.zip. - Set as default — mark the project the origin root redirects to, and keep it running.
- MCP write enabled / disabled — per-project switch for whether the workspace MCP endpoint may write to this project. See AI agents over MCP.
- Locate… — re-point a manifest entry whose folder has been moved. Shown only on a missing row, which is the only state where it applies. The destination must carry the same project id, so it can re-point the entry but never adopt a different project.
- Remove — drop the manifest entry (optionally delete the folder too). A project that is running must be stopped first.
Manager transfer defaults to plain HTTP and is intended only for a trusted LAN:
the bearer token prevents unauthenticated mutation but does not stop a network
observer from intercepting the pairing password, token, or project data. Serve
the peer over HTTPS to close that (see Peer transfer over
HTTPS), keep peers on a private network either way,
and do not expose the peer endpoints to the public Internet. A pairing expires
after 90 days without use; Settings › Tokens lists every device paired with
this one under Paired devices, where Revoke cuts one off at once, and
changing the device-admin password revokes them all. Outgoing connections
resolve once and are pinned to a private unicast address; public, mixed,
link-local, multicast, and unspecified results are rejected. Loopback peers
require the explicit NEXTHMI_ALLOW_LOOPBACK_PEERS=1 deployment override.
Usage reporting
The runtime reports that it is running to https://next-hmi.com/ping — once at
start-up, then once an hour while it keeps running. It is the only outgoing
connection NEXT HMI makes on its own, and it exists to answer a few questions:
how many installations are active, how long they stay up, and whether people
get past connecting to a PLC.
Each report carries exactly:
| Field | Example | Where it comes from |
|---|---|---|
installId |
f1c74a5c… |
random ID generated on first run, kept in <runtime_home>/.install-id.json |
version |
1.4.0 |
the build |
edition |
oss / ee |
the build |
os / osRelease |
Darwin / 25.6.0 |
the host platform |
python |
3.14.6 |
the bundled interpreter |
event |
start / heartbeat |
which of the two moments this is |
installMethod |
bundle / docker / source / other |
the portable download, the Docker image, a source checkout, or none of those |
sessionId |
9a07e2c1… |
random ID for this run, new on every start |
uptimeS |
86400 |
seconds since this run started |
connected / pageSaved / mcpUsed |
true |
whether this installation has ever connected to an OPC-UA server, saved a page, or answered an MCP tool call |
pages / tags / opcuaConns |
11-100 |
how many pages, variables and OPC-UA client connections all projects have together, as 0, 1-10, 11-100 or 100+ |
mcpEnabled |
false |
whether any project has MCP access switched on |
prevExit |
clean / unclean / none |
start only: whether the previous run shut down normally, crashed or was killed, or there was none |
prevVersion |
1.0.0 |
start only: the version of the previous run, when it was a different one; empty otherwise |
Nothing else is sent: no project names, page or variable names, tag values,
addresses, user names, or licence data. The sizes are counted on this machine
and only the class leaves it. The receiving end stores no IP address. The
installation ID is a random number tied to the runtime home, not to the
machine — copy the runtime home and both copies report as the same
installation; delete .install-id.json and the next start counts as a new one.
A report is best-effort. On a plant network with no route to the Internet it fails silently and the app carries on; nothing waits on it, nothing is queued, and no error is shown.
To turn it off, either:
- Settings → Usage reporting in the manager dashboard (takes effect immediately, no restart), or
- set
NEXTHMI_TELEMETRY=offin the environment — this wins over the setting and makes the switch read-only, which is the form to use in Docker or a managed deployment.NEXTHMI_TELEMETRY=onforces the opposite the same way.
Once an installation has connected to a PLC and saved a page, the Projects page asks once whether you use NEXT HMI in a machine or product. Tell us about it opens the contact form on next-hmi.com in your browser — with the installation ID filled in while usage reporting is on, so the message can be read next to this installation's reports — and Not now puts the question away for good. The app itself sends nothing either way; only submitting the form does. The same link stays under Settings → Usage reporting.
HTTPS
The manager binds every interface by default and serves plain HTTP, so out of
the box the device-admin password, every operator sign-in, and every project
edit cross the wire in the clear. A password still gates the dashboard and
every editor — that part does not depend on the binding — but anyone who can
watch the network reads it on its way past. Turn HTTPS on unless the network
is one you trust, or pin the install to NEXTHMI_HOST=127.0.0.1 and reach it
some other way.
The dashboard says so itself while that is the case. Open it at an address that
is neither HTTPS nor localhost and a notice stands above the project list, on
the settings page, and on the sign-in screen, linking to the switch. It follows
the address in your URL bar rather than where the server runs — the same
distinction the browser makes in What plain HTTP costs the
browser below — so reaching a networked
install from the machine it runs on shows nothing, while reaching it from a
laptop shows the notice. The operator runtime and the editor stay quiet: it is
addressed to whoever can act on it.
Only the manager terminates TLS. Project children are spawned on loopback and
reached over plain HTTP by the in-process proxy, so nothing else needs
configuring; the SPA already switches its WebSocket to wss:// whenever the
page itself was loaded over https://. One certificate therefore covers the
dashboard, every project's HMI, and every project's editor.
What plain HTTP costs the browser
Encryption is not the only thing at stake. Browsers gate a set of APIs behind a
secure context — HTTPS, or localhost / 127.0.0.1, and nothing else. A page
served over plain HTTP to a LAN address (http://192.168.1.10:8000) is not a
secure context, so those APIs are absent rather than merely restricted, and no
permission the operator grants can hand them back.
The reasoning is that plain HTTP has no integrity. Anyone on the path — a compromised router, another device on the same LAN, a transparent proxy — can rewrite the page in flight, and the browser cannot tell injected script from the application's own. Granting such a page the clipboard would grant it to whoever sits on the network, so the browser withholds it from every HTTP page rather than asking.
In NEXT HMI this costs the editor's clipboard: Copy and Paste on widgets, property values and component definitions report that the clipboard needs HTTPS or localhost, and stay unavailable until the page is served over one. Nothing else depends on it — the runtime, bindings, alarms, and the editor itself all work over plain HTTP.
What decides this is the address in the browser's URL bar, not where the server
runs. Opening a Docker install at http://localhost:8000 on the host machine is
a secure context; opening the same container at http://192.168.1.10:8000 from
a laptop is not.
Ports
Enabling HTTPS moves the app to a second port and leaves the first one redirecting, so links made before the switch keep working:
| Setting | 8000 (NEXTHMI_PORT) |
8443 (NEXTHMI_HTTPS_PORT) |
|---|---|---|
| HTTPS off (default) | the app, over HTTP | not bound |
| HTTPS on | 307 to the HTTPS port, path and query intact |
the app, over TLS |
The redirect is deliberately temporary, not a 301: browsers cache a permanent
redirect hard enough that turning HTTPS back off would strand the operator on a
port nothing listens on. Turning it off unbinds 8443 rather than redirecting
downward — a redirect from HTTPS to HTTP is what an attacker wants, and it
would need a certificate warning to deliver.
WebSocket handshakes cannot follow a redirect, so the HTTP port closes them.
Nothing in the SPA reaches that path: its document is redirected first, and it
derives ws:/wss: from the origin it was actually loaded from.
An MCP client is the one caller that has to be re-pointed by hand: most do not
follow redirects, so a connector configured against :8000/mcp stops working
the moment HTTPS goes on. See AI agents over MCP.
Both ports are published by docker-compose.yml. Override either with
NEXTHMI_PORT / NEXTHMI_HTTPS_PORT, or --port / --https-port.
This split applies only to HTTPS turned on in Settings → HTTPS. A
deployment that pins its certificate through NEXTHMI_SSL_CERTFILE /
NEXTHMI_SSL_KEYFILE keeps serving TLS on NEXTHMI_PORT and binds nothing
else: it has been HTTPS since its first boot, so there is no earlier
plain-HTTP link to preserve and no toggle that could strand a page — and
moving it would break the port mapping the operator already published.
From the manager UI
Settings → HTTPS is the normal route and needs no shell access:
- Pick HTTPS on the protocol switch and confirm. Everyone signed in to the dashboard, you included, signs in again over HTTPS: a sign-in made over plain HTTP stops working.
- Under Certificate, keep Generated for this device — the manager
creates a self-signed certificate covering
localhost, this host's name, and its addresses, including the one it is routed to on the network — or choose My own certificate and upload a PEM certificate and unencrypted PEM private key. Mismatched or passphrase-protected keys are rejected at upload, not at the next startup. - The manager stops running projects, restarts, and the page reopens itself on the new protocol — on port 8443, see Ports above. Projects resume on their own.
Both certificates are kept side by side under <runtime_home>/tls/, so
switching between generated and uploaded needs no re-upload. The private keys
are written 0600.
A generated certificate is not signed by any authority, so browsers warn once per machine until someone accepts it. That warning is the cost of not needing a CA; the connection is encrypted either way, which a plain-HTTP one is not.
It also names the addresses the device had when it was generated. If the device later answers on a different one — a new DHCP lease, a move to another network — the warning comes back for the address URL, and Regenerate under Certificate issues a fresh one for the address it has now. A static address or a DHCP reservation avoids the round trip. An already-generated certificate is never rewritten on its own.
Upload your own key only over an HTTPS page — the UI warns when the page is on HTTP, since the key would otherwise cross the network in the clear. Turn on HTTPS with the generated certificate first, then upload over that.
Built-in TLS from the environment
NEXTHMI_SSL_* overrides the UI setting entirely, and the HTTPS section then
reports itself read-only. Use it for Docker, or wherever configuration is owned
by the deployment rather than the operator. Both variables are required
together, and both files must exist at startup or the launcher refuses to
bind:
| Variable | Purpose |
|---|---|
NEXTHMI_SSL_CERTFILE |
PEM certificate chain (leaf first). |
NEXTHMI_SSL_KEYFILE |
PEM private key. |
NEXTHMI_SSL_KEYFILE_PASSWORD |
Passphrase, only if the key is encrypted. |
export NEXTHMI_SSL_CERTFILE=/etc/nexthmi/tls/fullchain.pem
export NEXTHMI_SSL_KEYFILE=/etc/nexthmi/tls/privkey.pem
./nexthmi
The banner then prints an https:// URL. In Docker, bind-mount the cert
directory read-only and set the same variables; the files must be readable by
the user the app runs as.
Certificate renewal is not automated in either route — the process reads its certificate once at startup, so restart the manager after a renewal (for an uploaded certificate, re-upload it and pick the protocol again).
Behind a terminating proxy
Caddy, nginx, or Traefik in front of the manager is the better option where
one already exists: it handles renewal. Set NEXTHMI_HOST=127.0.0.1 so the
manager answers the proxy alone and nothing reaches it directly, leave
NEXTHMI_SSL_* unset, and have the proxy send X-Forwarded-Proto and pass
the browser's Host header through unchanged. Caddy and Traefik do that by
default; nginx needs proxy_set_header Host $http_host; ($host drops the port, so a proxy on :8443 would no longer match). Without it the manager
cannot tell the browser's own address from another website's and refuses
every change and every live connection. Leave the Content-Security-Policy and
X-Frame-Options response headers as the manager sends them; to show NEXT HMI
inside another site, list that site instead (see Showing NEXT HMI inside
another site). A
proxy on the same host is trusted out of the box; one on another host needs
its address allow-listed:
| Variable | Default | Purpose |
|---|---|---|
NEXTHMI_FORWARDED_ALLOW_IPS |
127.0.0.1 |
Comma-separated proxy addresses whose X-Forwarded-* headers are trusted. |
Set this to the proxy's address, never * on an untrusted network — any
client that can reach the manager directly could then dictate the scheme and
client IP the backend believes it is seeing.
Peer transfer over HTTPS
Manager-to-manager transfer speaks whichever protocol the peer is configured
with. The transfer dialog starts on HTTPS, port 8443; a protocol can also
be stored on a manual peer entry. A runtime serving TLS advertises
scheme=https in its mDNS record, so picking a discovered peer fills its
protocol and port in for you.
Peer certificates are trusted because you compared them, not through a CA, because a plant LAN rarely has one. The transfer dialog pairs in three steps — Verify device → Sign in → Send (Fetch when pulling):
- Verify device. The manager connects to the peer without sending anything and shows its name, address, runtime ID and the SHA-256 fingerprint of the certificate it presents. On the peer, open Settings › HTTPS and compare that fingerprint with its SHA-256. Only It matches, continue goes on; It doesn’t match stops, and nothing has been sent.
- Sign in. Only now is the peer's device-admin password asked for. The
manager checks, during the TLS handshake of the pairing request itself,
that the peer still presents exactly the certificate you confirmed;
otherwise no password leaves this manager. The confirmed certificate is
then pinned in
<runtime_home>/.peer-trust.json, by the address you used and by the peer's runtime ID. - Send. Every later connection to that peer must present the same certificate. It is the sole trust anchor, so a mismatch aborts the TLS handshake before the bearer token or any project bytes reach the socket.
The pin follows the device rather than its address: the same device at a new address is recognised by its certificate and keeps its pin, while a different certificate at a pinned address, or claiming a pinned runtime ID, stops pairing with Certificate changed: pairing stopped — never a silent re-pin.
A certificate pinned by a release from before this confirmation step was pinned silently, on first contact. The first pairing after the upgrade shows its fingerprint to compare, like a new device, and confirming it then records the peer's runtime ID.
Remember this device (off by default) keeps the pairing key the peer
issues, in <runtime_home>/.peer-pairings.json (readable by the manager's user
only), so the next transfer to the same device needs no password. The key is
only ever sent over the certificate it was paired over. When the peer no longer
accepts it — revoked under its Settings › Tokens, unused for 90 days, or
its device-admin password changed — the dialog says so (hmi-line3 no longer
accepts this device), and Pair again drops the key and asks for the
password.
Forgetting the device's pinned certificate (below) drops its key too.
A cloned runtime home. The runtime ID lives in
<runtime_home>/.peer-identity.json. A runtime home copied to another machine
— a cloned VM or disk image — carries it along, and the two devices then hide
each other from discovery and cannot pair: the dialog stops with This device
has the same identity as the one you are pairing with. Delete
.peer-identity.json in the copy's runtime home and restart it; a new runtime
ID is made on the next start.
A peer that has ever been reached over HTTPS is never contacted over plain HTTP again, even if it later advertises otherwise — mDNS records are unauthenticated and would otherwise be a downgrade path.
A peer without HTTPS. The dialog refuses to pair with it — Pairing needs HTTPS on the other device — because the password would cross the network unencrypted. Turn HTTPS on there (Settings › HTTPS, choose HTTPS; it restarts on its HTTPS port, 8443 unless changed), then verify the device again with protocol HTTPS and that port. On a network that is genuinely closed to other devices, Isolated network without HTTPS? lets you pair anyway after ticking I understand the password is sent unencrypted, and this network is closed to other devices. The dialog then says Unencrypted connection: the certificate cannot be verified. and shows the first step as Not verified (HTTP). Over plain HTTP no pairing key is remembered.
Renewing a peer's certificate breaks transfers to it on purpose: a certificate that changed without warning looks exactly like an interception. Confirm the new certificate is genuine, then drop the pin — Forget pinned certificate and verify again in the transfer dialog, which drops every pin of that device, and its remembered pairing key, and shows the new certificate to compare — or:
curl -X DELETE "https://<manager>/api/manager/peers/trust?host=<peer>&port=8000"
GET /api/manager/peers/trust lists the current pins and their fingerprints.
Peer discovery itself (mDNS) is unauthenticated regardless of protocol — it advertises which runtimes exist, nothing more, and anything on the LAN can advertise itself under any name. That is why the certificate is compared before the password is typed, whether the peer was discovered or entered by hand.
What TLS covers
The manager session cookie is marked Secure automatically on requests that
arrive over HTTPS (directly or via a trusted proxy), and stays unmarked on
plain HTTP so local installs keep working. The restart that turns HTTPS on in
Settings → HTTPS ends every dashboard sign-in: a cookie set over plain HTTP
is not Secure, has crossed the network readable, and the browser would keep
sending it to the HTTP port.
Docker
Three ways to get the same image. Each starts it on port 8000 (and 8443 once
HTTPS is on) with the runtime home mounted as /data.
From the registry
docker run -d --name nexthmi -p 8000:8000 -p 8443:8443 \
-v nexthmi-data:/data ghcr.io/mpnvdlee/next-hmi:latest
The image is multi-arch (linux/amd64 + linux/arm64). Swap :latest for a
version tag such as :1.0.1 to pin a release.
Open http://localhost:8000 and set the device-admin password when the
manager dashboard prompts. It asks for the setup code first:
docker logs nexthmi shows it. On first boot the bundled seed project is
registered and started; open its HMI or editor from the dashboard. The project
needs no password of its own.
Reaching the container from another machine works the same way, with one
exception: http://192.168.1.10:8000 is not a secure context, so the browser
withholds the editor's clipboard there. See What plain HTTP costs the
browser.
As a file download, without a registry
For a host that cannot reach ghcr.io, every release carries an offline
installer per architecture, nexthmi-community-docker-linux-x64-<version>.zip and
nexthmi-community-docker-linux-arm64-<version>.zip: the image as a file, a compose file
that restarts the container with the host, and install.sh. Copy it over,
unzip it and run:
./install.sh
It loads the image and starts it, with ./project-data beside it as /data.
README.txt in the zip covers stopping, logs and updating.
From a clone of the repo
docker compose up -d
docker-compose.yml at the repo root builds the image and binds
./project-data to /data. That bind-mount carries both the runtime home
(projects.json, .logs/, .widget-build/) AND any project folders the
operator places under it. Projects with absolute paths outside /data need
their own bind-mounts, writable by the user the app runs
as.
The user inside the container
The app runs as the unprivileged user nexthmi, uid and gid 10001, not as
root. The container starts as root only to give /data to that user, then
drops to it. The first start after an upgrade from a release that ran as root
changes the owner of every file in the volume; later starts find nothing left
to change. On the host, the files in ./project-data therefore belong to uid
10001.
Anything else you bind-mount has to suit that user too, since the container
leaves it alone: a project folder outside /data must be writable by uid
10001, and a certificate pinned with NEXTHMI_SSL_CERTFILE /
NEXTHMI_SSL_KEYFILE readable. Give it over on the host:
sudo chown -R 10001:10001 /path/on/the/host
To start the container without root at all, run it as that user yourself,
docker run --user 10001:10001 …. Nothing is handed over then, so /data
has to be writable by it already; a new named volume is.
The enterprise build still runs as root: its license is bound to the host's hardware UUID, which only root can read.
Discovery note: mDNS requires host networking. Without it (default bridge
mode) the runtime won't appear on peer pickers — operators can still use
manual host:port entries.
Environment variables
| Variable | Default | Purpose |
|---|---|---|
NEXTHMI_DATA_DIR |
/data (in image) |
Runtime home. Pins the path; the Admin UI shows it read-only when this is set. |
NEXTHMI_FRONTEND_DIST |
/app/frontend/dist |
Compiled SPA. Triggers the templated index.html route. |
NEXTHMI_WIDGET_BUILD_DIR |
<runtime_home>/.widget-build |
Compiled custom-widget output. |
NEXTHMI_VALIDATION_SWEEP |
on |
Set to off to skip the startup page-validation sweep. |
NEXTHMI_TELEMETRY |
on |
Set to off to stop the install-count ping and make the Settings switch read-only. See Usage reporting. |
NEXTHMI_HOST |
unset | Address the manager binds. Every interface, IPv4 and IPv6, by default; set 127.0.0.1 to answer this machine only — a reverse proxy on it, or nothing at all. Setting 0.0.0.0 binds IPv4 alone, which refuses http://localhost:8000. See HTTPS. |
NEXTHMI_SSL_CERTFILE / NEXTHMI_SSL_KEYFILE |
unset | Serve HTTPS from the manager itself, overriding Settings → HTTPS. See HTTPS. |
NEXTHMI_FORWARDED_ALLOW_IPS |
127.0.0.1 |
Proxy addresses whose X-Forwarded-* headers are trusted. |
NEXTHMI_MAX_PROJECT_ZIP_MB |
500 |
Cap for zip uploads (export / import / push / pull). Oversize archives are rejected before any bytes hit disk. |
WATCHFILES_FORCE_POLLING |
1 (in image) |
Required when a project folder is a bind-mount; native inotify doesn't see host edits. |
ESBUILD_BINARY_PATH |
/usr/local/bin/esbuild |
Path to the esbuild executable used to compile custom widgets. |
Upgrading
- Registry —
docker pull ghcr.io/mpnvdlee/next-hmi:latest, then remove the container and run the samedocker runagain. - File download — unzip the newer installer and run its
./install.shfrom the folder that holds yourproject-data/. - Clone —
git pull, thendocker compose up -d --build.
The data volume is untouched, apart from the owner of its files on the first start after an upgrade from a release that ran as root (see The user inside the container). The backend checks each project's stamped format version on activation and refuses to open one written by a newer build.
Re-seeding
If you delete ./project-data/ (or point the volume at a fresh
directory), the manager reseeds from the image's bundled seed project on
the next start and registers it. A fresh device-admin password is required again,
and the reseeded project starts out with only its guest user.
Existing volumes retain their current project credentials unchanged.
Portable binaries
Installing
- Download
nexthmi-community-<os>-<arch>-<version>.zipfrom the release. - Unzip anywhere you have write access (Downloads, Desktop, anywhere under your home directory).
- Launch:
- macOS: double-click
nexthmi.command. - Windows: double-click
nexthmi.exe. - Linux (x64): run
./nexthmifrom a terminal in the unzipped folder. It needs glibc 2.38 or later: Ubuntu 24.04, Debian 13, Fedora 39 or newer.
- macOS: double-click
A terminal window opens, prints the banner, and stays in the foreground.
The banner shows the version, the runtime-home path, the address the default
project answers on, and a link to the manager's project list (/projects) for
reaching the others. Until a device-admin password is set it also shows the
Setup code the first visit asks for. Under Getting started it lists
the first steps: open the project list in a browser, enter the setup code and
choose the device-admin password on the first visit (and sign in with it after
that), then pick a project — Open runs it,
Open editor lets you change it. In a terminal that supports it, every URL in
the banner is a clickable link.
Open the printed URL in a browser. Those rows name localhost, for the browser
on this device. Under On the network stand the two addresses another
machine types instead — this device's name, and its address for a network whose
name resolution does not carry it — because an install binds every interface by
default. Set NEXTHMI_HOST=127.0.0.1 to keep it to this machine only, and the
network block goes with it: nothing else is answering. See HTTPS
before putting an install on a network you do not control.
On the first launch, set the device-admin password, with the setup code from
the banner. That is the only credential the install asks for: the seeded
project holds only its guest user and opens straight away. Upgrades preserve
an existing project's users and passwords byte-for-byte.
The setup code is what claims the install. Until a password exists there is no password to check, so the first-run page asks for the code instead. The device prints it only in this terminal and in its log, and a website cannot read either: a page that points a name of its own at the device gets past its other checks, but not this one. Five wrong codes from one address lock setup from that address for a minute, as five wrong passwords lock sign-in. Anyone who can read the terminal or the log can still claim the install, and it advertises itself over mDNS the whole time it waits, so set the password right after the first start. The code works once; a restart prints a new one.
To stop: focus the terminal window and press Ctrl-C — uvicorn's lifespan shutdown runs and the OPC-UA pool closes cleanly.
Documentation
The zip carries the full documentation as a self-contained HTML site in
docs/ beside the executable — open docs/index.html directly, or use the
Help button in the editor header, which the manager answers at /help.
Builds without a bundled copy (Docker, a source checkout) redirect /help to
the public documentation page instead.
First-run gatekeeper prompts
These are unsigned binaries. The OS will ask you to confirm before it runs them once.
macOS — Gatekeeper shows "nexthmi cannot be opened because the developer cannot be verified." Clear the quarantine flag once:
xattr -dr com.apple.quarantine /path/to/nexthmi-macos-arm64/
Then re-launch nexthmi.command. (Alternative: right-click the file →
Open → confirm. You have to do that for nexthmi.command and the
nexthmi executable next to it.)
Windows — SmartScreen shows "Windows protected your PC." Click "More info" → "Run anyway."
The public builds are not code-signed, and that is the arrangement rather than a gap waiting to be filled: a signed build is part of what a commercial licence buys. It changes nothing about what this build does — the prompts above are a one-time step, not a limitation.
Changing the runtime home
The runtime home path is read-only from the UI. To move it:
- Stop the backend.
- Edit the bootstrap config file (
~/.config/nexthmi/runtime.jsonon macOS / Linux,%APPDATA%\NextHMI\runtime.jsonon Windows). SetdataDirto the new path, or delete the file to fall back to the platform default (~/Documents/NextHMI/). - Optionally export
NEXTHMI_DATA_DIRto pin it without touching the file — the env var wins over the bootstrap file. - Move the manifest (
projects.json) and.logs//.widget-build/into the new location if you want history preserved. Project folders referenced by absolute paths in the manifest don't need to move. - Relaunch.
The bootstrap file is rewritten on first launch when nothing was found, so it's always present after the platform default kicks in.
Changing the default projects root
defaultProjectsRoot in projects.json governs where new / imported /
pulled projects land by default. Out of the box it is absent, and resolves to
your Documents folder — a place that already exists, so the runtime never
creates a folder to hold projects you may keep elsewhere. Neither it nor the
runtime home is editable from the UI — Settings → Runtime home displays
the runtime home path read-only. Every create / import / pull dialog lets you
type or browse to a different destination, so a one-off elsewhere needs no
configuration. To change the default permanently, stop the manager, add or
edit defaultProjectsRoot in <runtime_home>/projects.json, and relaunch.
Upgrading a binary install
- Download the new zip.
- Replace the files inside the existing folder (or unzip into a new
folder and delete the old one):
nexthmi(executable)nexthmi.command(macOS launcher)_internal/(PyInstaller runtime + bundled assets)version.txt
- Re-launch.
The bootstrap config file is outside the binary folder, so it survives the upgrade. The runtime home (and every project tracked in its manifest) lives wherever the bootstrap file points.
Limitations
- The macOS build is Apple silicon only:
nexthmi-community-macos-arm64-<version>.zip. There is no Intel (x64) macOS build. - No auto-update. Operators check the release page for new versions.
- No process-level service install — the binary runs in the foreground
of whichever terminal launched it. If you want it persistent, wrap it
in
launchd(macOS), Task Scheduler (Windows) or a systemd unit (Linux) yourself.