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:
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
docker compose up— the entrypoint copies a blankconfig.jsoninto/config(only if one isn't already there), sees no credentials, and starts the setup wizard on:7771.- Open
http://localhost:7771and 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 writesnode_id+api_keyintoconfig.jsonand drops a.provisionedmarker. - The container exits and
restart: unless-stoppedrelaunches 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 asX-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:
(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:
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