This guide takes a stack you run today with docker compose up on a
single host — including mounted volumes — and moves it onto a multi-node
JACO cluster.
JACO consumes the same docker-compose.yml plus a small
jaco.yaml overlay that declares the
cluster-level concerns the single-host file never had: how many
replicas, which hosts, and public ingress. The compose file keeps
describing service shapes (image, environment, healthcheck, volumes,
networks).
The hard part of any migration is state. Read the next section
before you do anything else — it determines how you lay out and move
every stateful service.
Docker volumes and bind mounts are node-local. JACO does not
replicate volumes, has no networked/CSI storage layer, and data does
not follow a replica if the scheduler places it on a different node.
This has hard consequences:
A stateful service must be replicas: 1 and pinned to one node
with placement: hosts (see Scheduling).
If the pinned node is down, the service reports pending and is
not rescheduled onto an empty volume elsewhere — safe for your
data, but it means there is no automatic failover of the data itself.
High availability for stateful data is done at the application
layer (e.g. Postgres streaming replication, Redis replication),
with each instance pinned to a different node. JACO supplies the
cross-node network and DNS; the database does the replication.
For a service mount like pgdata:/var/lib/postgresql/data:
volume name actually used
docker compose up
<project>_pgdata (project defaults to the compose file's directory name)
jaco apply
jaco_<deployment>_pgdata (the deployment name from jaco.yaml)
JACO scopes every declared named volume to the deployment so two
stacks that happen to use the same bare key (pgdata, data,
logs, cache, …) cannot collide on a shared local docker volume
(internal/runtime/compose/spec.go).
The scheme matches the existing per-deployment convention used for
networks (jaco_<deployment>_<network>) and container names
(<deployment>-<service>-<index>). The prefix never appears inside
the container — the service still reaches the volume at its declared
mount path.
When you want two deployments to share storage (or you're migrating
from a stack whose volume is already named myproject_pgdata and you
want to keep using it in place), set the top-level volumes.<key>.name:
to the literal docker volume name. JACO honors it verbatim — no
deployment prefix is applied:
The same escape hatch covers external: true — compose's "this volume
already exists, don't manage it" contract — which JACO recognises and
also leaves unprefixed.
driver: and driver_opts: on the top-level entry are still
silently dropped. A volume backed by an NFS or cloud driver becomes
a plain local-driver volume on each node. If your current stack gets
shared storage through a volume driver, that does not carry over —
flatten it to a plain named volume plus an explicit data copy, or
front it with application-level replication.
A bind mount whose host path does not exist on the target node is not
rejected at apply — docker auto-creates an empty directory there. A
bind-mounted data directory silently comes up empty on the cluster
node unless you pre-stage the path first.
Stateless (web, API, workers, proxies) — no meaningful local
state. These become multi-replica, spread across the cluster.
Stateful (databases, caches with persistence, queues, anything
whose volume holds data you can't lose) — single pinned replica, or
app-level replication.
For every volumes: entry, decide:
Real persistent data (a database data directory) → must be moved
(Step 5) and the service pinned (Step 4).
Config / source bind mounts (./nginx.conf:/etc/nginx/...,
./src:/app) → these host paths won't exist on cluster nodes. Bake
config into the image, deliver it via environment / env_file, or
hoist per-stack values into a top-level
environment: <path> on
jaco.yaml and reference them via ${VAR} in the compose file —
see Step 4 below for the migration shape.
# node 1sudo jaco cluster init# Save the printed operator_token — it cannot be recovered.export JACO_TOKEN=<operator_token>jaco node issue-join-token # prints the join command# nodes 2 and 3sudo jaco node join --peer <node-1-host>:7000 --token <single-use>
Confirm all three are ready:
export LEADER=<node-1-host>:7000jaco node list --server $LEADER
JACO pulls every image and never builds — the compose build:
field is accepted but ignored
(compose.md). Any image you build locally
must be pushed to a registry the cluster nodes can reach, and the
compose image: must point at it. Plan registry credentials/network so
each node can pull.
Keep the honored fields; remove anything outside the allowlist (unknown
service fields reject the apply with validation_failed). Two specific
edits almost every stack needs:
Reserved ports — remove any ports: entry publishing host port
80 or 443; those belong to JACO's ingress and reject with
reserved_port. Public HTTP(S) moves to jaco.yamlroutes:.
Other published ports — "6379:6379" becomes a cluster-wide
raw-TCP listener automatically; no change needed.
restart:, build:, and deploy.replicas/placement are
parsed-but-ignored — the scheduler owns those decisions. depends_on
is honored as start ordering only.
deployment: myappservices: - name: web replicas: 3 # stateless → spread across all 3 nodes - name: api replicas: 3 - name: db replicas: 1 # stateful → single instance... placement: hosts hosts: [node-2] # ...pinned to the node holding its volume - name: cache replicas: 1 # in-memory cache → single instance is fineroutes: - domain: app.example.com service: web port: 80 tls: auto
Service names in jaco.yaml must match compose service keys exactly, or
the apply rejects with unknown_service.
Compose .env files at the project root are not honored by JACO
(the daemon never reads operator-side files). The equivalent shape
is the top-level environment: <path>
on jaco.yaml, loaded CLI-side and used as the ${VAR}
interpolation source for the whole compose document.
If your single-host stack relied on a project .env:
# single-host todayREGISTRY=ghcr.io DB_URL=postgres://… docker compose up# or implicitly via ./.env
rename the file and point jaco.yaml at it explicitly:
# jaco.yamldeployment: myappenvironment: .env # path relative to this jaco.yamlroutes: ...services: ...
${VAR} references resolve from the env file the CLI loads —
process-environment passthrough is deliberately NOT honored
(manifests stay explicit and reproducible across operators / hosts).
Service-level env_file: keeps working in parallel; per
compose-spec precedence, the explicit environment: value on a
service wins over a service-level env_file: entry for matching
keys.
For databases this is the safest path — it avoids uid, page-format, and
engine-version mismatches that raw volume copies hit:
# On the old hostdocker exec <old-db-container> pg_dumpall -U postgres > dump.sql# After db comes up on its pinned node (Step 6), load itpsql "postgres://postgres@<node-2-host>:5432/" < dump.sql
The source volume on the old host is <project>_pgdata. The
destination depends on whether you let JACO scope the volume to its
deployment (default — recommended) or pin the literal name via the
volumes.<key>.name: escape hatch.
Default (deployment-scoped): the destination volume on the cluster
node is jaco_<deployment>_pgdata. Substitute the deployment name
from your jaco.yaml (e.g. myapp → jaco_myapp_pgdata).
# On the OLD host — confirm the real name, then export the live volume.docker volume ls | grep pgdatadocker run --rm -v <project>_pgdata:/from:ro -v "$PWD":/backup \ alpine tar czf /backup/pgdata.tgz -C /from .# Copy to the node you pinned the service to.scp pgdata.tgz node-2:/tmp/# On node-2 — create the deployment-scoped volume JACO will mount, then load it.docker volume create jaco_myapp_pgdatadocker run --rm -v jaco_myapp_pgdata:/to -v /tmp:/backup:ro \ alpine sh -c 'cd /to && tar xzf /backup/pgdata.tgz'
If you'd rather keep using the volume name your old stack created
(e.g. you've already taken a snapshot named myproject_pgdata and
want JACO to mount it in place), set
volumes: { pgdata: { name: myproject_pgdata } } in the compose file.
JACO uses that literal verbatim and skips the deployment prefix.
The pinned db should land on node-2 and reach running; stateless
services spread to running on all three nodes. A db stuck in
pending with cannot_satisfy_host_placement means node-2 isn't
eligible (check jaco node list).
Verify — check routes/TLS resolve, tail logs
(jaco logs), and confirm data integrity in the new
stack:
jaco logs --server $LEADER myapp/db --follow
Decommission the old single-host stack only after you've
confirmed data and traffic on the cluster.
A single pinned instance is a single point of failure: if its node
dies, the service sits in pending until the node returns (data can't
follow). For real HA, run application-level replication with each
instance pinned to a different node — the pattern the shipped sample
uses (tests/samples/jaco/):
The replica streams WAL from the primary across the WireGuard mesh.
JACO keeps each instance on its node and its volume; the database owns
the replication and failover policy.
If you are porting from a compose file written against the v1 or v2
spec, a handful of keys were dropped from the modern spec and JACO
rejects them at parse time with a typed legacy_compose_field error
naming the modern equivalent (issue #122). The error's
details.field and details.modern_equivalent give an actionable
diagnostic instead of an opaque "unknown field":
legacy key
rewrite to
log_driver: json-file
logging:driver: json-file
log_opt: {max-size: 10m}
logging:options:max-size: 10m
net: host
network_mode: host
volume_driver: local
use the long-form volumes: entry with driver: local (see compose spec)
top-level service dockerfile:
build:dockerfile: … (then drop it — JACO ignores build:)
Genuine typos (a misspelled key not in this list) keep the generic
compose load: wrap so they aren't misclassified.