# Partner-facing example for running `submilli-server` in a container, and the # fixture the release workflow's smoke test drives. # # echo "SUBMILLI_SERVER_TOKEN=$(openssl rand -hex 32)" > .env # docker compose up -d # set -a; . ./.env; set +a # curl -X PUT http://127.0.0.1:8128/v1/blueprints/demo \ # -H "Authorization: Bearer $SUBMILLI_SERVER_TOKEN" \ # -H 'content-type: application/json' \ # -d "$(python3 -c 'import json,sys; print(json.dumps({"yaml": sys.stdin.read()}))' \ # < examples/docker/blueprints/demo.yaml)" # # SUBMILLI_IMAGE lets the smoke test point this at a locally-built image; the # default is the published tag a partner would pull. services: submilli: image: ${SUBMILLI_IMAGE:-ghcr.io/submilli/submilli-runtime:latest} # The server speaks plain HTTP, so a token sent across a LAN is readable on # the way, and the published port is scoped to the loopback interface. This # is substantive, not stylistic: Docker DNATs published ports in # nat/PREROUTING and they then traverse FORWARD via DOCKER-USER/DOCKER, so # the INPUT rules ufw writes are never consulted — a plain `8128:8128` would # expose a code-execution endpoint to the LAN on a host the operator # believes is firewalled. # # Loopback publishing scopes the *host* interface only. It does not isolate # the server from other containers, which is why `submilli-net` below is a # dedicated network rather than the default bridge: on the default bridge any # co-resident container reaches the server directly on its container IP, # where the published-port restriction does not apply. ports: - "127.0.0.1:8128:8128" networks: - submilli-net # Persist SQLite at /var/lib/submilli/server/db/submilli.db together with # its journal and lock files. Sessions, secrets, packages, and every VFS # directory (named volumes, session files, and ephemeral scratch, under # server/volumes/) remain under the same server/ root. Existing blueprint # revision files are imported at startup, then the directory moves to # server/archive/blueprints/. volumes: - submilli-state:/var/lib/submilli # A writable /tmp for anything that asks the OS for a temp file: the root # filesystem is read-only. Programs' ephemeral scratch is not here. It lives # on the state volume with the rest of the VFS directories. mode=1777 # matches the base image's /tmp. tmpfs: - /tmp:mode=1777,size=256m # Two layers behind non-root. The server binds unprivileged 8128, needs no # capabilities, and spawns no subprocesses, so both cost nothing here — and # the threat being defended against is a sandbox escape from interpreted # user code, which is the same reason the base image ships no shell. cap_drop: - ALL security_opt: - "no-new-privileges:true" read_only: true # Docker's SIGKILL fallback. The server's own teardown is bounded well inside # it: a 5s request drain (--shutdown-grace), then 250ms of runtime teardown, # then up to 1s flushing telemetry — about 6.25s worst case. Raise # --shutdown-grace and this together, never one alone. stop_grace_period: 10s healthcheck: test: ["CMD", "/usr/local/bin/submilli-server", "--health-check"] interval: 30s timeout: 5s start_period: 10s retries: 3 # SUBMILLI_BIND/SUBMILLI_PORT are what the healthcheck reads: it runs as its # own process and cannot inherit the server's flags. The image already sets # HOST=0.0.0.0, but that is the *ambient* tier — naming them explicitly is # what makes an operator's port override apply to the probe as well. # # SUBMILLI_SERVER_TOKEN is the server's admin token, read from `.env`. The # `submilli server` CLI sends the same variable, so `docker compose exec # submilli submilli server status` works as it is. environment: SUBMILLI_BIND: 0.0.0.0 SUBMILLI_PORT: 8128 SUBMILLI_DATABASE_PATH: /var/lib/submilli/server/db/submilli.db # Update this host and port if you rename the service or change its container port. SUBMILLI_MCP_ALLOWED_HOSTS: submilli:8128 SUBMILLI_SERVER_TOKEN: ${SUBMILLI_SERVER_TOKEN:?set SUBMILLI_SERVER_TOKEN in .env, for example with `openssl rand -hex 32`} # Supply the secret-store key as a file, not as an environment variable: an # env var is readable via `docker inspect`, appears in `docker compose # config` output, and is inherited by the process environment. # # Uncomment both blocks after creating the key. Compose's uid/gid/mode secret # attributes are ignored outside swarm and Docker does not chown bind mounts, # so a conventionally-permissioned root-owned 0600 file is unreadable by uid # 65532 — and the server fails boot rather than degrading. Mode 0444 (or # owner 65532:65532 with 0400) is required: # # head -c32 /dev/urandom | base64 > submilli-store-key # chmod 0444 submilli-store-key # # environment: # SUBMILLI_SECRET_STORE_KEY_FILE: /run/secrets/submilli-store-key # secrets: # - submilli-store-key # # Without a key the secret store is simply off and the server boots normally. # # To install packages from private GitHub repositories, give the server a # GitHub token: a fine-grained token with Repository permissions → Contents: # Read-only on the package repositories. The server reads it from the file # its config file names as `github_token_file`, again on every install, so # overwriting the file in place rotates it. Same file-mode caveat as the # key above: # # printf '%s' "$GITHUB_TOKEN" > github-token && chmod 0444 github-token # printf 'github_token_file: /run/secrets/github-token\n' > server.yaml # # environment: # SUBMILLI_CONFIG: /run/secrets/submilli-config # secrets: # - github-token # - submilli-config volumes: submilli-state: networks: submilli-net: # secrets: # submilli-store-key: # file: ./submilli-store-key # github-token: # file: ./github-token # submilli-config: # file: ./server.yaml