Skip to content

Docker & Compose

Terminal window
mkdir -p ~/nouride && cd ~/nouride
docker run -d --name nouride --restart unless-stopped \
-p 18254:18254 \
-e HOST=0.0.0.0 \
-v "$PWD/nouride:/app/.nouride" \
ghcr.io/nouverse-tech/nouride:latest

Nothing to configure first. A fresh install boots with no config.toml and no agents, and everything after that happens in the browser.

Terminal window
docker compose logs -f # the first-run password is printed here

Compose

name: nouride
services:
nouride:
image: ghcr.io/nouverse-tech/nouride:latest
container_name: nouride
restart: unless-stopped
ports:
- "${PORT:-18254}:${PORT:-18254}"
# Every credential reaches the container this way. Secret *names* are chosen in config.toml —
# `api_key = "nougate"` reads $NOUGATE — so they cannot be enumerated under `environment`.
env_file:
- path: .env
required: false
environment:
- HOST=${HOST:-0.0.0.0}
- PORT=${PORT:-18254}
- LOG_LEVEL=${LOG_LEVEL:-info}
volumes:
# The whole install as one mount: config, database, secrets, agent packs, workspace.
- ./nouride:/app/.nouride:z
healthcheck:
test: ["CMD", "bun", "-e", "await fetch('http://localhost:18254/health').then(r=>process.exit(r.ok?0:1))"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"

Why env_file and not just environment. Compose reads .env either way, but only to substitute ${...} in the compose file itself — any key not also listed under environment would be silently ignored by the daemon. And the list under environment is deliberately short, because credential variable names are chosen in config.toml and cannot be enumerated here.

You can also keep credentials out of .env entirely and set them from the dashboard. They land in .nouride/data/secrets.json, which is on the mounted volume.

One mount, one backup

.nouride/
├── config.toml
├── data/ SQLite, secrets.json, the control token — 0600
├── agents/ persona packs
└── workspace/ what the agents produce

To keep the agents’ output on a different disk from the daemon’s state, mount workspace separately and point workspace_dir at it. The three paths are separate config keys for exactly that.

The registry

Terminal window
echo "$GHCR_TOKEN" | docker login ghcr.io -u <github-username> --password-stdin
docker compose -f docker-compose.prod.yml up -d

A classic PAT, or a fine-grained token with read:packages and nothing more. For Kubernetes the same credential becomes an imagePullSecret:

Terminal window
kubectl create secret docker-registry ghcr \
--docker-server=ghcr.io --docker-username=<github-username> --docker-password="$GHCR_TOKEN"

The image contains no credentials, so making the package public is a real option rather than a compromise — and it is the difference between one docker login and a token to rotate on every machine that pulls.

What is in the image, and what is not

openssh-client, util-linux (for script(1), which is what makes interactive sessions work) and ripgrep. Nothing else — not git, not kubectl, not a cloud CLI.

The container runs as uid 1000, so the agent cannot install anything at runtime: apk add fails with a permission error, and anything it did install would be gone on the next image build. Add what your work needs at build time:

Terminal window
docker build --build-arg EXTRA_PACKAGES="git kubectl helm" -t nouride .

For infrastructure work over SSH you usually need less than you expect — kubectl runs on the machine at the far end, not in here.

Reaching other machines over SSH

The image carries openssh-client. Two things it cannot carry have to come from outside.

The key. Forward your ssh-agent socket rather than mounting a private key. A key on disk with no passphrase is a secret this process can read and any subprocess it starts can copy; a socket is a path whose other end decides, per request, whether to sign. SSH_AUTH_SOCK is on the exec environment allowlist for exactly this.

services:
nouride:
environment:
- SSH_AUTH_SOCK=/ssh-agent
volumes:
- ${SSH_AUTH_SOCK}:/ssh-agent # Linux
# macOS (Docker Desktop / OrbStack) exposes the host agent at a fixed path instead:
# - /run/host-services/ssh-auth.sock:/ssh-agent
- ./ssh:/home/bun/.ssh # so known_hosts survives a recreate

Host keys. Without a persisted known_hosts, every host is unknown on every container recreate — and there is no terminal to answer the prompt, so the command hangs until its timeout rather than failing. That is the confusing shape: it looks like the network, and it is a missing file. Either mount the directory as above, or accept new hosts on first sight with ssh -o StrictHostKeyChecking=accept-new.

Interactive sessions

They work in the container, and that is not a small thing. exec runs with stdin closed — the right default for a one-shot command — but the session tool opens a persistent process the agent can type into, so a bare ssh host, kubectl exec -it, vim and a sudo password prompt are all reachable.

The terminal comes from script(1) in util-linux, which is in the image: a child that checks isatty sees a real pty. A native macOS install does not get this.

Restarting

Terminal window
docker compose restart
docker compose pull && docker compose up -d # upgrade

nouride restart inside a container reports docker and refuses: the restart policy lives outside the container, so exiting is only a restart if you configured one.

WhatsApp in its own container

# Enable together with `bridge_transport = "ws"` in config.toml.
wa-bridge:
image: ghcr.io/nouverse-tech/nouride-wa-bridge:latest
container_name: nouride-wa-bridge
restart: unless-stopped
environment:
- BRIDGE_PORT=8099
volumes:
- ./nouride/data/whatsapp:/app/session:z
[gateways.whatsapp.main]
bridge_transport = "ws"
bridge_url = "ws://wa-bridge:8099"

WhatsApp →