Deploy
Docker Deployment
How deploy.sh builds the image and what compose.yaml actually declares: four services on a fixed subnet, five named volumes, and dependencies installed at container start.
The reference deployment is two files at the repository root: deploy.sh, which prepares the configuration and builds an image, and compose.yaml, which declares the stack. Installation covers the short version — clone, run ./deploy.sh, four containers come up. This page is the same path read as a deployment: what the image contains, why it almost never needs rebuilding, and which parts of compose.yaml you have to change when you move off a laptop.
The four phases of deploy.sh
Section titled “The four phases of deploy.sh”Four phases, all idempotent — an existing file or an unchanged image is left alone.
- Writes
.env, if it does not existENVcomes from the environment or defaults tolocaldb;CONFIG_ENCRYPTION_KEYis 32 characters drawn from[A-Za-z0-9]using the shell's$RANDOM. Bothdocker compose(for${ENV}substitution) and the engine itself read this file. - Writes
config.{env}.yaml, if it does not exist A random 32-characterJWT_KEY, the threedemo1/2/3@mirobody.aiaccounts with the code777777, and commented placeholders for the LLM keys andMCP_PUBLIC_URL. This is your override layer — the script never touchesconfig.yaml. - Builds the image, if the Dockerfile text changed Probes whether
hub.docker.comanswers, picks a registry prefix accordingly, then builds an inline Dockerfile labelled with its own checksum. - Restarts the stack and tails the log
docker compose down, then any container publishing 18080 / 18082 / 18089 is stopped, thendocker compose up -d --remove-orphans, thendocker compose logs -f.
The last phase has two consequences to consider before running it on a server:
- The script ends in
docker compose logs -f, so it stays in the foreground.Ctrl-Cstops the log tail; the containers keep running. - The
docker compose downis run without-v, so the named volumes — including the database — survive. Re-running./deploy.shis safe.
The image
Section titled “The image”The Dockerfile is a string in the script. Reproduced here as it is built:
FROM ubuntu:24.04RUN apt update && \ apt install -y --no-install-recommends \ ca-certificates curl \ g++ gfortran build-essential \ libfftw3-dev libhdf5-dev libblas-dev liblapack-dev \ python3 python3-venv python3-dev \ fonts-wqy-microhei fonts-wqy-zenhei fontconfig && \ rm -rf /var/lib/apt/lists/* && \ fc-cache -fv && \ mkdir /root/venv && \ python3 -m venv /root/venv && \ mkdir -p /appWORKDIR /appNote what is not there: no COPY, no pip install, no application code. The image is an environment, not a build of the engine. The repository arrives at /app as a bind mount and the dependencies install when the container starts.
What the image contains
Section titled “What the image contains”The image is “Ubuntu 24.04 + a compiler toolchain + a virtualenv at /root/venv”. Python dependencies are not installed at image-build time but into a volume at container start, so the image has to carry a C / C++ / Fortran toolchain and numeric development headers — anything without a prebuilt wheel for the platform compiles on the spot. It also carries TLS roots, curl (the healthcheck shells out to it) and CJK glyphs, without which rendered labels are empty boxes.
Image rebuild conditions
Section titled “Image rebuild conditions”The script writes the md5 of the Dockerfile text onto the image as a label and compares it next run: unchanged means it prints Using existing docker image. and skips the build.
# which checksum the current image was built fromdocker image inspect --format '{{ index .Config.Labels "dockerfile.md5" }}' mirobody
# force a rebuilddocker image rm mirobody && ./deploy.shMirrors for restricted networks
Section titled “Mirrors for restricted networks”Before building, the script calls a reachability probe against hub.docker.com: curl with a 3-second connect timeout if available, otherwise wget --spider, otherwise ping. HTTP 200, 301 or 302 counts as reachable.
If it is not reachable, the script walks its mirror list — one entry, docker.1ms.run — and the first one that answers becomes a registry prefix, so FROM ubuntu:24.04 is built as FROM docker.1ms.run/ubuntu:24.04.
Three limits of that fallback, all of which matter on a restricted network:
- It only rewrites the image the script builds.
pgvector/pgvector:pg17-trixieandredis:7.0-alpineare pulled bydocker compose, which never sees the prefix. Configure a registry mirror in the Docker daemon for those. - The npm registry is set unconditionally.
npm config set registry https://registry.npmmirror.comis baked into the image whether or not the probe failed. Override it in the container if you want the default registry. - PyPI is not mirrored. Neither the script nor
compose.yamlsets an index URL, so apipmirror is something you add yourself — for example as another entry in themirobodyservice’senvironment.
The four services
Section titled “The four services”| Service | Image | Published | Address on mirobody_network |
|---|---|---|---|
pg | pgvector/pgvector:pg17-trixie | 18082:5432 | 10.108.0.2 |
redis | redis:7.0-alpine | 18089:6379 | 10.108.0.9 |
mirobody | mirobody, built locally | 18080:18080 | 10.108.0.8 |
mirobody_worker | mirobody, the same image | none — ports: [] | 10.108.0.11 |
The network is a bridge named mirobody_network with an explicit IPAM block: subnet 10.108.0.0/24, gateway 10.108.0.1. Addresses are pinned rather than discovered, which is the whole reason config.yaml can ship PG_HOST: 10.108.0.2 and REDIS_HOST: 10.108.0.9 as working defaults with no service-name resolution involved.
pg — PostgreSQL with pgvector
Section titled “pg — PostgreSQL with pgvector”Three environment variables initialise the cluster on first boot — POSTGRES_USER: holistic_user, POSTGRES_DB: holistic_db, POSTGRES_PASSWORD: REPLACE_THIS_VALUE_IN_PRODUCTION — and they line up with PG_USER and PG_DBNAME in config.yaml. The data directory is the mirobody_postgres volume.
Redis is not started bare; the service overrides command with an explicit flag list:
redis-server--bind 0.0.0.0 --port 6379 --protected-mode no--requirepass REPLACE_THIS_VALUE_IN_PRODUCTION--maxmemory 512mb --maxmemory-policy allkeys-lru--appendonly yes --appendfilename "appendonly.aof" --appendfsync everysec--loglevel notice --timeout 60 --tcp-keepalive 30--io-threads 4 --io-threads-do-reads yes --tcp-backlog 511mirobody
Section titled “mirobody”The HTTP process. It depends on pg and redis, publishes 18080:18080, and takes six environment variables:
| Variable | Value | Note |
|---|---|---|
ENV | ${ENV} | Substituted by compose from .env. Chooses which config.{env}.yaml is loaded. |
CONFIG_SERVER · CONFIG_TOKEN | ${…} | Forwarded for the optional remote config server. These two are read from the environment only — putting them in YAML has no effect. |
PYTHONUNBUFFERED | 1 | Log lines appear in docker compose logs immediately. |
PYTHONPATH | /app | So mirobody serve resolves against the bind mount. |
HTTP_HOST · HTTP_PORT | 0.0.0.0 · 18080 | Both are commented out in config.yaml, and the fallback in code is 0.0.0.0 and port 80 — so without these two entries the container would listen on the wrong port. |
mirobody_worker
Section titled “mirobody_worker”The worker is declared as <<: *mirobody_base, a YAML merge of the mirobody service, and then overrides four keys:
| Key | Override | Why |
|---|---|---|
ports | [] | Reset to empty. Inheriting 18080:18080 would make two containers claim the same host port. |
command | activate the venv, mirobody worker | Deliberately no pip install — the anchor’s install step already ran in mirobody. |
networks | ipv4_address: 10.108.0.11 | A pinned address cannot be inherited; it would collide. |
depends_on | pg, redis, and mirobody | The shared site_packages volume has to be populated before the worker imports the package. |
Everything else — image, volumes, environment — is inherited unchanged.
Named volumes
Section titled “Named volumes”Four volumes, and each one is doing a distinct job:
| Volume | Mount point | Holds |
|---|---|---|
mirobody_postgres | /var/lib/postgresql/data | The database cluster. |
mirobody_upload | /app/.theta/mcp/upload | Uploaded files when no object store is configured — this is LocalStorage’s default base path. |
mirobody_charts | /app/.theta/mcp/charts | Rendered chart PNGs, served back out at /charts. |
mirobody_site_packages | /root/venv/lib/python3.12/site-packages | Installed Python dependencies, plus the .deps_hash marker described below. |
The last three exist because of the bind mount. .:/app maps your working copy into the container, so if the upload directory were an ordinary path it would materialise inside your checkout. Mounting a named volume on top of a subdirectory of the bind mount keeps that content in Docker and out of the repository, while still surviving docker compose down.
Dependency installation at container start
Section titled “Dependency installation at container start”Both application containers run the same shell pipeline as their command: activate the venv in the image → checksum pyproject.toml and requirements.txt → compare against a marker inside the site-packages volume → pip install only on a mismatch, otherwise print >> deps unchanged, skip install → then start the server or the worker. It is chained with &&, so a failed install never reaches the start.
For day-to-day development there are only two conclusions:
- Changing a
.pyfile needs a restart and nothing else. The source is bind-mounted and the install is editable, so code goes through no build step. - Only a changed dependency set reinstalls. The checksum marker lives inside the site-packages volume, so it cannot drift from the packages it describes: delete the volume and the marker goes with it.
Day-to-day operations
Section titled “Day-to-day operations”docker compose ps # what is updocker compose logs -f mirobody mirobody_worker # both application logsdocker compose restart mirobody mirobody_worker # pick up a config changedocker compose exec mirobody bash # a shell in the server containerdocker compose exec pg psql -U holistic_user -d holistic_dbdocker compose down # stop, keep the volumesConfiguration is read once at startup, so a change to config.{env}.yaml needs a restart of both application containers — the worker loads the same files and is just as stale otherwise.
Troubleshooting
Section titled “Troubleshooting”Port 18080, 18082 or 18089 is already in use
Before starting, deploy.sh greps docker ps for ":<port>->" and stops what it finds. That only reaches containers publishing exactly that host port — a non-Docker process holding the port is untouched, and so is a container that publishes the same service on a different host port. Free the port, or change the left-hand side of the mapping in compose.yaml; if you move 18080, change HTTP_PORT in the mirobody environment to match.
A container starts and then exits
Read docker compose logs mirobody. Both application containers run a shell pipeline chained with &&, so the failure is almost always inside it — npm install or pip install could not reach a registry, or the import failed. Note that depends_on waits for the dependency to start, not to be ready, and the file declares no healthchecks, so on a cold machine the engine can reach a PostgreSQL that is still initialising. Restarting the container is enough in that case.
Dependencies will not install
The install happens at container start, inside the container, so it needs egress to the npm registry — pinned to registry.npmmirror.com in the image — and to PyPI. A failure leaves no .deps_hash, so the next start retries from scratch. To work through it interactively:
docker compose exec mirobody bashsource /root/venv/bin/activatepip install -r requirements.txtThe image is stale, or a rebuild is not triggering
The build is skipped whenever the md5 of the inline Dockerfile text matches the dockerfile.md5 label on the local mirobody image, and nothing else is compared. Remove the image to force the rebuild:
docker image rm mirobody && ./deploy.shA config change had no effect
Three causes, in order of likelihood. The engine reads config once at startup — restart. Environment variables beat both YAML layers, so anything set in the compose environment block (HTTP_HOST, HTTP_PORT, ENV) cannot be overridden from a file. And on first load the loader encrypts secret-looking values in place: any key matching _KEY, _PASSWORD, _PASS, _PWD, _SECRET, _SK or _TOKEN is rewritten into your config.{env}.yaml as ciphertext. Finding gAAAA… where you typed a password is the expected behaviour, not corruption.
Next steps
Section titled “Next steps”The placeholders, the demo logins, and everything else that has to change
The three layers and what each key group controls
The other two install paths — local Python and the PyPI package
What the two processes are actually doing