Deployment

Docker (recommended)

docker compose up -d

The compose file publishes 443 (HTTPS) and 80 (HTTP redirect) and

mounts the `mycelium2-data` volume at `/opt/mycelium2/data`. All state

lives there: SQLite database, encrypted files, config, TLS certs

(auto-generated), assets.

First boot:

1. The server creates the data layout and runs migrations. No admin

account and no bootstrap files are created.

2. Two packaged skills — `pdf-to-markdown` (PDF book → enhanced

markdown ready for library ingest) and `ebook-to-markdown`

(epub/mobi/azw3/fb2 → the same kind of enhanced markdown book,

via the calibre engine) — are seeded into the global skills shelf

automatically. Every user can read them from `/skills`; admins can

edit. Upgrades refresh them when the packaged version changes; admin

edits are preserved between upgrades. A shelf seeded by an earlier

release sits at seed v1 (flat `pdf-to-markdown`) or v2 (nested

`pdf-to-markdown` only), depending on when it last upgraded; one

boot of this release brings either state to seed v3 — from v1, the

legacy flat paths are deleted and both skills' nested layouts are

written; from v2, the pdf layout is already correct and the ebook

skill is added nested — this boot is a version-bump refresh, so

admin edits to the pdf packaged paths are overwritten, as with

any bump. Admin edits to the legacy flat paths do not survive the

v1 migration — copy them out first. Admin edits to the packaged

paths survive between upgrades; the refresh that bumps the packaged

version — or a lost `.seed-version` marker, which triggers the same

refresh — overwrites them.

3. Open `https://<host>/setup` and create the admin account (username

and password of your choice; recovery key shown once — record it).

4. Log in at `https://<host>/`. `/setup` becomes unavailable once the

first account exists.

TLS

By default the server generates a self-signed certificate on first

boot and stores it in the data directory. To use real certificates,

mount them and set the env vars:

    environment:
      MYCELIUM2_TLS_CERT: /opt/mycelium2/tls/fullchain.pem
      MYCELIUM2_TLS_KEY: /opt/mycelium2/tls/privkey.pem
    volumes:
      - ./certs/fullchain.pem:/opt/mycelium2/tls/fullchain.pem:ro
      - ./certs/privkey.pem:/opt/mycelium2/tls/privkey.pem:ro

Behind a reverse proxy

The binary terminates TLS itself. If you prefer an upstream proxy

(Let's Encrypt automation, etc.), point the proxy at the HTTPS port

with `proxy_pass https://127.0.0.1:8443` and set

`MYCELIUM2_HTTPS_ADDR=127.0.0.1:8443`. Keep the proxy's

`X-Forwarded-*` handling consistent; sessions are cookie-based and

host-agnostic.

Binary

mycelium2 \
  --data-dir /opt/mycelium2/data \
  --https-addr 0.0.0.0:443 \
  --http-addr 0.0.0.0:80

All flags have `MYCELIUM2_*` env equivalents (see

Configuration).

Systemd (binary deployment)

[Unit]
Description=Mycelium2
After=network.target

[Service]
User=mycelium
Group=mycelium
Environment=MYCELIUM2_DATA_DIR=/opt/mycelium2/data
ExecStart=/usr/local/bin/mycelium2
Restart=on-failure
# Hardening
NoNewPrivileges=true
ProtectSystem=strict
ReadWritePaths=/opt/mycelium2/data
PrivateTmp=true

[Install]
WantedBy=multi-user.target

Health and monitoring

- `GET /health` — public JSON status (unauthenticated by design, for

container orchestration). It also reports the mutation queue —

`queue_depth`, `oldest_pending_age_seconds`, `queue_dead_count`,

`llm_last_success_seconds` — and answers 503 `degraded` when the

database is unreachable, any queued item is dead, or a pending item

has waited past twice the integration deadline. Alongside those it

reports the LLM admission gate — `llm_active` (runs holding a slot),

`llm_queued` (waiters) and `llm_capacity` (configured slots). Those

three are

observability only — a saturated or queued gate is load, not ill

health, and never makes the status `degraded`.

- `GET /api/v1/health` — detailed JSON status (authenticated).

- `GET /metrics` — Prometheus counters (logins, searches, ingests,

backups, mutation-queue totals, ...) plus gauges for the mutation

queue depth, the oldest pending item's age, and

`mycelium2_llm_last_success_timestamp` (last successful LLM

completion; absent until the first success) — public by design for

scrape endpoints; put it behind a firewall or reverse-proxy auth if

your threat model requires.

- LLM-gate metric lines (all seven from one consistent read per

scrape):

- `mycelium2_llm_gate_active` (gauge) — runs holding a slot

- `mycelium2_llm_gate_queued` (gauge) — waiters in the FIFO queue

- `mycelium2_llm_gate_capacity` (gauge) — configured slot count

- `mycelium2_llm_gate_admissions_total` (counter) — slots granted

since start, both admission classes

- `mycelium2_llm_gate_timeouts_total` (counter) — wait-class

waiters that gave up, including the ones released at shutdown

- `mycelium2_llm_gate_wait_seconds_total` (counter) — cumulative

queue wait of admitted wait-class runs

- `mycelium2_llm_gate_wait_count` (counter) — samples behind that

total; average wait = `wait_seconds_total / wait_count`

- Container healthcheck: the HTTP redirect listener answers on port

80 (`curl http://127.0.0.1:80/`).

Sizing `max_parallel`

`max_parallel` (Admin → LLM, default 2) is the number of agent runs

allowed to talk to the backend at once — size it to what the backend

actually serves in parallel, not to the number of users. A local

Ollama or a single-GPU gateway usually runs one or two generations

before latency collapses; a hosted multi-replica endpoint can take

more. Raise it only while watching `mycelium2_llm_gate_active`

against the backend's own throughput: beyond the backend's real

parallelism, queueing at the gate is cheaper than queueing inside the

model server, because waiters keep their FIFO place, report a

position, and give up at a bound instead of timing out mid-request.

If `mycelium2_llm_gate_queued` is persistently high while the backend

is not saturated, the gate — not the model — is the limit; if

`mycelium2_llm_gate_timeouts_total` is climbing, waiters are giving up

and either the backend or the bounds need attention. Changes apply

immediately, so tuning needs no restart.

Upgrading

The image is data-free: pull the new image and restart. Migrations run

automatically on startup. Take a backup first (see

Backup and restore).

Packaged skills (seed v3)

The packaged `pdf-to-markdown` and `ebook-to-markdown` skills seed

as nested bundles (`SKILLS_SEED_VERSION` 3):

1. Fresh boots write the nested layouts directly: each skill's

`/<slug>/skill.md` hub (carrying the bundle manifest), its

companion concepts under `/<slug>/`, and the scripts as raw

payload files — scripts are never concepts (no registry row, no

search entry).

2. A shelf seeded by an older release migrates on the first boot of

the new binary, from either earlier seed: from seed v1 (the flat

pdf layout), the legacy flat `/pdf-to-markdown*.md` concepts are

deleted (idempotently) and both nested bundles are written; from

seed v2 (nested pdf only), the pdf layout is already correct and

the ebook bundle is added nested — this boot is a version-bump

refresh, so admin edits to the pdf packaged paths are overwritten,

as with any bump. The flat-deletion concern is pdf-only — the

ebook skill never shipped flat, so it installs nested either way.

Admin edits to the legacy flat packaged paths do not survive the

v1 migration — copy them out first if you modified them.

3. The `.seed-version` marker gates refresh as before: a matching

marker is a no-op (admin edits to the packaged paths survive

between bumps), and extra admin-created skills are never touched.

Seeding validates the embedded concepts and manifest paths before

any store write and fails fast on error — the server refuses to

boot half-seeded. Payload md5s are a different gate: verified

against the hub manifest at bundle download/export time, and

pinned by the packaged-skills asset test.

Hydrate bundle

The interactive frontend ships as a wasm hydrate bundle built from

`crates/mycelium-ui`: two checked-in artifacts under

`crates/mycelium-web/assets/` — `mycelium_ui.js` (the wasm-bindgen JS

wrapper) and `mycelium_ui_bg.wasm` (the compiled module). The JS

wrapper fetches its sibling `.wasm` by filename, so the pair's names

are load-bearing — do not rename. Delivery works like every other

default asset: both files are embedded in the binary and the asset

scaffold writes them to the data-dir assets directory on first boot or

when the assets version marker is stale (an `ASSETS_VERSION` bump

delivers a regenerated bundle to existing deployments; delete the

`.defaults-version` marker to opt out of refreshes). There is no

separate Dockerfile stage — the image ships the bundle through the

same scaffold. `serve_asset` serves the `.wasm` as

`application/wasm`, and the site CSP carries `'wasm-unsafe-eval'`

(WASM modules compile at runtime). The chat and API-keys pages load the

bundle (a `<script type="module">` tag) plus the `islands.js`

traversal script, which initializes the wasm and walks the page's

`<leptos-island>` roots; every other page fetches neither.

Regenerate the bundle whenever `crates/mycelium-ui`'s island code

changes (requires the `wasm32-unknown-unknown` rustup target):

cargo build -p mycelium-ui --no-default-features --features hydrate \
  --release --target wasm32-unknown-unknown
cargo install wasm-bindgen-cli --version 0.2.128 --locked   # one-time; pin matters
wasm-bindgen --target web --out-dir /tmp/hydrate-pkg \
  target/wasm32-unknown-unknown/release/mycelium_ui.wasm
cp /tmp/hydrate-pkg/mycelium_ui.js crates/mycelium-web/assets/mycelium_ui.js
cp /tmp/hydrate-pkg/mycelium_ui_bg.wasm crates/mycelium-web/assets/mycelium_ui_bg.wasm

Commit the two updated artifacts and bump `ASSETS_VERSION` in both

`crates/mycelium-web/src/assets.rs` and `crates/mycelium-ui/src/shell.rs`

(they must stay equal — a lockstep test enforces it) so running

deployments refresh.