Inside the Container Deployment¶
Learning objectives
After reading this chapter you will understand:
- What each file in
deployment/docker/anddeployment/curl/is for, and how the two directories relate to each other - How the images are built, layer by layer, and how to rebuild any of them by hand without the helper scripts
- How the compose files stack on top of each other, and what each one adds: containers, environment variables, volumes and ports
- What actually happens when the backend container starts and stops
- Which networks, pods and volumes your container engine creates, and what they are called
- Every published port and why it exists, and every mount and why it is read-only or read-write
- How the
maketargets andedumatcher.shcommands map onto plainpodman/dockercommands
This chapter assumes you know roughly what an image and a container are, and nothing more. Installation is the how to install chapter and the complete flag reference; this one is the how it works walkthrough behind it. The developer-level view, including the release workflow and network debugging, is in the Architecture and Developer Guide's Container and Network Setup.
Five container terms, as they apply here¶
Container tools use a handful of words in very specific ways. Here is what each one means in an EduMatcher deployment.
| Term | In general | In EduMatcher |
|---|---|---|
| Image | A read-only template: a file system plus a default command | Seven of them: edumatcher (the whole exchange), edumatcher-ai-sim (the same exchange preset to trade by itself) and one per web GUI |
| Container | A running instance of an image, with its own processes, network interface and writable layer | Up to seven run at once: the exchange container and six GUI containers |
| Compose file | A YAML file that describes several containers and how they connect, so you start them with one command instead of six | compose.yaml and its overlays. Several files can be combined, each adding to the last |
| Bind mount and named volume | Two ways to give a container storage that outlives it. A bind mount is a directory on your disk; a named volume is storage the container engine manages for you | Everything the exchange produces lives in a bind mount (./data), so you can see it. The GUIs keep small private state in named volumes |
| Published port | A port on your machine forwarded to a port in a container | How you — your browser, curl, a protocol client — reach the system. The containers do not use them to reach each other |
Two more appear further down: a compose project (the group of containers one compose command manages, which also gets its own private network) and a Podman pod (a named group of containers). Both are explained where they come up in Projects, networks and pods.
The two directories¶
The deployment lives in two directories. They describe the same system: one builds it from source, the other runs the released images.
flowchart LR
subgraph repo["Repository checkout"]
SRC["src/edumatcher\n(Python)"]
WEB["web-apps/*\n(Node)"]
DOCKER["deployment/docker/\nDockerfile, compose files,\nMakefile"]
end
subgraph ghcr["GitHub Container Registry (ghcr.io)"]
IMGS["edumatcher\nedumatcher-ai-sim\nedumatcher-*-gui"]
end
subgraph home["Your machine, no checkout"]
CURL["~/.edumatcher/\ncompose.yaml, .env,\nedumatcher.sh"]
end
SRC --> DOCKER
WEB --> DOCKER
DOCKER -->|"make build / make up-all\nbuilds images locally"| LOCAL["Local images\nedumatcher:local\nedumatcher-*-gui:latest"]
DOCKER -->|"release workflow\nbuilds amd64 + arm64"| IMGS
IMGS -->|"pulled by"| CURL
INSTALL["deployment/curl/install.sh"] -->|"downloads the files\nfrom the release tag"| CURL
deployment/docker/ |
deployment/curl/ |
|
|---|---|---|
| For | Changing EduMatcher and seeing the change run | Running a released version |
| Images come from | Built on your machine from the checkout | Pulled from ghcr.io, never built |
| Needs | Podman or Docker, plus Poetry and Node for the builds | Podman or Docker only |
| Driven by | make targets (Makefile) |
./edumatcher.sh |
| Lives in | The repository | One directory, ~/.edumatcher by default |
Because they describe one system, the compose files in deployment/curl/ are
twins of those in deployment/docker/. They differ in only two ways:
they name images on GHCR instead of having build: sections, and they put all
six GUIs in one file.
What is in each directory¶
deployment/docker/:
| File | Purpose |
|---|---|
Dockerfile |
Builds the backend image, in three stages: builder, runtime (the exchange) and ai-sim (the self-trading exchange) |
entrypoint.sh |
The script every backend container runs at start: deploys the configuration, starts the processes, waits, and shuts them down |
compose.yaml |
The base compose file: the exchange container alone, with its gateway ports |
compose.zmq.yaml |
Overlay: also publish the internal ZeroMQ bus |
compose.ssh.yaml |
Overlay: run an SSH server in the exchange container |
compose.guis.yaml |
Overlay: the five web GUIs that talk to the exchange |
compose.config-gui.yaml |
Overlay: the configuration builder, which talks to nothing |
compose.ai-sim.yaml |
A separate, self-contained stack: the AI-trader simulation and five GUIs |
Makefile |
Every build and run command, with the right files and variables filled in |
.env.example |
The default settings, copied to .env on first use |
.dockerignore |
Keeps everything except the wheel and the entrypoint out of the build |
.wheel/ |
A drop box: a Python wheel placed here is what the image installs |
README.md |
A summary of this chapter, beside the files |
deployment/curl/:
| File | Purpose |
|---|---|
install.sh |
The one-line installer: resolves a release, downloads the files below, writes .env and starts everything |
edumatcher.sh |
The control script copied into the install directory: start, stop, status, config, update and the rest |
compose.yaml |
The exchange and all six GUIs, from GHCR images |
compose.zmq.yaml |
Overlay: publish the ZeroMQ bus (EM_ZMQ=1 in .env) |
compose.ai-sim.yaml |
The AI-trader simulation, from GHCR images (install.sh --sim) |
.env.example / .env.sim.example |
The default settings for the exchange and for the simulation |
README.md |
Usage notes for a released install |
The images¶
Eight images, one backend Dockerfile¶
| Image | Built from | Listens on (in the container) | Contains |
|---|---|---|---|
edumatcher |
deployment/docker/Dockerfile, stage runtime |
All exchange ports (see Ports) | Python 3.14 and the edumatcher package with every pm-* command |
edumatcher-ai-sim |
same Dockerfile, stage ai-sim |
same | The runtime image with different defaults: s300-sim, the ai-sim profile, Stockholm time |
edumatcher-terminal-gui |
web-apps/terminal-gui/Dockerfile |
8090 | TapeDeck: a React front end served by a small Node server (the bridge) |
edumatcher-log-gui |
web-apps/log-gui/Dockerfile |
8091 | The Log Operator Console, plus a Python copy of edumatcher for its diagnostics view |
edumatcher-config-gui |
web-apps/config-gui/Dockerfile |
8092 | The configuration builder |
edumatcher-trader-gui |
web-apps/trader-gui/Dockerfile |
8093 | The trading GUI |
edumatcher-book-gui |
web-apps/book-gui/Dockerfile |
8094 | The order book viewer |
edumatcher-dash-gui |
web-apps/dash-gui/Dockerfile |
8095 | The operator dashboard |
Locally built images are tagged edumatcher:local (from IMAGE_NAME and
IMAGE_TAG in .env) and edumatcher-<app>:latest. Released images are
ghcr.io/johan162/<image>:<version>, with latest following the newest
release. One version tag pins all eight images to the same release.
Why the whole exchange is one container¶
It is tempting to expect one container per process: one for pm-engine, one
for each gateway, and so on. EduMatcher deliberately does not do that.
The exchange's processes talk to each other over a ZeroMQ message bus, and the
engine binds that bus to 127.0.0.1, the loopback interface. Loopback is
private to one network namespace. Each container normally has its own, so
processes in different containers could not see the bus. Putting every
pm-* process in one container gives them one shared loopback, exactly as
they have when you run them by hand on one machine or in the
VM runtime image.
Inside that container, pm-opctl-cli is the process manager: it starts the
processes of a profile, watches them and stops them. The container engine
sees only one container; pm-opctl-cli manages what runs inside it.
flowchart TB
subgraph be["Container: edumatcher (one network namespace)"]
TINI["tini (PID 1)"] --> EP["entrypoint.sh"]
EP --> OPCTL["pm-opctl-cli start PROFILE"]
OPCTL --> P1["pm-log-srv"]
OPCTL --> P2["pm-engine"]
OPCTL --> P3["pm-audit · pm-stats · pm-clearing"]
OPCTL --> P4["pm-scheduler · pm-index"]
OPCTL --> P5["gateways: alf, balf, md, ralf, dc"]
OPCTL --> P6["pm-api-gwy (desk + dashboards)"]
P2 <-->|"ZeroMQ bus on 127.0.0.1\n5555-5559"| P3
P2 <--> P4
P2 <--> P5
P2 <--> P6
end
The backend Dockerfile, stage by stage¶
A multi-stage Dockerfile builds in steps. Each FROM starts a fresh stage,
and a later stage copies only the results it needs out of an earlier one. Build
tools, pip caches and temporary files therefore never reach the image you run.
flowchart LR
subgraph s1["Stage 1: builder"]
B1["python:3.14-slim-bookworm"] --> B2["COPY .wheel/"]
B2 --> B3["python -m venv /opt/edumatcher/.venv"]
B3 --> B4["pip install edumatcher\n(wheel, pinned or latest)"]
end
subgraph s2["Stage 2: runtime (image edumatcher)"]
R1["python:3.14-slim-bookworm"] --> R2["apt: ca-certificates, curl,\nprocps, tini, tzdata\n(+ openssh-server)"]
R2 --> R3["COPY --from=builder\n/opt/edumatcher/.venv"]
R3 --> R4["ENV PATH, EDUMATCHER_DATA_DIR=/data"]
R4 --> R5["shell profile + bash completion"]
R5 --> R6["entrypoint.sh, EXPOSE,\nHEALTHCHECK, ENTRYPOINT"]
end
subgraph s3["Stage 3: ai-sim (image edumatcher-ai-sim)"]
A1["FROM runtime"] --> A2["ENV EM_CONFIG=s300-sim\nEM_PROFILE=ai-sim\nEM_RESTART_DEAD=1\nEM_SPEED=1\nTZ=Europe/Stockholm"]
end
B4 -->|"only the venv"| R3
R6 --> A1
Stage 1 — builder. Creates a Python virtual environment and installs
EduMatcher into it. Where the package comes from is decided by what is in the
.wheel/ drop box and by one build argument:
flowchart TD
Q1{"A *.whl file\nin .wheel/ ?"} -->|yes| W["pip install that wheel\n(your checkout)"]
Q1 -->|no| Q2{"EDUMATCHER_VERSION\nset?"}
Q2 -->|yes| V["pip install edumatcher==VERSION\n(that PyPI release)"]
Q2 -->|no| L["pip install edumatcher\n(newest PyPI release)"]
make build puts a freshly built wheel in .wheel/ unless you pass PYPI=1
or VERSION=, in which case it empties the drop box first. That is what makes
"build from my checkout" the default. A build log line
Installing local wheel: /tmp/wheel/edumatcher-...whl confirms your code went
in.
Stage 2 — runtime. A clean copy of the same base image, plus five small
OS packages, each there for a reason:
| Package | Why it is needed |
|---|---|
tini |
Runs as PID 1. pm-opctl-cli starts processes detached; when one exits, someone has to reap it, and a shell script is not good at that |
procps |
pgrep and ps, which pm-opctl-cli and the *-cli health commands use to find processes |
tzdata |
The time zone database. The scheduler and market calendars need real zone rules |
ca-certificates, curl |
HTTPS from inside the container, for diagnostics |
openssh-server |
Only when WITH_SSH=1 (the default), for make up SSH=1 |
It then copies in the virtual environment from stage 1 and sets two
environment variables every process depends on: PATH, so pm-engine and the
rest are plain commands, and EDUMATCHER_DATA_DIR=/data, so every process
reads and writes the mounted data directory. The same two settings are also
written to /etc/profile.d/ and /root/.bashrc. An SSH login does not inherit
the image's environment, so it needs them there.
The last lines of the stage set up how the container runs:
EXPOSElists the ports the processes use. It is documentation only: nothing becomes reachable because of it. The compose files decide what is published.HEALTHCHECKopens a TCP connection to the engine's order-intake socket on 5555 every 30 seconds, after a 60-second grace period.podman psordocker psthen show the container ashealthyorunhealthy. For a check of every process, usemake health(pm-opctl-cli health).ENTRYPOINTrunstini, which runsentrypoint.sh. See What happens when the exchange container starts.
Stage 3 — ai-sim. Adds nothing but default settings on top of
runtime. The edumatcher-ai-sim image is the same software, preset to deploy
the s300-sim example, run the ai-sim profile on Stockholm trading hours,
and restart any process that dies. A plain podman build with no --target
builds this last stage. That is why every compose file that builds the
exchange names target: runtime explicitly.
Build arguments of the backend image¶
Build arguments are values you can pass at build time with --build-arg. They
affect what goes into the image, not how it runs.
| Argument | Default | Effect |
|---|---|---|
PYTHON_VERSION |
3.14 |
Tag of the python:*-slim-bookworm base image, for both stages |
EDUMATCHER_VERSION |
(empty) | PyPI release to install when .wheel/ holds no wheel. Empty means the newest |
PIP_INDEX_URL |
(empty) | A PyPI mirror, such as a corporate Artifactory, for builds behind a firewall |
WITH_SSH |
1 |
0 leaves out openssh-server for a smaller image |
compose.yaml passes the last three from the variables of the same name in
your .env or shell.
The web GUI images¶
All six GUI Dockerfiles follow one pattern, also in two stages:
flowchart LR
subgraph b["build stage: node:22-slim"]
N1["npm ci\n(all dependencies)"] --> N2["npm run build\n(React bundle)"] --> N3["npm prune --omit=dev"]
end
subgraph r["runtime stage: node:22-slim"]
R1["node_modules, bridge,\nweb/dist"] --> R2["CMD npm run start\n(the bridge serves the UI\nand proxies to the exchange)"]
end
N3 --> R1
Each image runs one small Node server, the bridge. It serves the browser
application and relays its requests to the exchange, so the browser only ever
talks to one port. The bridges have no durable state of their own. The named
volumes mounted into them hold a failover log, used while pm-log-srv is
unreachable, and the log console's acknowledgements.
Their build arguments are mostly for corporate networks: HTTP_PROXY,
HTTPS_PROXY, NO_PROXY, NPM_STRICT_SSL (false when a proxy re-signs
TLS), NPM_REGISTRY (an npm mirror) and WEB_PORT.
Image build arguments in the
installation chapter lists which of them the compose files actually pass on.
The log console carries a copy of EduMatcher too
edumatcher-log-gui is the one GUI image with Python in it. Its
diagnostics view runs pm-log-cli rather than reimplementing that tool in
TypeScript. It picks its edumatcher package the same way the backend
does, from a wheel in web-apps/log-gui/.wheel/, and make build-guis
fills that drop box by the same rule as make build: a wheel built from
your checkout, or a PyPI release with PYPI=1 or VERSION=x.y.z (passed
to the build as EDUMATCHER_VERSION).
How released images are built¶
You never need to do this yourself, but it explains what you pull. Publishing a
GitHub release runs .github/workflows/publish-images.yml. It builds all
eight images (edumatcher, edumatcher-ai-sim and the six GUIs) natively on
an amd64 and an arm64 runner, and joins each pair into one multi-arch
manifest. Both backend images and the log console get the release's own wheel
in their .wheel/ drop box, so their Python code is exactly the release. The
same tag is pushed for every image; that is why one EM_VERSION pins the
whole system.
What happens when the exchange container starts¶
The container's command is tini -- entrypoint.sh. entrypoint.sh reads a
handful of environment variables (all set by the compose files from your
.env) and runs four steps.
sequenceDiagram
autonumber
participant CE as Container engine
participant T as tini (PID 1)
participant E as entrypoint.sh
participant O as pm-opctl-cli
participant D as /data (bind mount)
CE->>T: start container
T->>E: run entrypoint.sh
E->>D: deploy the engine configuration<br/>(pm-setup or pm-config-deploy)
opt EM_SSH=1
E->>E: restore host keys from /data/ssh-hostkeys,<br/>install authorized_keys, start sshd
end
opt EM_SPEED / EM_START set
E->>E: export EDUMATCHER_SCHEDULER_SPEED / _START
end
E->>O: pm-opctl-cli start EM_PROFILE
O->>D: PID files and logs in /data/emo/
E->>O: pm-opctl-cli list (printed to the container log)
loop until stopped
alt EM_RESTART_DEAD=1
E->>O: every 60 s: pm-opctl-cli list --restart
else
E->>E: sleep
end
end
CE->>T: SIGTERM (podman stop / compose down)
T->>E: forward SIGTERM
E->>O: pm-opctl-cli stop
E-->>CE: exit 0
Step 1: deploying the configuration¶
Which configuration is deployed depends on two variables and on a small stamp
file, /data/.container-config, which records the example name the data
directory was built from:
flowchart TD
S{"EM_CONFIG_FILE\nset?"} -->|yes| F["pm-setup --no-config\npm-config-deploy EM_CONFIG_FILE\n(delete the stamp)"]
S -->|no| C{"stamp exists and\ndiffers from EM_CONFIG?"}
C -->|yes| R["pm-setup --config EM_CONFIG --force\n(redeploy: the example changed)"]
C -->|no| K["pm-setup --config EM_CONFIG\n(keeps an existing deployment)"]
R --> W["write EM_CONFIG to the stamp"]
K --> W
In practice:
- A configuration file of your own (
EM_CONFIG_FILE) is deployed on every start. Editing the file and restarting is the whole edit-and-test loop. - A bundled example (
EM_CONFIG) is deployed once and then kept across restarts. That is what you want when you have editeddata/ref_data/engine_config.yamlby hand. Naming a different example redeploys.
Either way the result is /data/ref_data/engine_config.json, the compiled
configuration every process reads.
Step 4: stopping cleanly¶
podman stop, docker stop and compose down send the container SIGTERM,
then wait for a grace period before killing it. tini forwards the signal to
entrypoint.sh, whose handler runs pm-opctl-cli stop. That stops every
process of the profile in order, so each one can flush its files.
stop_grace_period in the compose files (30 seconds, 60 for the simulation)
gives that walk enough time before the engine resorts to SIGKILL.
The container log is the entrypoint's log
make logs, podman logs edumatcher or ./edumatcher.sh logs edumatcher
show the ==> lines above: which configuration was deployed, which profile
started, and the process table. Each process writes its own log to
data/emo/<name>.log.
The compose files, one by one¶
How compose files combine¶
Compose reads one or more files, given with -f, and merges them in
order. A later file can add services, add entries to a service's environment,
ports or volumes, or override single values. The base file stays small; each
optional feature is a separate overlay you add only when you want it.
flowchart LR
BASE["compose.yaml\nthe exchange container"] --> M(("merged\nconfiguration"))
ZMQ["compose.zmq.yaml\n+ bus ports, + 2 env vars"] -.->|"ZMQ=1"| M
SSH["compose.ssh.yaml\n+ sshd, + key mount, + port 22"] -.->|"SSH=1"| M
GUIS["compose.guis.yaml\n+ 5 GUI services"] -.->|"make up-all"| M
CFG["compose.config-gui.yaml\n+ config-gui service"] -.->|"CONFIG_GUI=1"| M
M --> RUN["podman-compose / docker compose\nup -d"]
The make flags only choose which -f arguments are passed:
| Command | Files passed |
|---|---|
make up |
compose.yaml |
make up ZMQ=1 SSH=1 |
compose.yaml compose.zmq.yaml compose.ssh.yaml |
make up-all |
compose.yaml compose.guis.yaml |
make up-all ZMQ=1 CONFIG_GUI=1 |
compose.yaml compose.zmq.yaml compose.guis.yaml compose.config-gui.yaml |
make up-sim |
compose.ai-sim.yaml alone, as project edumatcher-sim |
To see the result of a merge without starting anything, ask compose to print it:
cd deployment/docker
podman-compose -f compose.yaml -f compose.zmq.yaml config # or: docker compose ... config
${VARIABLE:-default}: where the values come from¶
The compose files contain almost no fixed settings. Nearly every value is a
reference such as ${BIND_ADDR:-127.0.0.1}, meaning the variable
BIND_ADDR, or 127.0.0.1 if it is unset. Compose fills these in from your
shell environment first, then from the .env file beside the compose file.
When a value can be set in several places, the nearest one wins:
So make up CONFIG=s10-nominal affects that one start only and does not edit
.env. The next plain make up goes back to what .env says.
Two kinds of variable
Some variables are passed into a container as its environment (they
appear under environment:), for example EM_CONFIG. Others are used
only by compose itself to fill in the file and never reach a container,
for example BIND_ADDR, the GUI host ports and IMAGE_NAME. The tables
below say which is which.
compose.yaml — the exchange¶
The base file defines one service, edumatcher, built from the Dockerfile's
runtime stage.
flowchart LR
subgraph host["Your machine"]
ENV[".env"]
DATA["./data"]
CONF["./config"]
P["127.0.0.1:5560, 5565, 5570,\n5580, 5590, 5600, 8080, 8081"]
end
subgraph c["Container: edumatcher"]
D["/data (read-write)"]
CF["/config (read-only)"]
G["gateways, pm-log-srv,\npm-api-gwy"]
end
ENV -->|"EM_CONFIG, EM_CONFIG_FILE,\nEM_PROFILE, TZ,\nEDUMATCHER_GATEWAY_BIND_HOST"| c
DATA --- D
CONF --- CF
P -->|"published"| G
| Section | Setting | Why |
|---|---|---|
build |
context: ., target: runtime, three args |
Build the exchange (not the AI-sim stage) from this directory |
image |
${IMAGE_NAME:-edumatcher}:${IMAGE_TAG:-local} |
The name the built image is tagged with |
container_name / hostname |
edumatcher |
A fixed name, so podman exec edumatcher ... works and the GUIs can find it |
restart |
unless-stopped |
Restart after a crash, but not after you stopped it deliberately |
stop_grace_period |
30s |
Time for pm-opctl-cli stop to stop every process cleanly |
environment |
see below | What entrypoint.sh and the processes read |
volumes |
./data:/data, ./config:/config:ro |
See Volumes and mounts |
ports |
eight client-facing ports | See Ports |
Environment passed into the container:
| Variable | Default | Read by | Effect |
|---|---|---|---|
EM_CONFIG |
s10-basic |
entrypoint.sh |
Bundled example configuration to deploy |
EM_CONFIG_FILE |
(empty) | entrypoint.sh |
Path inside the container to a configuration of your own; wins over EM_CONFIG |
EM_PROFILE |
default |
entrypoint.sh |
Which pm-opctl-cli profile to start: default, mini, micro, mm-demo, ai-swarm, ai-sim |
EDUMATCHER_GATEWAY_BIND_HOST |
0.0.0.0 |
every gateway, pm-log-srv, pm-api-gwy |
The interface the client-facing listeners bind inside the container. 0.0.0.0 is what lets the GUI containers reach them |
TZ |
Europe/Stockholm |
everything | The container's time zone. Match it to your trading calendar |
entrypoint.sh also reads EM_SSH, EM_SPEED, EM_START and
EM_RESTART_DEAD, which the overlays and the AI-sim stack set.
compose.zmq.yaml — the raw message bus¶
Adds seven ports and two environment variables to the edumatcher service, so
tools on your machine (pm-calf-spy, pm-dc-spy, pm-index-cli, your own
ZeroMQ clients) can attach to the bus directly.
| Adds | Value | Why |
|---|---|---|
EDUMATCHER_ENGINE_BIND_HOST |
0.0.0.0 |
The engine binds its bus to 127.0.0.1 by default; publishing the port alone would forward to nothing |
EDUMATCHER_INDEX_BIND_HOST |
0.0.0.0 |
Same for pm-index |
| ports | 5555–5559, 5601, 5602 | The engine's, the index's and the log server's ZeroMQ sockets |
The bus has no authentication
Anything that can reach 5555 can submit orders as if it were a gateway.
Keep BIND_ADDR=127.0.0.1 while this overlay is on.
compose.ssh.yaml — SSH into the exchange¶
| Adds | Value | Why |
|---|---|---|
EM_SSH |
1 |
Tells entrypoint.sh to start sshd |
| volume | ./.ssh/authorized_keys:/run/host-authorized-keys:ro |
Your public keys, collected by make keys from ~/.ssh/*.pub |
| port | ${SSH_PORT:-2222} → 22 |
ssh -p 2222 root@localhost |
The keys are mounted read-only and copied into place, because sshd refuses
key files it does not own with mode 0600. The container's host keys are
generated once and kept in data/ssh-hostkeys/, so recreating the container
does not trigger a "host key changed" warning. No key material is ever built
into the image.
compose.guis.yaml — the four connected web GUIs¶
Adds four services in the same compose project as the exchange. That is
the important part: being in one project puts them on one private network,
where the exchange answers to the hostname edumatcher.
flowchart LR
subgraph host["Your machine"]
BR["Browser"]
end
subgraph net["Compose network: edumatcher_default"]
T["terminal-gui\n:8090"]
L["log-gui\n:8091"]
TR["trader-gui\n:8093"]
B["book-gui\n:8094"]
D["dash-gui\n:8095"]
E["edumatcher"]
end
BR -->|"8090"| T
BR -->|"8091"| L
BR -->|"8093"| TR
BR -->|"8094"| B
BR -->|"8095"| D
T -->|"edumatcher:5570 market data\nedumatcher:8081 history\nedumatcher:5600 own logs"| E
L -->|"edumatcher:5601 / 5602\nlive log stream\nedumatcher:5570 session phase"| E
TR -->|"edumatcher:8080\nREST + WebSocket"| E
B -->|"edumatcher:8081\nmarket data + history\nedumatcher:5570 session phase\nedumatcher:5600 own logs"| E
D -->|"edumatcher:5611\nhealth snapshots\nedumatcher:5570 session phase\nedumatcher:5600 own logs"| E
L -.->|"reads log.db from\n./data, read-only"| DATA[("./data")]
D -.->|"reads metrics.db and the\nconfiguration, read-only"| DATA
| Service | Talks to | Environment that wires it | Storage |
|---|---|---|---|
terminal-gui |
pm-md-gwy (CALF, 5570), pm-api-gwy dashboards (8081), pm-log-srv (5600) |
CALF_HOST/CALF_PORT, API_GATEWAY_URL, PM_TERMINAL_API_KEY, LOG_SRV_HOST/LOG_SRV_PORT, INDEX_IDS, MAX_WS_CLIENTS |
named volume terminal-gui-logs |
log-gui |
pm-log-srv LALF-PS (5601 PUB, 5602 PULL), pm-md-gwy (CALF, 5570) |
LOG_SRV_HOST, LOG_SRV_PUB_PORT, LOG_SRV_PULL_PORT, CALF_HOST, LOG_DB_PATH, ACK_STORE_PATH |
./data read-only at /backend-data; named volume log-gui-acks |
trader-gui |
pm-api-gwy desk (8080) |
API_PROXY_TARGET |
none |
book-gui |
pm-api-gwy dashboards (8081), pm-md-gwy (CALF, 5570), pm-log-srv (5600) |
API_GATEWAY_URL, PM_BOOK_API_KEY, CALF_HOST, LOG_SRV_HOST/LOG_SRV_PORT |
named volume book-gui-logs |
dash-gui |
pm-monitor (5611 PUB), pm-md-gwy (CALF, 5570), pm-api-gwy dashboards (8081), pm-log-srv (5600) |
MONITOR_HOST/MONITOR_PUB_PORT, CALF_HOST, API_GATEWAY_URL, PM_DASH_API_KEY, METRICS_DB_PATH, CONFIG_ARTIFACT_PATH, LOG_GUI_URL/BOOK_GUI_URL, LOG_SRV_HOST/LOG_SRV_PORT |
./data read-only at /backend-data; named volume dash-gui-logs |
Every GUI also gets HOST=0.0.0.0 (listen on the container's interface),
PORT (its own port) and CORS_ORIGIN. depends_on: edumatcher only
controls start order. It does not wait for the exchange to be ready, which is
one reason make up-all starts in two phases (see below).
Note what is not involved: none of the GUI-to-exchange traffic uses a
published port. The exchange's published ports are there for you; the GUIs
reach it across the private network. The log console's live stream on
5601/5602 therefore works without ZMQ=1. pm-log-srv binds those sockets
on 0.0.0.0 inside the container, and the network carries the traffic. The
same holds for the dashboard and pm-monitor's 5611, which
EDUMATCHER_GATEWAY_BIND_HOST opens inside the container.
compose.config-gui.yaml — the configuration builder¶
One service, config-gui, on port 8092. It writes configuration files in the
browser and talks to nothing else, so it is kept out of compose.guis.yaml
and added only with CONFIG_GUI=1. Unlike the other GUIs it has no
depends_on.
compose.ai-sim.yaml — the self-trading market¶
A different kind of file: self-contained, not an overlay. It sets its own
project name (name: edumatcher-sim) and pulls every image from GHCR by
default. It needs no .env and no build.
flowchart LR
subgraph sim["Compose project: edumatcher-sim"]
E["edumatcher-sim\n(image edumatcher-ai-sim)\ns300-sim, profile ai-sim"]
T["edumatcher-sim-terminal-gui"]
B["edumatcher-sim-book-gui"]
DG["edumatcher-sim-dash-gui"]
L["edumatcher-sim-log-gui"]
TR["edumatcher-sim-trader-gui"]
end
D[("./data-sim")] --- E
D -. "read-only" .- L
D -. "read-only" .- DG
DG --> E
T --> E
B --> E
L --> E
TR --> E
Its differences from the plain exchange:
| Setting | Value | Why |
|---|---|---|
| backend image | ${AISIM_IMAGE:-ghcr.io/.../edumatcher-ai-sim:${EM_VERSION:-latest}} |
make up-sim LOCAL=1 swaps in a locally built edumatcher-ai-sim:local |
EM_SPEED / EM_START |
1 / (empty) |
Run the trading calendar faster than real time, from a chosen start |
| data | ./data-sim |
Its own data directory, so it never touches the exchange's ./data |
stop_grace_period |
60s |
100 AI traders and the market model take longer to stop |
| read-only key | PM_READONLY_API_KEY, default key-readonly-9vaho7 |
Fixed, because the configuration is fixed: no two-phase start needed |
| GUIs | four (no config-gui) | Nothing to configure in a preset simulation |
It publishes the same host ports as the exchange stack, so only one of the two can run at a time. Running the simulation is covered in the AI Traders book's The Simulation Container.
Projects, networks and pods¶
When you run up, the container engine creates more than containers. Knowing
the names helps when you list things with podman ps, podman network ls or
podman volume ls.
The compose project and its names¶
Every compose run belongs to a project. The project name prefixes the
network and the named volumes it creates. It comes from, in order: -p on the
command line, name: in the compose file, COMPOSE_PROJECT_NAME in .env,
and finally the directory name.
| Stack | Project name | Why |
|---|---|---|
deployment/docker, make up / up-all |
edumatcher |
COMPOSE_PROJECT_NAME=edumatcher in .env. Without a .env it would be docker, the directory name |
curl install in ~/.edumatcher |
edumatcher |
The directory name, with the leading dot dropped |
| either AI-sim stack | edumatcher-sim |
name: in the file; the Makefile also passes -p edumatcher-sim |
What a project named edumatcher creates:
| Object | Name | Created by |
|---|---|---|
| Network | edumatcher_default |
Compose, for every project |
| Containers | edumatcher, edumatcher-terminal-gui, edumatcher-log-gui, edumatcher-trader-gui, edumatcher-book-gui, edumatcher-dash-gui, edumatcher-config-gui |
container_name: in the files |
| Named volumes | edumatcher_log-gui-acks, edumatcher_terminal-gui-logs, edumatcher_book-gui-logs, edumatcher_dash-gui-logs |
volumes: at the bottom of the GUI files |
| Pod (Podman only) | pod_edumatcher |
podman-compose |
One stack at a time
A source-built stack and a curl install have the same project name, the
same container names and the same host ports. If one is running, the
other would quietly attach to its containers. Both tools check for this
and refuse to start: make up-all checks via check-foreign, and
./edumatcher.sh start checks that the existing edumatcher container
mounts its own data directory. Stop one before starting the other.
make mounts and ./edumatcher.sh mounts show which directory each
running container really uses.
The network¶
Compose gives each project one private network and attaches every service to
it, registering the service name as a hostname on that network. The
exchange's service is called edumatcher, so edumatcher:8081 from inside
terminal-gui reaches the exchange's REST API.
flowchart TB
subgraph host["Your machine (host network)"]
LO["127.0.0.1:8090-8095\n127.0.0.1:5560 … 8081\n(published ports)"]
end
subgraph net["Private network edumatcher_default"]
direction LR
E["edumatcher\n(hostname: edumatcher)"]
T["terminal-gui"]
L["log-gui"]
TR["trader-gui"]
B["book-gui"]
DG["dash-gui"]
C["config-gui"]
end
LO -->|"port forwarding"| net
T --- E
L --- E
TR --- E
B --- E
DG --- E
Each container has its own network namespace and its own 127.0.0.1. A
process listening on 127.0.0.1 inside edumatcher is invisible to the GUI
containers. That is why the client-facing listeners bind 0.0.0.0: every
interface of this container, which still means only this private network
plus whatever ports you publish.
How the containers are wired
covers the three meanings of 127.0.0.1 / 0.0.0.0 in detail.
Pods (Podman only)¶
A pod is Podman's way of grouping containers. Docker has no pods; with Docker this section does not apply.
podman-compose puts all containers of a project into one pod named
pod_<project>. EduMatcher's stacks get pod_edumatcher or
pod_edumatcher-sim. It creates that pod with --infra=false --share=, which
means the containers in it share no namespaces: each keeps its own network
stack, and they talk over the compose network exactly as described above. The
pod is a label that lets you manage the group as a whole:
podman pod ls # pod_edumatcher, with its container count
podman pod ps # (same, older syntax)
podman ps --pod # containers with their pod
podman pod stop pod_edumatcher # stop the whole group
podman pod logs pod_edumatcher # every container's output
You never need to create the pod yourself: up creates it, down removes it.
Use make down-all or ./edumatcher.sh stop rather than removing the pod by
hand, so compose also cleans up the network.
podman compose versus podman-compose
podman compose (with a space) is a thin wrapper that runs whichever
compose program it finds first, which may be Docker's docker-compose.
The Makefile and edumatcher.sh prefer the standalone podman-compose
when it is installed. make info shows which one was picked.
Volumes and mounts¶
Every piece of storage a container uses, and why it is set up the way it is:
| Container | Container path | Host side | Type | Mode | Why |
|---|---|---|---|---|---|
edumatcher |
/data |
./data (curl: ~/.edumatcher/data) |
bind mount | read-write | The exchange's whole state: configuration, databases, audit log, process logs. On your disk so it survives every rebuild and you can read it directly |
edumatcher |
/config |
./config |
bind mount | read-only | A configuration file of your own, deployed from here at every start. Read-only because the exchange has no business changing your source file |
edumatcher |
/run/host-authorized-keys |
./.ssh/authorized_keys |
bind mount, SSH=1 only |
read-only | Your public keys; copied into place by the entrypoint |
log-gui |
/backend-data |
./data |
bind mount | read-only | The console reads log.db directly for history, and must never write the log server's database |
log-gui |
/app/ack-data |
<project>_log-gui-acks |
named volume | read-write | Which log entries an operator has acknowledged |
terminal-gui |
/app/logs |
<project>_terminal-gui-logs |
named volume | read-write | Failover log, written while pm-log-srv is unreachable |
book-gui |
/app/logs |
<project>_book-gui-logs |
named volume | read-write | Same, for the order book viewer |
dash-gui |
/backend-data |
./data |
bind mount | read-only | The dashboard reads metrics.db (history) and ref_data/engine_config.json (the threshold bands), and must never write either |
dash-gui |
/app/logs |
<project>_dash-gui-logs |
named volume | read-write | Failover log, for the operator dashboard |
AI-sim edumatcher-sim |
/data |
./data-sim (curl --sim: ~/.edumatcher-sim/data) |
bind mount | read-write | The simulation's own state, separate from the exchange's |
flowchart LR
subgraph disk["Your disk"]
D["./data"]
CF["./config"]
K["./.ssh/authorized_keys"]
end
subgraph engine["Container engine storage"]
V1["log-gui-acks"]
V2["terminal-gui-logs"]
V3["book-gui-logs"]
V4["dash-gui-logs"]
end
D -->|"rw /data"| E["edumatcher"]
CF -->|"ro /config"| E
K -->|"ro SSH=1"| E
D -->|"ro /backend-data"| L["log-gui"]
V1 -->|"rw"| L
V2 -->|"rw"| T["terminal-gui"]
V3 -->|"rw"| B["book-gui"]
D -->|"ro /backend-data"| DG["dash-gui"]
V4 -->|"rw"| DG
The split is deliberate. Exchange data is in a bind mount because it is
yours: you back it up, inspect it with sqlite3, delete it to start over. The
GUI volumes hold disposable private state that nothing else needs, so they
live where the container engine keeps such things. They are removed by
./edumatcher.sh uninstall or compose down --volumes; ./data is not.
The :z on every bind mount
Every compose file adds :z to its bind mounts (./data:/data:z,
./data:/backend-data:ro,z). On SELinux systems such as Fedora and RHEL,
:z asks the container engine to relabel the directory so containers may
use it; without it the exchange cannot write ./data at all. Elsewhere it
has no effect. Keep it in a compose file of your own.
What lives in ./data is listed in
Directories and paths.
Ports and why each one exists¶
A port appears in up to three places. The container port is where the
process listens. The published port is the matching port on your machine,
created by ports: in a compose file. BIND_ADDR decides which of your
machine's interfaces it opens on. Unpublished ports are reachable only inside
the container, or across the compose network if the process binds 0.0.0.0.
flowchart LR
C["Client on your machine\n(browser, curl, ALF client)"] -->|"BIND_ADDR:5565"| PUB["Published port\non your machine"]
PUB -->|"forwarded"| CP["Container port 5565"]
CP --> PR["pm-alf-gwy listening on\nEDUMATCHER_GATEWAY_BIND_HOST\n(0.0.0.0)"]
S["Sibling GUI container"] -->|"edumatcher:5570\n(no publishing involved)"| CP2["Container port 5570"]
The exchange container¶
| Port | Process | Kind | Published | Why it exists |
|---|---|---|---|---|
| 5560 | pm-balf-gwy |
TCP | always | Binary ALF order entry for trading clients |
| 5565 | pm-alf-gwy |
TCP | always | Text ALF order entry for trading clients and the protocol examples |
| 5570 | pm-md-gwy |
TCP | always | Market data (CALF). Also what terminal-gui reads its live feed from |
| 5580 | pm-ralf-gwy |
TCP | always | Post-trade feed (RALF) for clearing and drop-copy clients |
| 5590 | pm-dc-gwy |
TCP | always | Drop copy (DCLF) |
| 5600 | pm-log-srv |
TCP | always | Log ingest (LALF). Clients outside the container, including the GUI bridges, send their logs here |
| 8080 | pm-api-gwy |
HTTP | always | REST and WebSocket API, desk instance: trading credentials. Swagger UI at /docs. trader-gui uses it |
| 8081 | pm-api-gwy |
HTTP | always | REST and WebSocket API, dashboards instance: the read-only key. terminal-gui and book-gui use it |
| 5555 | pm-engine |
ZeroMQ PULL | ZMQ=1 |
Order intake: every gateway sends orders to the engine here |
| 5556 | pm-engine |
ZeroMQ PUB | ZMQ=1 |
Event and order-book feed |
| 5557 | pm-engine |
ZeroMQ PUB | ZMQ=1 |
Drop-copy feed |
| 5558 | pm-index |
ZeroMQ PUB | ZMQ=1 |
Index values |
| 5559 | pm-index |
ZeroMQ PULL | ZMQ=1 |
Index commands |
| 5601 | pm-log-srv |
ZeroMQ PUB | ZMQ=1 |
Live log broadcast (LALF-PS). log-gui reads it across the network either way |
| 5602 | pm-log-srv |
ZeroMQ PULL | ZMQ=1 |
Log console control channel |
| 5611 | pm-monitor |
ZeroMQ PUB | ZMQ=1 |
Health snapshots and events. dash-gui reads it across the network either way; published for pm-monitor-cli or a dashboard run from source |
| 5610 | pm-monitor |
ZeroMQ PULL | never | Health beacons from every process inside the container. Never published: anything that reached it could report fake health |
| 22 | sshd |
TCP | SSH=1, as SSH_PORT (2222) |
Shell access with your SSH keys |
| 5561–5564 | pm-stats, pm-audit, pm-clearing, pm-index |
ZeroMQ REP | never | Health pings answered by each process, used by pm-*-cli health and pm-opctl-cli list inside the container |
| 5553, 5554 | pm-market-sim |
ZeroMQ | never | The market model's true values and instructor commands: kept away from students on purpose |
The client-facing ports are published because something outside the container
has to reach them. The bus and health ports are not, because only the
exchange's own processes use them, over the container's loopback interface.
ZMQ=1 is the deliberate exception, for when you want to attach your own
tools to the bus.
The GUI containers¶
| Host port (variable) | Container port | Service | Why |
|---|---|---|---|
TERMINAL_GUI_PORT (8090) |
8090 | terminal-gui |
TapeDeck in your browser |
LOG_GUI_PORT (8091) |
8091 | log-gui |
Log Operator Console |
CONFIG_GUI_PORT (8092) |
8092 | config-gui |
Configuration builder |
TRADER_GUI_PORT (8093) |
8093 | trader-gui |
Trading GUI |
BOOK_GUI_PORT (8094) |
8094 | book-gui |
Order book viewer |
DASH_GUI_PORT (8095) |
8095 | dash-gui |
Operator dashboard |
Only the host side of a GUI port is configurable. If 8090 is already taken on
your machine, set TERMINAL_GUI_PORT=9090 in .env and restart; the container
still listens on 8090.
make ports (or podman port edumatcher) lists what is actually published
on a running container.
BIND_ADDR is the only setting that decides who can connect
With the default 127.0.0.1, every published port is reachable from your
machine only. BIND_ADDR=0.0.0.0 opens all of them to your network,
which is right for a classroom and wrong anywhere you do not trust: the
order-entry gateways have no password.
Every variable, in one place¶
deployment/docker/.env¶
| Variable | Default | Used by | Goes into a container? |
|---|---|---|---|
COMPOSE_PROJECT_NAME |
edumatcher |
compose | no: names the project, network, pod and volumes |
EDUMATCHER_VERSION |
(empty) | backend build | build argument: PyPI release when no wheel is present |
WITH_SSH |
1 |
backend build | build argument: include openssh-server |
PIP_INDEX_URL |
(not in .env) |
backend build | build argument: PyPI mirror |
EM_CONFIG |
s10-basic |
entrypoint.sh |
yes |
EM_CONFIG_FILE |
(empty) | entrypoint.sh |
yes; set by make up-all CONFIG=<file> |
EM_PROFILE |
default |
entrypoint.sh |
yes |
TZ |
Europe/Stockholm |
all processes | yes |
BIND_ADDR |
127.0.0.1 |
compose ports: |
no: the host interface ports open on |
EDUMATCHER_GATEWAY_BIND_HOST |
0.0.0.0 |
gateways, pm-log-srv, pm-api-gwy, pm-monitor |
yes |
SSH_PORT |
2222 |
compose.ssh.yaml, make ssh |
no |
TERMINAL_GUI_PORT … DASH_GUI_PORT |
8090 … 8095 |
GUI ports: |
no |
LOG_GUI_URL / BOOK_GUI_URL |
http://localhost:<port> |
dash-gui |
yes: the drawers' links to log-gui and book-gui |
IMAGE_NAME / IMAGE_TAG |
edumatcher / local |
compose image: |
no |
CONTAINER_NAME |
edumatcher |
compose, Makefile |
no |
Read from your shell when set, with no .env entry: CORS_ORIGIN,
MAX_WS_CLIENTS, CALF_CLIENT_ID, INDEX_IDS and LOG_SRV_ENABLED for the
GUIs.
API_GATEWAY_URL, PM_TERMINAL_API_KEY, PM_BOOK_API_KEY and PM_DASH_API_KEY are also
variables of compose.guis.yaml, but leave them unset: make up-all
resolves them from the deployed configuration at every start (see
the two-phase start).
~/.edumatcher/.env (curl install)¶
The same idea, with three differences. EM_VERSION and GHCR_OWNER choose
which released images to pull. EM_ZMQ=1 adds the ZeroMQ overlay (there is no
make to pass ZMQ=1). There are no build variables. The defaults also differ
in one place: EM_PROFILE=mm-demo (the full stack plus a slow market-maker
bot, so a fresh install has prices to look at). The full table is
in
Configuring it: the .env file.
~/.edumatcher-sim/.env (curl --sim install)¶
EM_STACK=sim (which tells edumatcher.sh to drive compose.ai-sim.yaml),
EM_VERSION, GHCR_OWNER, EM_SPEED, EM_START, EM_CONFIG_FILE, TZ,
BIND_ADDR, EDUMATCHER_GATEWAY_BIND_HOST, PM_READONLY_API_KEY and the four
GUI ports.
Variables only entrypoint.sh reads¶
| Variable | Set by | Effect |
|---|---|---|
EM_SSH |
compose.ssh.yaml |
1 starts sshd |
EM_SPEED |
AI-sim image and compose file | Not 1: export EDUMATCHER_SCHEDULER_SPEED so the calendar runs faster |
EM_START |
AI-sim compose file | Export EDUMATCHER_SCHEDULER_START: where an accelerated calendar begins |
EM_RESTART_DEAD |
AI-sim image | 1: every minute, restart any process of the profile that has died |
EDUMATCHER_DATA_DIR |
the image (/data) |
Where everything is read and written; do not change it in a container |
Using the helper scripts¶
The Makefile in deployment/docker¶
The Makefile first detects your tools. It uses Podman if it is installed,
otherwise Docker (CONTAINER_ENGINE=docker forces Docker). It then uses
podman-compose, podman compose, docker compose or docker-compose,
whichever exists. Every target then runs ordinary compose commands with the
right files.
| Group | Target | What it does |
|---|---|---|
| Setup | make init |
Create .env from .env.example; also happens automatically on first build/up |
make info |
Show the detected engine, compose tool and image name | |
| Build | make build |
Build a wheel from your checkout with Poetry, place it in .wheel/, build the exchange image |
make build PYPI=1 / VERSION=x.y.z |
Build the exchange image from a PyPI release instead | |
make build-guis [GUI=<name>] |
Rebuild all six GUI images, or one. log-gui gets a wheel from your checkout (or PyPI with PYPI=1 / VERSION=) |
|
make build-sim |
Build edumatcher-ai-sim:local from your checkout |
|
| Run | make up [ZMQ=1] [SSH=1] [CONFIG=…] [PROFILE=…] |
Start the exchange alone |
make up-all [BUILD=1] [CONFIG_GUI=1] … |
Start the exchange and the GUIs in two phases | |
make down / make down-all |
Stop and remove the containers (data is kept) | |
make restart |
Restart the exchange container | |
make up-sim [LOCAL=1] [SPEED=N] [START=…] / make down-sim |
Start or stop the AI-trader simulation | |
| Inspect | make status / make health |
pm-opctl-cli list / pm-opctl-cli health inside the container |
make logs / make proc-logs P=engine |
Follow the container log / one process log in data/emo/ |
|
make ports / make mounts |
Published ports / which directory backs each container path | |
make sim-status |
The simulation's process table, data size and free disk | |
| Access | make shell / make sim-shell |
A root shell in the exchange / simulation container |
make ssh |
Log in over SSH (after make up SSH=1) |
|
make keys |
Collect ~/.ssh/*.pub into .ssh/authorized_keys |
|
| Configure | make config-deploy |
Recompile data/ref_data/engine_config.yaml; then make restart |
make config-show |
Show the deployed configuration | |
| Develop | make dev-env GUI=<name> |
Print the environment for running one GUI from source against this container |
| Publish | make ghcr-push [TAG=…] |
Build every image from your checkout and push it to GHCR (single architecture; prefer the workflow) |
| Clean up | make clean / make clean-data / make clean-all |
Remove container and image / delete ./data (asks first) / both |
make up-all step by step¶
up-all cannot start everything in one go. terminal-gui and book-gui need
the read-only API key, and that key is only known once the exchange has
deployed its configuration: every bundled example has a different one. So it
runs in two phases:
sequenceDiagram
autonumber
actor You
participant M as make up-all
participant C as compose
participant E as edumatcher container
participant G as GUI containers
You->>M: make up-all CONFIG=…
M->>M: check-foreign: is "edumatcher" someone else's?
opt CONFIG is a file
M->>M: copy it to ./config/engine_config.yaml,<br/>set EM_CONFIG_FILE=/config/engine_config.yaml
end
M->>C: up -d edumatcher (phase 1)
C->>E: start, entrypoint deploys the configuration
loop up to 60 s
M->>E: exists /data/ref_data/engine_config.json?
end
M->>E: python3: find the read-only credential<br/>(gateway_id: null) in the deployed config
E-->>M: port and API key
M->>M: export API_GATEWAY_URL=http://edumatcher:<port>,<br/>PM_TERMINAL_API_KEY, PM_BOOK_API_KEY, PM_DASH_API_KEY
M->>C: up -d (phase 2: everything)
C->>G: start GUIs with the key in their environment
M-->>You: the URLs
./edumatcher.sh start does exactly the same for a curl install. The
AI-sim stack skips it, because its configuration and key are fixed.
install.sh (curl)¶
flowchart TD
A["curl … install.sh | bash -s -- [options]"] --> B{"podman or\ndocker found?"}
B -->|no| X["stop: install one first"]
B -->|yes| C["resolve the release:\n--version, or the latest\nfrom the GitHub API"]
C --> D["download compose files, edumatcher.sh\nand .env template from that\nrelease's tag into --dir"]
D --> E["create data/ and config/;\nwrite .env only if absent,\nthen set EM_VERSION, GHCR_OWNER"]
E --> F{"--config?"}
F -->|"a file"| G["copy to config/engine_config.yaml,\nset EM_CONFIG_FILE"]
F -->|"a name"| H["set EM_CONFIG"]
F -->|no| I
G --> I{"--no-start?"}
H --> I
I -->|yes| Y["./edumatcher.sh pull\n(downloads the images);\nstart later with\n./edumatcher.sh start"]
I -->|no| Z["./edumatcher.sh start\n(pulls the images)"]
Re-running the installer into an existing directory is safe: it keeps your
.env and data/, and refreshes the compose files and script from the
release. That is how you pick up a GUI added by a newer release, which
./edumatcher.sh update alone would not (it pulls new images for the files
you already have). --sim installs the simulation into ~/.edumatcher-sim
instead.
edumatcher.sh (curl)¶
The control script reads .env to decide what it drives. EM_STACK=sim
selects compose.ai-sim.yaml; otherwise it uses compose.yaml, plus
compose.zmq.yaml when EM_ZMQ=1.
| Command | Equivalent | Notes |
|---|---|---|
./edumatcher.sh start |
make up-all |
Two-phase for the exchange, one phase for the simulation; refuses if another install owns the containers |
./edumatcher.sh stop |
make down-all |
Data kept |
./edumatcher.sh restart |
stop + start | How .env edits take effect |
./edumatcher.sh status |
compose ps + make status |
|
./edumatcher.sh logs [service] |
compose logs -f |
|
./edumatcher.sh shell [cmd…] |
make shell |
With a command, runs just that command |
./edumatcher.sh config <name or file> |
CONFIG= |
Edits .env (and copies a file, plus a neighbouring market_sim.yaml); apply with restart |
./edumatcher.sh speed N [start] |
make up-sim SPEED=N START=… |
Simulation only |
./edumatcher.sh upgrade [-y] (or --upgrade) |
— | Find the newest release, pull it, then — if the system was running, and after a warning and a question (-y skips it) — stop, replace the images, remove the old ones and restart. A stopped system is only upgraded, not started. A failed pull changes nothing |
./edumatcher.sh pull |
— | Download the images, start nothing |
./edumatcher.sh update [version] |
— | Set EM_VERSION, pull, restart, without asking |
./edumatcher.sh urls / mounts |
— / make mounts |
|
./edumatcher.sh uninstall [--data] |
compose down --volumes |
--data also deletes data/ and config/ |
Doing it by hand¶
Everything the scripts do can be done with plain commands, which is useful
when a script is not available, or to understand what it does. The examples
use podman and podman-compose; replace them with docker and
docker compose if you use Docker. Run them from deployment/docker.
Rebuilding the exchange image¶
cd deployment/docker
# 1. Build a wheel from the checkout and put it in the drop box
(cd ../.. && poetry build --format wheel)
rm -f .wheel/*.whl
cp "$(ls -t ../../dist/*.whl | head -1)" .wheel/
# 2. Build the runtime stage, tagged the way compose.yaml expects
podman build --target runtime -t edumatcher:local .
# Variants:
podman build --target runtime -t edumatcher:local \
--build-arg WITH_SSH=0 . # no sshd
rm -f .wheel/*.whl && podman build --target runtime -t edumatcher:local \
--build-arg EDUMATCHER_VERSION=0.50.5 . # a PyPI release
podman build --target runtime -t edumatcher:local \
--build-arg PIP_INDEX_URL=https://pypi.example.corp/simple . # a mirror
# 3. The self-trading variant: same steps, other target
podman build --target ai-sim -t edumatcher-ai-sim:local .
Always give --target
Without it, the build produces the last stage, ai-sim, tagged with
whatever name you gave it. An edumatcher:local that quietly starts the
300-symbol simulation is a confusing thing to debug.
Add --no-cache to rebuild every layer, for example after a base image
security update. Otherwise unchanged layers are reused and a rebuild after a
code change only redoes the pip install and what follows.
Rebuilding a GUI image¶
Each GUI builds from its own directory:
cd web-apps/terminal-gui
podman build -t edumatcher-terminal-gui:latest .
# behind a corporate proxy
podman build -t edumatcher-terminal-gui:latest \
--build-arg HTTP_PROXY=http://proxy.example:8080 \
--build-arg HTTPS_PROXY=http://proxy.example:8080 \
--build-arg NPM_STRICT_SSL=false .
The names compose expects are edumatcher-terminal-gui,
edumatcher-log-gui, edumatcher-config-gui, edumatcher-trader-gui,
edumatcher-book-gui and edumatcher-dash-gui, each with the tag latest. From
deployment/docker, podman-compose -f compose.yaml -f compose.guis.yaml -f
compose.config-gui.yaml build terminal-gui does the same. This is what
make build-guis GUI=terminal-gui runs.
The log console needs one extra step by hand, because it bundles the Python package. Put a wheel in its drop box first, exactly as for the exchange image:
(cd ../.. && poetry build --format wheel) # from deployment/docker
rm -f ../../web-apps/log-gui/.wheel/*.whl
cp "$(ls -t ../../dist/*.whl | head -1)" ../../web-apps/log-gui/.wheel/
cd ../../web-apps/log-gui && podman build -t edumatcher-log-gui:latest .
With an empty drop box it installs a PyPI release instead: the newest, or the
one named with --build-arg EDUMATCHER_VERSION=x.y.z.
A rebuilt image is not used until its container is recreated. Running up -d
again does that for every container whose image changed.
Starting with compose directly¶
cd deployment/docker
cp -n .env.example .env
mkdir -p data config
# The exchange alone (make up), optionally with overlays (ZMQ=1 SSH=1)
podman-compose -f compose.yaml up -d
podman-compose -f compose.yaml -f compose.zmq.yaml up -d
# One start with another example (make up CONFIG=s10-nominal)
EM_CONFIG=s10-nominal podman-compose -f compose.yaml up -d
The whole system needs the two phases by hand:
# Phase 1: the exchange
podman-compose -f compose.yaml -f compose.guis.yaml up -d edumatcher
# Wait for it to deploy its configuration, then read the read-only key
until podman exec edumatcher test -f /data/ref_data/engine_config.json; do sleep 1; done
podman exec edumatcher python3 -c '
import json
cfg = json.load(open("/data/ref_data/engine_config.json"))
for gw in cfg.get("api_gateways", {}).values():
for c in gw.get("credentials") or []:
if c.get("gateway_id") is None:
print(gw.get("port", 8080), c["api_key"]); raise SystemExit
'
# prints e.g.: 8081 key-readonly-xxxxxx
# Phase 2: everything, with the key
export API_GATEWAY_URL=http://edumatcher:8081
export PM_TERMINAL_API_KEY=key-readonly-xxxxxx
export PM_BOOK_API_KEY=$PM_TERMINAL_API_KEY
export PM_DASH_API_KEY=$PM_TERMINAL_API_KEY
podman-compose -f compose.yaml -f compose.guis.yaml up -d
And to stop, with the same files you started with:
The released stack by hand¶
A curl install is just its directory. The same commands work there with no build step, since every image comes from GHCR:
cd ~/.edumatcher
podman-compose -f compose.yaml pull # fetch the images for EM_VERSION
podman-compose -f compose.yaml up -d edumatcher
# … read the key as above, export the three variables …
podman-compose -f compose.yaml up -d
Make target to raw command¶
make |
Raw command (from deployment/docker) |
|---|---|
make build |
poetry build → copy wheel to .wheel/ → podman-compose -f compose.yaml build |
make up ZMQ=1 |
podman-compose -f compose.yaml -f compose.zmq.yaml up -d |
make down |
podman-compose -f compose.yaml down |
make shell |
podman exec -it edumatcher bash |
make status |
podman exec -it edumatcher pm-opctl-cli list --no-restart |
make health |
podman exec edumatcher pm-opctl-cli health |
make logs |
podman logs -f edumatcher |
make ports |
podman port edumatcher |
make config-deploy |
podman exec edumatcher pm-config-deploy /data/ref_data/engine_config.yaml |
make up-sim |
podman-compose -p edumatcher-sim -f compose.ai-sim.yaml up -d |
Common situations¶
| You want to | Do this |
|---|---|
| See your code change running | make build && make up-all. Check the build log says Installing local wheel |
| See a GUI change running | make up-all BUILD=1 GUI=<name> |
| Change which example runs | make up-all CONFIG=s10-nominal, or ./edumatcher.sh config s10-nominal && ./edumatcher.sh restart |
| Run your own configuration | make up-all CONFIG=./mine.yaml, or ./edumatcher.sh config ./mine.yaml && ./edumatcher.sh restart |
| Edit the deployed configuration in place | Edit data/ref_data/engine_config.yaml, then make config-deploy && make restart |
| Let students on your network connect | BIND_ADDR=0.0.0.0 in .env, then restart. Mind the warning above |
| Attach a ZeroMQ tool from your machine | make up ZMQ=1 / EM_ZMQ=1 in .env |
| Start over with an empty exchange | make down-all && make clean-data && make up-all |
| Find out whose data a container is using | make mounts / ./edumatcher.sh mounts |
| Keep the stack running after a reboot (Podman) | Containers with restart: unless-stopped are restarted at boot only if podman-restart.service is enabled: systemctl --user enable podman-restart.service |
For problems, the troubleshooting sections of deployment/docker/README.md
and of the Architecture and Developer Guide's
Container and Network Setup
go deeper, including how to test a connection from inside a GUI container.