Skip to content

Node in Docker (Compose)

Besides the Pi Zero voice node, Jarvis ships a containerized node you can run anywhere Docker runs — a laptop on the LAN, a dev box, or alongside the server stack itself (the demo/kitchen nodes on the prod host run this way). It's the same jarvis-node-setup codebase, packaged as an image.

This is the text-mode node

ghcr.io/alexberardi/jarvis-node:latest is the headless, text-mode image — no microphone, no wake word, no audio. It exposes a REST API you drive with typed commands (POST /api/v1/command), which is ideal for demos, integration tests, and dev. Voice capture needs the separate audio image and host audio devices — see Voice mode below.

The compose file

A minimal, working single-node deployment:

docker-compose.yml
services:
  jarvis-node:
    image: ghcr.io/alexberardi/jarvis-node:latest
    container_name: jarvis-demo-node
    ports:
      - "7771:7771"            # setup wizard + node API
    environment:
      - CONFIG_PATH=/config/config.json
      - JARVIS_NODE_PORT=7771
      - JARVIS_NODE_DB=/data/jarvis_node.db
      - JARVIS_SECRET_DIRECTORY=/data
      - JARVIS_CONFIG_URL_STYLE=remote
    volumes:
      - ./config:/config       # directory — the entrypoint seeds config.json inside it
      - jarvis-node-data:/data
      - jarvis-node-packages:/root/.jarvis/packages
    restart: unless-stopped

volumes:
  jarvis-node-data:
  jarvis-node-packages:

That's enough to boot an unprovisioned node and pair it through the setup wizard. Read on for what each line does — and, before production use, the three things this minimal file leaves out.

Environment variables

The image bakes sensible defaults (CONFIG_PATH, JARVIS_NODE_PORT, and JARVIS_SKIP_PROVISIONING_CHECK=true), so most of these are explicit-for-clarity rather than strictly required.

Variable Value What it does
CONFIG_PATH /config/config.json The node's JSON config — identity (node_id, api_key), the config-service URL, room, MQTT settings. Seeded from a template on first boot, then filled in by the wizard. Any config key can also be overridden by a JARVIS_<KEY> env var, which wins over the file.
JARVIS_NODE_PORT 7771 HTTP bind port for the setup wizard (unprovisioned) or the REST API (provisioned). Must match the container side of the ports: mapping.
JARVIS_NODE_DB /data/jarvis_node.db Path to the node's encrypted SQLite (SQLCipher) database — reminders, timers, installed-command secrets, agent schedules.
JARVIS_SECRET_DIRECTORY /data Directory holding all crypto material (see below). Defaults to ~/.jarvis; pointing it at /data puts it on the persistent volume.
JARVIS_CONFIG_URL_STYLE remote How the node rewrites discovered service URLs for its network vantage point. remote is a legacy value — the node auto-recomputes the correct style at startup. See URL rewrite style.

Ports

Only one port matters: 7771. What it serves depends on whether the node is provisioned yet.

A self-service web UI for pairing the node. Open http://localhost:7771 in a browser.

Endpoint Purpose
GET / The wizard HTML
GET /health {"status":"setup_required","mode":"setup"}
POST /setup/discover Scan the LAN for jarvis-config-service
POST /setup/connect Resolve + verify auth/CC URLs, write them to config.json
POST /setup/complete Register the node (mints a provisioning token, exchanges it for a node key)

The headless node API (text mode).

Endpoint Purpose
GET /health {status, node_id, mode:"text", needs_k2}
GET /api/v1/info {node_id, room, mode, mqtt_enabled}
POST /api/v1/command Run text through the full command pipeline — body {"text": "set a 10 minute timer"}{success, message, data}
GET /debug/mqtt MQTT connection/thread status

The volumes

All three volumes hold state that must persist across container recreation — losing any of them has consequences:

Mount Holds If you lose it
./config:/config config.json — the node's credentials + bootstrap URLs The node drops back into setup mode and must be re-paired.
jarvis-node-data:/data The encrypted DB and all crypto: secrets.key (K1 master key), k2.enc (the key shared with the mobile app for settings sync), db.key (SQLCipher key), the .provisioned marker The DB and K2 become unrecoverable; reminders/timers/installed-command secrets are lost.
jarvis-node-packages:/root/.jarvis/packages Installed Pantry package metadata + shared libraries Installed packages need reinstalling. (/root because the image runs as root — the mount path is coupled to that.)

Config-service is the single source of truth

The node fabricates no URLs of its own — every service address (command-center, MQTT broker, auth) is resolved through config-service. If config-service can't resolve a name, the call fails loudly rather than falling back to localhost.

First boot & provisioning

graph LR
    A["docker compose up"] --> B["Entrypoint seeds<br/>blank config.json"]
    B --> C{"Has<br/>credentials?"}
    C -->|No| D["Setup mode<br/>wizard on :7771"]
    D --> E["Pair via browser<br/>→ writes node_id + api_key"]
    E --> F["Container restarts<br/>(restart: unless-stopped)"]
    C -->|Yes| G["Text mode<br/>REST API on :7771"]
    F --> G
  1. docker compose up — the entrypoint copies a blank config.json into /config (only if one isn't already there), sees no credentials, and starts the setup wizard on :7771.
  2. Open http://localhost:7771 and walk the five steps: point it at your config-service URL → sign in (or create the first account) → pick a household → name the node + room → Register. The wizard writes node_id + api_key into config.json and drops a .provisioned marker.
  3. The container exits and restart: unless-stopped relaunches it. Now it has credentials, so it boots into text mode: runs migrations, connects to MQTT, and serves the REST API. The node is live, authenticating to command-center as X-API-Key: node_id:api_key.

Minimum server-side requirement

A reachable jarvis-config-service (:7700) that has jarvis-auth and jarvis-command-center registered, plus a user account/household on that stack. No mobile app or pre-claim token is needed — the built-in wizard does the whole pairing. (The mobile-app + WiFi-AP flow in Provisioning is the Pi route, not this one.)

What the node reaches

Service Port Needed for
config-service 7700 Bootstrap — everything else is discovered from it
command-center 7703 All voice/text traffic; node registration
auth 7701 Login/registration during setup (at runtime, CC validates the node's key against auth)
MQTT broker 1883/1884 Server→node messages — TTS, settings push, package installs, reminders

URL rewrite style (important)

config-service returns each service's registered address as-is; whether those addresses are reachable depends on where the node sits relative to the stack. JARVIS_CONFIG_URL_STYLE controls the rewrite. The node auto-computes the right value from the config-service host on every startup — so set it to match your topology, or omit it and let the node decide:

Node location Style Effect
Same Docker host as the stack dockerized localhost-registered rows → host.docker.internal (requires extra_hosts on Linux — see below)
A different machine / LAN external Uses each service's published address and swaps localhost for the server IP — resolves both infra rows (broker) and container-name rows (command-center)
Same box, not containerized (none) URLs are already reachable as-is

remote is legacy

The example compose sets JARVIS_CONFIG_URL_STYLE=remote, which is a retired value. On startup the node treats an unset-or-remote style as "recompute from the config-service host," so it self-heals to dockerized or external. For a new deployment, prefer external (off-box) or dockerized (same host) — or omit it entirely. (remote only rewrote localhost rows, so a command-center registered under its container name stayed unreachable off-box — the bug that retired it.)

Hardening the minimal compose

The compose above is deliberately minimal. Three additions matter for anything beyond a same-host demo:

1. extra_hosts on Linux. A dockerized node on a Linux host needs the host.docker.internal alias mapped, or it can't reach host-published services:

    extra_hosts:
      - "host.docker.internal:host-gateway"

(Harmless on Docker Desktop / macOS, where the alias already exists.)

2. Persist installed-package components. The jarvis-node-packages volume keeps package metadata and shared libraries, but the executable component files land under /app/**/custom_* inside the image layer. Without volumes for those, Pantry-installed commands are lost when you pull a new image. Add:

    volumes:
      - jarvis-node-commands:/app/commands/custom_commands
      - jarvis-node-agents:/app/agents/custom_agents
      - jarvis-node-families:/app/device_families/custom_families
      - jarvis-node-managers:/app/device_managers/custom_managers
      - jarvis-node-routines:/app/routines/custom_routines

3. Pin the image. :latest is convenient for a demo but non-reproducible. Pin a released tag for anything you depend on.

Optional environment variables

Variable Purpose
JARVIS_NODE_MODE text (default) or voice. voice needs the audio image + host audio devices.
JARVIS_CONFIG_URL An explicit config-service URL — wins over config.json and suppresses the style self-heal.
JARVIS_<SERVICE>_LAN_URL Per-service LAN override, e.g. JARVIS_COMMAND_CENTER_LAN_URL=http://10.0.0.107:7703. Short-circuits discovery for a LAN-co-located node (~7 ms vs ~1.9 s via the relay).
JARVIS_MASTER_KEY Supply the SQLCipher DB key externally instead of the auto-generated db.key.
JARVIS_<CONFIG_KEY> Override any config.json key — e.g. JARVIS_ROOM, JARVIS_MQTT_ENABLED.

Voice mode

The :latest image can't do voice — the audio stack (PyAudio, openWakeWord) isn't in it. Running a voice node in Docker needs the audio image plus host audio-device passthrough; in practice, voice nodes run on Pi Zero hardware rather than in a container. Use the text-mode container for headless/REST scenarios.

Operating it

docker compose up -d          # start
docker compose logs -f        # watch boot → setup or text mode
docker compose down           # stop (volumes persist)

Health check once provisioned:

curl localhost:7771/health
# {"status":"healthy","mode":"text","node_id":"...","needs_k2":false}

Send a command:

curl -X POST localhost:7771/api/v1/command \
  -H "Content-Type: application/json" \
  -d '{"text": "what time is it"}'

See also

  • Node Setup — the full node runtime, service discovery, wake behavior, and update policy
  • Provisioning — the Pi Zero (mobile-app + WiFi-AP) pairing flow
  • Service Discovery — how config-service resolves addresses