Skip to content
Get Started

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.

Four phases, all idempotent — an existing file or an unchanged image is left alone.

  1. Writes .env, if it does not exist ENV comes from the environment or defaults to localdb; CONFIG_ENCRYPTION_KEY is 32 characters drawn from [A-Za-z0-9] using the shell's $RANDOM. Both docker compose (for ${ENV} substitution) and the engine itself read this file.
  2. Writes config.{env}.yaml, if it does not exist A random 32-character JWT_KEY, the three demo1/2/3@mirobody.ai accounts with the code 777777, and commented placeholders for the LLM keys and MCP_PUBLIC_URL. This is your override layer — the script never touches config.yaml.
  3. Builds the image, if the Dockerfile text changed Probes whether hub.docker.com answers, picks a registry prefix accordingly, then builds an inline Dockerfile labelled with its own checksum.
  4. Restarts the stack and tails the log docker compose down, then any container publishing 18080 / 18082 / 18089 is stopped, then docker compose up -d --remove-orphans, then docker 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-C stops the log tail; the containers keep running.
  • The docker compose down is run without -v, so the named volumes — including the database — survive. Re-running ./deploy.sh is safe.

The Dockerfile is a string in the script. Reproduced here as it is built:

inline Dockerfile, from deploy.sh
FROM ubuntu:24.04
RUN 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 /app
WORKDIR /app

Note 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.

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.

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.

Terminal window
# which checksum the current image was built from
docker image inspect --format '{{ index .Config.Labels "dockerfile.md5" }}' mirobody
# force a rebuild
docker image rm mirobody && ./deploy.sh

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-trixie and redis:7.0-alpine are pulled by docker 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.com is 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.yaml sets an index URL, so a pip mirror is something you add yourself — for example as another entry in the mirobody service’s environment.
ServiceImagePublishedAddress on mirobody_network
pgpgvector/pgvector:pg17-trixie18082:543210.108.0.2
redisredis:7.0-alpine18089:637910.108.0.9
mirobodymirobody, built locally18080:1808010.108.0.8
mirobody_workermirobody, the same imagenone — 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.

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:

compose.yaml
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 511

The HTTP process. It depends on pg and redis, publishes 18080:18080, and takes six environment variables:

VariableValueNote
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.
PYTHONUNBUFFERED1Log lines appear in docker compose logs immediately.
PYTHONPATH/appSo mirobody serve resolves against the bind mount.
HTTP_HOST · HTTP_PORT0.0.0.0 · 18080Both 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.

The worker is declared as <<: *mirobody_base, a YAML merge of the mirobody service, and then overrides four keys:

KeyOverrideWhy
ports[]Reset to empty. Inheriting 18080:18080 would make two containers claim the same host port.
commandactivate the venv, mirobody workerDeliberately no pip install — the anchor’s install step already ran in mirobody.
networksipv4_address: 10.108.0.11A pinned address cannot be inherited; it would collide.
depends_onpg, redis, and mirobodyThe shared site_packages volume has to be populated before the worker imports the package.

Everything else — image, volumes, environment — is inherited unchanged.

Four volumes, and each one is doing a distinct job:

VolumeMount pointHolds
mirobody_postgres/var/lib/postgresql/dataThe database cluster.
mirobody_upload/app/.theta/mcp/uploadUploaded files when no object store is configured — this is LocalStorage’s default base path.
mirobody_charts/app/.theta/mcp/chartsRendered chart PNGs, served back out at /charts.
mirobody_site_packages/root/venv/lib/python3.12/site-packagesInstalled 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 .py file 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.
Terminal window
docker compose ps # what is up
docker compose logs -f mirobody mirobody_worker # both application logs
docker compose restart mirobody mirobody_worker # pick up a config change
docker compose exec mirobody bash # a shell in the server container
docker compose exec pg psql -U holistic_user -d holistic_db
docker compose down # stop, keep the volumes

Configuration 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.

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:

Terminal window
docker compose exec mirobody bash
source /root/venv/bin/activate
pip install -r requirements.txt
The 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:

Terminal window
docker image rm mirobody && ./deploy.sh
A 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.