If your application runs in containers on one host, the server runs as
one more container. Docker changes two things about keeping it private.
A published port goes around the host’s firewall, and other containers
reach a container over Docker’s networks, bypassing the published
port. The published compose.yaml handles both, and keeps the state on a
volume so it survives a redeploy.
This guide shows you how to run the server with Docker Compose.
Start it
Section titled “Start it”The published file is the stack’s compose.yaml. Your application joins
it as another service. The file and the image come from the same
release, so download the file at the release’s tag and pin the image to
the same version in .env, beside the token. Into a new directory:
curl -fsSLO https://raw.githubusercontent.com/submilli/submilli-runtime/v0.3.0/compose.yamlprintf 'SUBMILLI_IMAGE=ghcr.io/submilli/submilli-runtime:0.3.0\nSUBMILLI_SERVER_TOKEN=%s\n' "$(openssl rand -hex 32)" > .envdocker compose up -ddocker compose psNAME IMAGE COMMAND SERVICE CREATED STATUS PORTSmyproject-submilli-1 ghcr.io/submilli/submilli-runtime:0.3.0 "/usr/local/bin/subm…" submilli 12 seconds ago Up 12 seconds (healthy) 127.0.0.1:8128->8128/tcpCompose reads the token from .env and refuses to start without it. The
port is published on the host’s loopback, so the submilli server
commands work from the host once the same token is in your shell:
set -a; . ./.env; set +asubmilli server statusThe file also sets up:
- A named volume,
submilli-state, holds$SUBMILLI_HOME, so Blueprints, sessions, Packages, and secrets survivedocker compose downand image upgrades.docker compose down -vdeletes it. - Each run’s scratch directory lives in a
256 MB
tmpfsat/tmp, so it never grows the container’s disk. - The server runs as a non-root user on a read-only filesystem with every Linux capability dropped. The image has no shell. These are extra layers beneath the sandbox itself.
- A health check runs
submilli-server --health-check, sodocker compose pscan sayhealthy. - Docker waits ten seconds before forcing the
container to stop, which covers the server’s five-second drain. If
you raise
--shutdown-grace, raisestop_grace_periodwith it.
Put your application on its network
Section titled “Put your application on its network”The file publishes the server’s port as 127.0.0.1:8128, not 8128.
That difference matters more with Docker than it looks. Docker routes
published ports around the host’s firewall, so a plain 8128:8128 is
reachable from your network even on a host where ufw says the
port is closed.
Loopback publishing only covers the host, though. Other containers reach
the server over Docker’s networks. So the server
sits on its own network, submilli-net, and only containers you put on
that network can reach it. Add your application to it and call the
server by its service name:
services: app: image: your-application environment: SUBMILLI_URL: http://submilli:8128 SUBMILLI_SERVER_TOKEN: ${SUBMILLI_SERVER_TOKEN} networks: - submilli-netYour application defines the two variables, so name them as it expects.
This hands it the admin token. To give it a user token instead,
declare one in a config file as Run the
server shows, and mount the file and
the token the way the store key is mounted below.
Any container on submilli-net can reach the API, so don’t put anything
else there. A container on Docker’s default network can’t connect at
all. Its requests time out.
An application that connects over MCP also needs the server to accept
the service name as a Host header, or its requests are refused with
403 Forbidden: Host header is not allowed. The shipped file sets
SUBMILLI_MCP_ALLOWED_HOSTS: submilli:8128 on the service for this. Change that value if you rename the service or its port.
Register Blueprints and install Packages
Section titled “Register Blueprints and install Packages”The port is published on the host, so Blueprints, secrets, and Packages
reach the server from the host with the same commands as anywhere,
through Register a Blueprint.
A deploy job does the same with the token from .env. For a Package in
a private repository the server needs its own GitHub token. Refer
to Install private Packages.
Give it the store key as a file
Section titled “Give it the store key as a file”The secret store stays off until the server has a key. Give it one as a
file, because environment variables show up in
docker inspect and docker compose config.
head -c 32 /dev/urandom | base64 > submilli-store-keychmod 0444 submilli-store-keyAdditions to the stack go in compose.override.yaml, which Compose
reads automatically, so the shipped file stays untouched:
services: submilli: environment: SUBMILLI_SECRET_STORE_KEY_FILE: /run/secrets/submilli-store-key secrets: - submilli-store-key
secrets: submilli-store-key: file: ./submilli-store-keyThe 0444 matters on a Linux host. Outside Docker Swarm, Compose mounts
the file with its original owner and mode, and the server runs as user
65532, so a 0600 file owned by you is unreadable and the server refuses
to start:
Error: opening secret store: secret store key: reading key file `/run/secrets/submilli-store-key`: Permission denied (os error 13)Docker Desktop on macOS and Windows hides file ownership, so a 0600 key
works there and then fails on the Linux server you deploy to. Set 0444
everywhere. Keep the key out of source control and out of your volume
backups.
Enable HTTPS
Section titled “Enable HTTPS”In this setup the API token never leaves the host. The port is published on the loopback, and your application reaches the server over a Docker network on the same machine. Plain HTTP is enough there. If you publish the port beyond the loopback, so that callers on other machines reach it, turn HTTPS on first, or the token crosses the network readable.
Mount the certificate chain and its private key, in PEM, the way the
store key is mounted, and name them in the two variables. The
certificate must cover the names callers use, which are submilli for your
application’s container and the host’s name for callers elsewhere.
services: submilli: environment: SUBMILLI_TLS_CERT_FILE: /run/secrets/submilli-tls-cert SUBMILLI_TLS_KEY_FILE: /run/secrets/submilli-tls-key secrets: - submilli-tls-cert - submilli-tls-key
secrets: submilli-tls-cert: file: ./server.crt submilli-tls-key: file: ./server.keyThe key needs the same 0444 as the store key, for the same reason. The
health check switches to HTTPS automatically.
Your application then calls https://submilli:8128. For a self-signed
certificate it must also be told to trust it, so give its container the
certificate:
services: app: environment: SUBMILLI_URL: https://submilli:8128 NODE_EXTRA_CA_CERTS: /run/secrets/submilli-tls-cert secrets: - submilli-tls-certNODE_EXTRA_CA_CERTS is for a Node application. It adds the file to the
authorities Node already trusts. Python’s SSL_CERT_FILE replaces those
authorities, so a Python application that also calls its model
provider over HTTPS needs a bundle that holds both, built when the
container starts:
cat "$(python -m certifi)" /run/secrets/submilli-tls-cert > /tmp/ca-bundle.pemexport SSL_CERT_FILE=/tmp/ca-bundle.pemAllow a service on your Docker network
Section titled “Allow a service on your Docker network”The server blocks programs from calling private addresses, which includes other containers on your Docker networks. So a Package that calls one of your own services, say an inventory API in another container, fails with a generic network error until you allow that service’s address:
services: submilli: environment: SUBMILLI_ALLOW_IP: 172.21.0.3SUBMILLI_ALLOW_IP takes an address or a range, comma-separated for more
than one. Allow the narrowest thing that works. A container’s address can
change when it’s recreated, so for a service that moves, give its network
a fixed subnet and allow that. Once you do, the server logs this line so that a setting like this
never goes unnoticed:
ts=2026-10-03T17:04:41.940Z level=warn stream=log target=submilli_server msg="the outbound egress guard was widened by environment variables; the config file cannot revoke these" vars=SUBMILLI_ALLOW_IPUpgrade and back up
Section titled “Upgrade and back up”Both the file and the image are pinned to a release. Version 0.2.0 is the first published release compatible with this guide’s token authentication and health check. An older installation needs its configuration and Blueprints migrated before starting the new server. Read the 0.2.0 release notes and back up its state first.
For a later upgrade, download compose.yaml from that published release’s
tag, set SUBMILLI_IMAGE in .env to the same version, and run
docker compose up -d. Keep the API token and the store key. The volume
carries the state across. Check the release’s migration instructions before
reusing it.
To back up, copy the volume while the server is stopped. Compose prefixes
the volume name with the project name, usually the directory name, and
docker volume ls shows it:
docker compose stop submillidocker run --rm -v myproject_submilli-state:/state:ro -v "$PWD":/backup busybox \ tar czf /backup/submilli-state.tgz -C /state .docker compose start submilli