Deploy¶
Tutorial. Three ways: a package install for one box with no checkout, Docker Compose for a trial, systemd units for a host that runs test and promoted environments side by side.
Not hardened for the public internet
The MES is designed to sit on a plant network behind a firewall. Put a reverse proxy with TLS in front of it, set the passwords, and do not expose the MCP servers. See security.
From the package, with no checkout¶
The shortest way to a plant on a machine that has Python and nothing else. The package carries the two lab plant packs, so there is nothing to clone.
pipx install factorysemantics-mes # or: uv tool install factorysemantics-mes
fsmes demo bottling
pipx install factorysemantics-mes
fsmes demo bottling
Not on PyPI yet
The release on PyPI today, 0.2.0, carries no plant packs, so
fsmes demo bottling needs the next one. From a checkout it works now:
python -m build --wheel, then install that wheel into a fresh
environment.
It says where the plant is — http://127.0.0.1:8010/dashboard — and which
account to sign in with. fsmes demo bottling stop puts it down,
fsmes demo bottling status says whether it is up, and fsmes demo machining
is a second plant on 8020.
What it does, once, on first start: copies the pack out of the package into a
per-user directory, generates that plant's eight-hour shift from the seed in
its line.json (seconds, and it is not shipped because it is 9.7 MB of CSV a
seed already states), writes a fleet file listing the copy, and
then runs the same fsmes fleet create and fsmes fleet start a checkout
runs. Afterwards it starts straight away.
Everything it writes is under one directory:
| Windows | %LOCALAPPDATA%\fsmes |
| Linux | $XDG_DATA_HOME/fsmes, or ~/.local/share/fsmes |
| macOS | ~/Library/Application Support/fsmes |
| Anywhere | FSMES_HOME, which moves all of it together |
Nothing under the installed package is written to, which is what makes this work on a locked-down plant PC and in a read-only container.
The pack copy is yours to edit — the port, the host, the time zone, the
accounts are all in packs/<name>/plant.toml under that directory, and the
pack page says what each key means. A later start says your copy
differs from the one the package ships and keeps yours; --refresh is the
one thing that replaces it.
This is an evaluation plant: it listens on loopback and its accounts are the
lab's, with lab passwords in the open. A plant other people reach needs its
own [[accounts]] in the pack, a real MES_SECRET_KEY, and the reverse
proxy in the warning above. For a plant that is meant to stay up, run it from
a systemd unit
pointed at that fleet file rather than from fsmes demo.
The AI tab's chat and the floor assistant need an ANTHROPIC_API_KEY (or a
local model through Ollama — see the AI page); without
one the command says so and every other screen is unaffected.
Compose¶
git clone https://github.com/factorysemantics/factorysemantics-mes.git
cd factorysemantics-mes
MES_SECRET_KEY="$(openssl rand -hex 32)" MES_ADMIN_PASSWORD='choose-one' \
docker compose -f docker/docker-compose.yml up --build
That starts PostgreSQL, a one-shot migration and seed job, the API behind
nginx on http://localhost:8000, the simulated OPC UA server, the OPC agent,
the mock ERP on port 8001 and the ERP sync worker. --scale api=3 adds API
replicas; migrations run once in their own container so replicas never race.
To leave twin mode, point MES_OPC_ENDPOINT at your server and
MES_TAG_MAP_FILE at the tag map the worksheet produced, and set
MES_ERP_MODE to erpnext, file, or off.
The image is ghcr.io/factorysemantics/fsmes (amd64 and arm64), signed with
cosign and shipped with an SBOM from each release. It is pushed only after
the same approval that publishes the package to PyPI, in the same run — so a
tag in the registry means that version was released, not that a build of it
happened to succeed. Pin the version tag rather than latest on anything you
care about; latest moves with each release.
systemd: test and promoted environments on one host¶
Two environments run from the same code on the same host; the only
difference is a registry file outside the checkout. The unit files and a
promote script are in deploy/.
| test | promoted | |
|---|---|---|
| Checkout | your working clone on main |
a second clone pinned to a release tag |
| Fleet file | labs/multiplant/fleet.toml in the repo |
~/.config/fsmes/prod/fleet.toml, mode 600, listing packs beside it |
| Accounts | lab accounts, lab passwords | the registry's accounts, passwords from ~/.config/fsmes/prod/env |
| Units | fsmes-plant@<name> |
fsmes-prod-plant@<name> |
| Changes | every merge | deploy/promote.sh <tag> only |
First-time setup:
git clone https://github.com/factorysemantics/factorysemantics-mes.git ~/Projects/fsmes-prod
mkdir -p ~/.config/fsmes/prod && chmod 700 ~/.config/fsmes/prod
# write fleet.toml, the packs it lists and env (see the fleet how-to); chmod 600 the file and the env
cp ~/Projects/fsmes-prod/deploy/[email protected] ~/.config/systemd/user/
systemctl --user daemon-reload
~/Projects/fsmes-prod/deploy/promote.sh v0.1.0
systemctl --user enable --now fsmes-prod-plant@<name>
promote.sh refuses a tag that is not on the remote it promotes from
(FSMES_PROMOTE_REMOTE, default public), stops every plant, backs each one
up — a SQLite file copied with its write-ahead log, a PostgreSQL database
dumped with pg_dump -Fc — and only then checks out the tag, applies each
pack and starts. It then asks each plant itself: /health must call itself
by the name the fleet knows it by and /pack must say its schema is at head.
Any failure after the checkout puts the previous tag, the previous venv and
the backups that run made back. A plant whose database it cannot copy refuses
the whole promote, and a password variable that is not set refuses it rather
than creating an account with an empty password. deploy/README.md is the
full list; fsmes fleet plan shows what a promote would act on.
Verify¶
curl http://127.0.0.1:<port>/healthanswers.- The log has no
well-known credentials in usewarning. fsmes plant all status(withFSMES_PLANT_REGISTRYset) lists every plant as alive.