Run Mosaica with Docker¶
This guide runs Mosaica in a container managed by Docker Compose, with the database kept in a Docker
volume. It suits a host where you already run services this way. You'll need Docker with the Compose
plugin and sudo on the host. From the ~/ireland directory of the first
tutorial you need schema.toml and points.parquet.
Once the container is running, Operate a deployment takes over. It applies to this install and to the systemd one alike.
Get the image¶
Mosaica's image is published to the GitHub Container Registry as ghcr.io/mosaicadb/mosaica, for
linux/amd64 and linux/arm64. Each release is tagged with its version, and the latest tag
follows the newest release. Pull it and check the version:
docker pull ghcr.io/mosaicadb/mosaica:latest
docker run --rm ghcr.io/mosaicadb/mosaica:latest --version
The image puts mosaica on a distroless base, which has the C library and certificates and no
shell, so every command you run in the container is a mosaica command. Given no command, it runs
mosaica serve. It runs as user 65532 and reads mosaica.toml from /etc/mosaica.
Building the image yourself
The Dockerfile at the root of the repository compiles mosaica and copies it onto the same
base image. The build argument puts the commit into --version, which is how you tell two
images apart later.
$ git clone https://github.com/mosaicadb/mosaica ~/mosaica # or git pull in an existing clone
$ cd ~/mosaica
$ docker build --build-arg MOSAICA_BUILD_COMMIT=$(git rev-parse HEAD) -t mosaica .
...
#16 unpacking to docker.io/library/mosaica:latest
#16 unpacking to docker.io/library/mosaica:latest 0.2s done
#16 DONE 1.2s
$ docker run --rm mosaica --version
mosaica 101b9768ff3cda26f8e960572cd898c181fc9fd5
Write image: mosaica in compose.yaml to use it.
Make a deployment directory¶
Compose works from one directory on the host, which holds compose.yaml, the configuration and the
credentials. The database itself lives in a volume that Docker manages.
$ mkdir -p ~/mosaica-docker/config ~/mosaica-docker/secrets
$ cd ~/mosaica-docker
$ cp ~/ireland/schema.toml ~/ireland/points.parquet config/
The container sees these at fixed paths.
| In the container | What it holds | Mounted from |
|---|---|---|
/etc/mosaica |
mosaica.toml, the declaration and the Parquet files it names |
config/, read-only |
/var/lib/mosaica |
the bundle, the write-ahead log, the cache and the catalogue | the volume |
/run/secrets/operator.secret |
the operator credential | a file from secrets/, read-only |
Write mosaica.toml¶
Save this as config/mosaica.toml:
[bundle]
path = "/var/lib/mosaica/bundle"
cache = "/var/lib/mosaica/cache"
wal = "/var/lib/mosaica/wal.log"
[disclosure]
token_max_lifetime = 3600
[serve]
viewer = "0.0.0.0:8080"
session = "0.0.0.0:8081"
control = "0.0.0.0:8082"
operator_credential_file = "/run/secrets/operator.secret"
[catalogue]
dir = "/var/lib/mosaica/catalogue"
The bundle, the log, the cache and the catalogue of users, API keys and grants all go into the
volume, the only place in the container the server can write. token_max_lifetime is how long a browser's token lasts, as Addresses and
credentials explains.
The three addresses listen on every interface inside the container. Other containers on the same
Docker network can reach them there, and so can any process on the host, through the container's
address on Docker's bridge network. Publishing a port in compose.yaml decides what else can reach
it. The operator credential is what keeps everyone who can reach the control port from changing
the corpus. Every address serves plain HTTP, and the server accepts a password sent to the viewer
address over it, so anything that reaches the viewer address from off the host goes through a TLS
proxy.
Write compose.yaml¶
Save this as compose.yaml:
services:
mosaica:
image: ghcr.io/mosaicadb/mosaica:latest
read_only: true
cap_drop: [ALL]
security_opt: [no-new-privileges:true]
volumes:
- ./config:/etc/mosaica:ro
- mosaica-data:/var/lib/mosaica
secrets:
- operator.secret
ports:
- "127.0.0.1:9161:8080"
- "127.0.0.1:9162:8081"
- "127.0.0.1:9163:8082"
restart: unless-stopped
volumes:
mosaica-data:
secrets:
operator.secret:
file: ./secrets/operator.secret
The ports are published to the host's loopback address only, so nothing off the machine reaches
Mosaica directly. Browsers reach the viewer address through a TLS proxy on the host, as Put
Mosaica behind TLS describes. The repository's docker/compose.yaml publishes the viewer
and session ports on every interface instead, which suits a machine behind a firewall or a load
balancer. Publish the control port to loopback or not at all.
read_only mounts the container's own filesystem read-only, so the server can write to the volume
and nowhere else. cap_drop: [ALL] removes every Linux capability, none of which the server uses,
and no-new-privileges stops any process in the container from gaining privileges it started
without.
restart: unless-stopped starts the container again if the server exits, and after the host
reboots. It does not restart a container because it reports itself unhealthy. For Mosaica that is
the behaviour you want, for the reason When a write to the log
fails gives.
Create the credential¶
The server needs the operator credential Addresses and credentials describes before it will start. Compose mounts the file into the container with the owner and mode it has on the host, and the server runs as user 65532. Make the file readable by you and by group 65532, and by no one else:
$ chmod 700 secrets
$ openssl rand -hex 32 > secrets/operator.secret
$ chmod 640 secrets/operator.secret
$ sudo chgrp 65532 secrets/operator.secret
You can still read the file yourself, which the curl examples below rely on. Compose's longer
secret syntax has uid, gid and mode settings that would do this without sudo, but with a
secret read from a file Compose ignores them:
time="2026-09-24T10:19:51+01:00" level=warning msg="secrets `uid`, `gid` and `mode` are not supported, they will be ignored"
If the server can't read the credential, it stops, and the restart policy starts it again, over and over. The log shows why each time:
mosaica serve: refused to start: cannot read the operator credential file /run/secrets/operator.secret (Permission denied (os error 13)); name a readable file, relative to mosaica.toml's directory or absolute
Build the bundle¶
A container starts from an empty volume, so build the bundle into it first. docker compose run
starts a one-off container with the same mounts and runs the command you give it:
$ docker compose run --rm mosaica build
...
built /var/lib/mosaica/bundle (v00000): 29935 items, 1 terms, 29935 pairs, 2181446 bytes on disk, 0 artifact(s) minted, 0 unclustered member row(s), 0 row(s) refused
view ireland: 29935 row(s)
Start the server¶
$ docker compose up -d
Container mosaica-docker-mosaica-1 Creating
Container mosaica-docker-mosaica-1 Created
Container mosaica-docker-mosaica-1 Starting
Container mosaica-docker-mosaica-1 Started
$ docker compose logs
mosaica-1 | 2026-09-24T09:26:52.648054Z INFO mosaica_server::memory: the allocator's arena count is capped arenas=12
mosaica-1 | 2026-09-24T09:26:52.669887Z INFO mosaica_engine::engine: the engine adopted the prefix's derived artifact structures named=0 containment_adopted=0 prefix=v00000
mosaica-1 | 2026-09-24T09:26:52.670888Z INFO mosaica_server: bulk reads may hold this much memory at once, within the process's memory cap bulk_admission=2 max_page_bytes=67108864 bulk_read_memory_bytes=939524096
mosaica-1 | {"event":"listening","viewer":"0.0.0.0:8080","session":"0.0.0.0:8081","control":"0.0.0.0:8082"}
The last line means all three addresses are open. The addresses it names are the ones inside the container, and from the host you use the published ports, 9161 to 9163.
Docker checks the container's health by running mosaica health inside it, which asks the viewer
address whether the server is ready. It checks every 2 seconds for up to ten minutes while the
server starts, since a large bundle can take minutes to open, and every 15 seconds after that.
docker compose ps shows the result:
$ docker compose ps
NAME IMAGE COMMAND SERVICE CREATED STATUS PORTS
mosaica-docker-mosaica-1 mosaica "/usr/local/bin/mosa…" mosaica 7 seconds ago Up 7 seconds (healthy) 127.0.0.1:9161->8080/tcp, 127.0.0.1:9162->8081/tcp, 127.0.0.1:9163->8082/tcp
From the host, ask for a token. Your application's backend will ask with an API key whose user holds
authorise-as, naming the user to read as. The operator credential can instead name the access
attributes the token holds, which needs no user in the catalogue:
$ curl -sS http://127.0.0.1:9162/session/authorise \
-H "authorization: Bearer $(cat secrets/operator.secret)" \
-H 'content-type: application/json' \
-d '{"terms": ["public"]}'
{"token":"37306e2d578bcf30b6685f8d4230224f2fbe304a1e140c0f6a7a57f29c813a08","token_id":0,"expires_at":1790245619}
The control address answers on port 9163:
$ curl -sS http://127.0.0.1:9163/control/status \
-H "authorization: Bearer $(cat secrets/operator.secret)" | jq .write_executor.posture
"running"
Stop, remove and upgrade¶
docker compose stop sends the server SIGTERM, and it exits at once:
$ docker compose stop
Container mosaica-docker-mosaica-1 Stopping
Container mosaica-docker-mosaica-1 Stopped
$ docker compose logs --tail 2
mosaica-1 | {"event":"listening","viewer":"0.0.0.0:8080","session":"0.0.0.0:8081","control":"0.0.0.0:8082"}
mosaica-1 | mosaica serve: stopped on SIGTERM
Stopping and restarting says what that means for requests
in progress. docker compose down removes the container and keeps the volume. docker compose down
-v deletes the volume too, and with it the database and every change made to it since the build.
To upgrade, pull the newest image and start the service again. Compose sees that the image has changed, replaces the container and keeps the volume.
cd ~/mosaica-docker
docker compose pull
docker compose up -d
To stay on one release, write its version, without the leading v, in place of latest in
compose.yaml.
A new version may not read a bundle an older one built. When the bundle's format differs, the server refuses to start and says the bundle must be recreated. Rebuild it from the sources as the next section shows, then send again any changes made while the old bundle was served.
Replace the bundle¶
Rebuild and replace the bundle explains why a rebuild gives every item a new
mosaica_id and why the old log goes aside with the old bundle. Under Compose, copy the changed
sources into config/, here the Parquet file with Saint Patrick's Bridge corrected, and build into
a new directory in the volume.
The image has no shell, so moving directories in the volume needs a short-lived container from
another image. This uses Alpine Linux. Compose names the volume after the directory,
mosaica-docker_mosaica-data.
$ cp ~/ireland/points.parquet config/
$ docker compose run --rm mosaica build --out /var/lib/mosaica/bundle.next 2>&1 | tail -2
built /var/lib/mosaica/bundle.next (v00000): 29935 items, 1 terms, 29935 pairs, 2181449 bytes on disk, 0 artifact(s) minted, 0 unclustered member row(s), 0 row(s) refused
view ireland: 29935 row(s)
$ docker compose stop
Container mosaica-docker-mosaica-1 Stopping
Container mosaica-docker-mosaica-1 Stopped
$ docker run --rm -v mosaica-docker_mosaica-data:/data alpine sh -c 'cd /data && mkdir old && mv bundle cache wal-0* old/ && mv bundle.next bundle && ls'
bundle
old
$ docker compose start
Container mosaica-docker-mosaica-1 Starting
Container mosaica-docker-mosaica-1 Started
$ curl -sS -o /dev/null -w '%{http_code}\n' http://127.0.0.1:9161/readyz
200
wal-0* matches the log's files, which are named after the wal path in mosaica.toml. Once the
new bundle serves what you expect, delete old the same way:
$ docker run --rm -v mosaica-docker_mosaica-data:/data alpine rm -rf /data/old