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, four 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 and LOG_ENCRYPTION_KEY are each 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, a commented key template (OPENROUTER_API_KEY first, DASHSCOPE_API_KEY as the fallback), commented PRODUCTION: true / BOOTSTRAP_SCHEMA: false lines for later, and a commented MCP_PUBLIC_URL. It adds no accounts: sign-in uses the template's caregiver@mirobody.ai / 111111. 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. The chosen prefix is also exported as DOCKER_MIRROR, which compose.yaml prepends to the pg and redis images.
  4. Restarts the stack and tails the log docker compose down, then a check that no foreign container holds 18060 / 18062 / 18069 and that no other Docker network holds 10.108.0.0/24 — a conflict names the holder and aborts rather than stopping someone else's containers — 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 compose 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. The same prefix is exported as DOCKER_MIRROR, which compose.yaml interpolates onto the pg and redis images, so the pulled images follow the fallback too.

One gap remains on a restricted network: PyPI. The in-container dependency install reads PIP_INDEX_URL, which compose.yaml passes through and defaults to https://pypi.org/simple — behind a slow or blocked PyPI, run the script as:

Terminal window
PIP_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple ./deploy.sh
ServiceImagePublishedAddress on mirobody_network
pgpgvector/pgvector:0.8.6-pg17-trixie127.0.0.1:18062:543210.108.0.2
redisredis:8.2-alpine127.0.0.1:18069:637910.108.0.9
mirobodymirobody, built locally18060:18060dynamic
mirobody_workermirobody, the same imagenone — ports: []dynamic

The database and cache ports bind to loopback only: the app reaches them over the internal network, so the host mappings exist purely for operator debugging — and the repository ships literal placeholder passwords, so they must not listen publicly. The network is a bridge named mirobody_network with an explicit IPAM block: subnet 10.108.0.0/24, gateway 10.108.0.1. The pg and redis 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; nothing resolves the application containers by address, so theirs are not.

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. The image is pinned to 0.8.6-pg17-trixie rather than the floating pg17-trixie tag, so two deploys of the same commit run the same pgvector build, and shm_size: 256mb covers the HNSW index builds that outgrow Docker’s 64 MB default. A pg_isready healthcheck (with a 30-second start_period for a slow first initdb) gates the application containers, which wait on service_healthy rather than mere start order.

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
--requirepass REPLACE_THIS_VALUE_IN_PRODUCTION
--maxmemory 512mb --maxmemory-policy noeviction
--appendonly yes --appendfilename "appendonly.aof" --appendfsync everysec
--loglevel notice --timeout 60 --tcp-keepalive 30
--io-threads 4 --tcp-backlog 511

The HTTP process. It depends on pg (healthy) and redis, publishes 18060:18060, and takes six environment variables:

VariableValueNote
ENV${ENV}Substituted by compose from .env. Chooses which config.{env}.yaml is loaded, and lets ENV=prod ./deploy.sh override a stale .env.
PYTHONUNBUFFERED1Log lines appear in docker compose logs immediately.
PIP_INDEX_URL${PIP_INDEX_URL:-https://pypi.org/simple}The index the start-time dependency install reads — the PyPI-mirror hook for restricted networks.
SEED_DEMO_DATA${SEED_DEMO_DATA:-true}Loads the synthetic demo record on first boot; set false for a deployment that will hold real data.
HTTP_HOST · HTTP_PORT0.0.0.0 · 18060Both 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.

A healthcheck curls /api/health with a 20-minute start_period: the first boot pip-installs the whole dependency set into the site-packages volume before the server binds a port, and on a slow link that takes longer than any ordinary probe window. The first passing probe ends the wait immediately, so a fast link pays nothing for the allowance.

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 18060:18060 would make two containers claim the same host port.
commandactivate the venv, python -m mirobody workerDeliberately no pip install — the anchor’s install step already ran in mirobody.
healthcheckdisable: trueThe anchor’s HTTP probe is meaningless for a process that serves nothing.
depends_onpg, redis, and mirobody healthyWaiting for mirobody to be healthy — not merely started — is what guarantees the pip install into the shared site_packages volume finished 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_redis/dataThe Redis append-only file, so queued tasks survive a redeploy.
mirobody_upload/app/.theta/mcp/uploadUploaded files when no object store is configured — this is LocalStorage’s default base path.
mirobody_site_packages/root/venv/lib/python3.12/site-packagesInstalled Python dependencies, plus the .deps_hash marker described below.

The last two 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 18060, 18062 or 18069 is already in use

deploy.sh checks docker ps for ":<port>->" and refuses to proceed when a foreign container holds one of the three, naming the holder — it deliberately does not stop other projects’ containers. A non-Docker process holding the port is not detected at all. Free the port, or change the left-hand side of the mapping in compose.yaml; if you move 18060, change HTTP_PORT in the mirobody environment to match.

A container starts and then exits

Read docker compose logs mirobody. The server container runs a shell pipeline chained with &&, so the failure is almost always inside it — pip install could not reach an index, or the import failed. The compose file gates start order on health (pg must answer pg_isready, the worker waits for mirobody to pass /api/health), so a cold-start race against an initialising PostgreSQL is no longer the usual suspect.

Dependencies will not install

The install happens at container start, inside the container, so it needs egress to a PyPI index — PIP_INDEX_URL, defaulting to pypi.org. 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.