Deploying¶
The prebuilt image is public; see RISK.md for the terms of use.
Run the prebuilt image¶
The image contains only the compiled programs. It ships without a
configuration, so it will not start until you give it two files: config.json
(what to trade) and .env (your exchange keys). Both templates, plus the
compose.yaml used to run it, are inside the image, so you do not need the
repository.
You need Docker installed and a terminal. Run each block below and read the note after it; if something does not look right, stop and check.
1. Make a folder to keep everything in¶
mkdir -p ~/dio
cd ~/dio
~ means your home folder. All the files for dio live in ~/dio, and this is
where you run the commands from now on. Every time you open a new terminal,
start with cd ~/dio.
2. Download the image¶
The image is public and published to two registries, so no login is needed. Pull from either:
docker pull ghcr.io/malciller/dio-domains:latest # GitHub Container Registry
docker pull malciller/dio-domains:latest # Docker Hub
You should see the download finish with a line naming the image you pulled. If
you use Docker Hub, use malciller/dio-domains:latest wherever the steps below
say ghcr.io/malciller/dio-domains:latest.
3. Copy the starter files out of the image¶
These three commands write config.json, .env, and compose.yaml into the
folder you are in:
# use whichever registry you pulled from above
IMAGE=ghcr.io/malciller/dio-domains:latest # or: malciller/dio-domains:latest
docker run --rm -v "$PWD:/out" --entrypoint cp $IMAGE \
/usr/share/doc/dio/config.example.json /out/config.json
docker run --rm -v "$PWD:/out" --entrypoint cp $IMAGE \
/usr/share/doc/dio/.env.example /out/.env
docker run --rm -v "$PWD:/out" --entrypoint cp $IMAGE \
/usr/share/doc/dio/compose.yaml /out/compose.yaml
Check they are there with ls -a; you should see config.json, .env,
compose.yaml (the leading dot on .env is normal).
4. Fill in .env with your exchange keys¶
.env is a plain text file of NAME=value lines. Rules: one per line, no
spaces around the =, no quotation marks, and you only fill in the exchange(s)
you actually use. Leave the others blank.
Open it in a simple editor:
nano .env
Arrow down to the matching lines and type your keys after the =. For example,
to trade on Hyperliquid, fill in:
HYPERLIQUID_WALLET_ADDRESS=0xYourAccountAddress
HYPERLIQUID_PRIVATE_KEY=0xYourSigningKey
Alpaca uses ALPACA_API_KEY / ALPACA_API_SECRET; Kraken uses
KRAKEN_API_KEY / KRAKEN_API_SECRET. The full list with what each one is for
is in CONFIGURATION.md.
In nano: type your value, then save with Ctrl-O, press Enter, and exit
with Ctrl-X. (Any text editor works; nano is just the easiest in a
terminal.) Your keys are private. Never paste this file into an issue or chat.
5. Edit config.json to say what to trade¶
config.json is JSON. The engine is strict: a misspelled or unknown key stops
it from starting, which is deliberate. Open it:
nano config.json
The safest first run is a testnet. Replace the whole file with this, which trades a tiny amount on Hyperliquid's testnet:
{
"trading": {
"assets": [
{
"asset": "BTC",
"strategy": {
"exchange": "hyperliquid",
"symbol": "BTC/USDC",
"name": "jacobs_ladder",
"qty": "0.0001",
"grid_interval": [0.1, 0.5],
"testnet": true
}
}
]
}
}
What the fields mean:
asset: a label for the group (BTChere). Errors and logs use it.strategy.exchange:kraken,hyperliquid,lighter,ibkr, oralpaca. It must match the keys you filled in.env.strategy.symbol: the instrument, in that exchange's format (BTC/USDCon Hyperliquid,BTC/USDon Kraken,AAPLon Alpaca/IBKR).strategy.name: the name of the bound strategy file. The engine loadsstrategies/<name>.strategyfrom the working directory (falling back to.json) and validates it at startup. The image shipsjacobs_ladder. See Strategy script language.strategy.qty: order size, in the base asset, written as a string.strategy.grid_interval: two numbers, the smallest and largest gap (in percent) between orders. The oracle picks within this range.strategy.testnet:trueroutes to the venue's sandbox/paper account. Kraken has no testnet, so a Kraken entry is always live.
The trading.defaults section is optional; the shipped config.example.json
uses it to declare the strategy name, fees, and buffers once per venue. Add an
overlay section to the group for a perpetual hedge, and inventory legs
inside it to hedge balances held on another venue. Every other option
(sell_mult, accumulation_buffer, data_feed, fees, oracle tuning) is
documented in CONFIGURATION.md. For Alpaca paper trading
instead, swap in:
{
"asset": "SPY",
"strategy": {
"exchange": "alpaca",
"symbol": "SPY",
"name": "jacobs_ladder",
"qty": "0.01",
"grid_interval": [0.1, 0.5],
"testnet": true,
"data_feed": "iex"
}
}
JSON gotchas: strings need double quotes and entries are separated by commas.
config.json is JSONC: // and /* */ comments, trailing commas, and a UTF-8
BOM are accepted. The syntax is forgiving; the schema is not.
6. Start it¶
docker compose up -d
The -d means "in the background". Watch it start up:
docker compose logs -f engine
You want to see it connect to the exchange and begin working. Press Ctrl-C to stop watching (this does not stop the engine). A denied API key or a config error shows up here. To attach the dashboard, in a second terminal:
cd ~/dio
docker compose run --rm dashboard
Detach from the dashboard with Ctrl-p then Ctrl-q (it keeps running).
7. Stop¶
docker compose down
This stops the containers. Your state (accumulated position bookkeeping) stays
in the dio-data volume; add -v to delete that too.
compose.yaml mounts config.json and .env read-only, keeps state in the
dio-data volume, shares the dio-sock volume for the dashboard's Unix domain
socket, and applies the same hardening as a manual run: read-only root
filesystem, all capabilities dropped, no-new-privileges, and a noexec tmpfs
at /tmp. The engine exposes metrics on port 8080.
If it does not start¶
- It exits immediately and the log names a config key, which means that key is misspelled,
wrongly nested, or not valid for that exchange. Fix
config.json. docker compose upfails with a name or port conflict because anotherdio_enginecontainer is running, or port 8080 is taken. Stop the other container.- The log shows an authentication error because the keys in
.envare wrong, belong to a different account, or you settestnetdifferently from the keys you pasted.
Manual docker run¶
If you are not using compose:
docker run -d --name dio_engine \
--restart unless-stopped \
--read-only --cap-drop=ALL --security-opt no-new-privileges:true \
--tmpfs /tmp:rw,noexec,nosuid,size=64m \
-v "$PWD/config.json:/app/config.json:ro" \
-v "$PWD/.env:/app/.env:ro" \
-v dio-data:/app/data \
-v dio-sock:/var/run/dio \
-p 8080:8080 \
-e DIO_CANARY=0 \
ghcr.io/malciller/dio-domains:latest # or: malciller/dio-domains:latest
# dashboard, inside the running container:
docker exec -it dio_engine dio-dashboard
Build the images yourself¶
The image is built in two stages. Dockerfile.base compiles the OxCaml toolchain
and installs the native build dependencies (OpenSSL headers and a source build of
libsecp256k1); it is slow (roughly an hour, and ~16 GB RAM to bootstrap the
compiler) and only changes when the compiler or a native dependency changes.
Dockerfile compiles the engine and the in-tree platform libraries on top of it,
which takes well under a minute.
docker build -f Dockerfile.base -t dio-oxcaml-base:5.2.0minus40 .
docker build \
--build-arg DIO_BASE_IMAGE=dio-oxcaml-base:5.2.0minus40 \
-t dio .
The build is linux/amd64 only. On Apple Silicon it runs under emulation.
No OCaml libraries come from opam. Every OCaml library the engine uses lives
under src/foundation/ and is compiled by Dockerfile. src/foundation/README.md describes the layout and ownership; versions and
licenses are in licenses/THIRD_PARTY_LICENSES. OpenSSL and libsecp256k1 are the
remaining native build dependencies; the Lighter signer is optional at runtime,
and the runtime image uses jemalloc.
Runtime¶
The compiled binary is dynamically linked. The runtime stage installs
libssl3t64, libstdc++6, libgcc-s1, libjemalloc2, ca-certificates and
netbase, and copies libsecp256k1 from the build stage. The Lighter signer is
not in the default image: build with --build-arg INCLUDE_LIGHTER_SIGNER=1 to
add it under /opt/lighter-signer/, or mount the library and point
LIGHTER_SIGNER_LIB_PATH at it.
The image sets these environment defaults:
LD_PRELOAD=libjemalloc.so.2withMALLOC_CONFtuned to limit arenas. jemalloc prevents glibc arena fragmentation under OCaml 5's per-domain allocation.OCAMLRUNPARAMGC defaults;config.json'sgcsection is applied on top at process start.LIGHTER_SIGNER_LIB_PATH=/opt/lighter-signer/lighter-signer-linux-amd64.
The container runs as the non-root dio user (UID/GID 1000).
Data¶
/app/data holds accumulation_state.json and sell_levels_state.json. Mount
it to a named volume or host directory; without a mount, state is lost when the
container is replaced.
Dashboard transport¶
dio-dashboard talks to the engine over a Unix domain socket under
/var/run/dio. Mount the same volume into both containers, or run the dashboard
with docker exec inside the engine container.
Dashboard views¶
dio-dashboard reads theme and alert_level from the mounted config.json. In
the main view, press v to switch the shared panel between ENGINE LATENCY and
ENGINE LOGS. The logs panel streams the engine log, colored by severity, and wraps
long lines under the message column.
A log line at or above alert_level (default warn) raises an alert popup over the
dashboard. Identical lines batch into one row with a count. Press Enter or Esc to
acknowledge and clear the popup.
Remote deployment¶
The pattern for a server is: copy the source (and your config.json/.env,
never into the image), build the image on the host, then run the container with
the same mounts and hardening shown above. Keep config.json and .env on a
persistent path and bind-mount them read-only; treat the .env file as
root-only (chmod 600).
Platform support¶
Only linux/amd64 images are published. The engine also builds on macOS for
local development (see the top-level README), but the prebuilt image targets
Linux.