Deployment
Use this page after nanoinfra agent -m "Hello!" works locally. Deployment keeps long-running surfaces online: WebUI, chat apps, heartbeat, Dream, cron jobs, and channel connections.
Before You Deploy
Check these once before Docker, systemd, or LaunchAgent:
| Check | Why it matters |
|---|---|
nanoinfra status shows the expected config and workspace | Confirms the process will read the instance you meant to run |
nanoinfra agent -m "Hello!" works | Proves install, config, provider, model, and workspace writes before adding a service layer |
| Secrets are in environment variables or protected config files | API keys, bot tokens, OAuth state, and chat credentials should not be world-readable |
~/.nanoinfra/ or your custom config/workspace path is persistent | Sessions, memory, channel login state, generated artifacts, and cron jobs live there |
| Channel access control is intentional | Use allowFrom, pairing, WebSocket token/tokenIssueSecret, or private test channels before exposing the bot |
| Ports are planned | Gateway health defaults to local-only 127.0.0.1:18790. The WebUI and the WebSocket channel default to 8765. nanoinfra serve defaults to 8900 |
| Logs are easy to reach | Use docker compose logs, journalctl, LaunchAgent log files, or nanoinfra gateway --verbose while diagnosing startup |
Restart the deployed process after editing config.json. Long-running processes read config at startup.
Choose a Runtime
| Runtime | Use it for | State location | Useful first command |
|---|---|---|---|
| Docker Compose | Repeatable container runs on Linux servers or workstations | Bind-mount ~/.nanoinfra to /home/nanoinfra/.nanoinfra | docker compose run --rm nanoinfra-cli agent -m "Hello!" |
| Docker CLI | Manual container testing or small one-off hosts | Bind-mount ~/.nanoinfra to /home/nanoinfra/.nanoinfra | docker run --rm -v ~/.nanoinfra:/home/nanoinfra/.nanoinfra ghcr.io/nanoinfraorg/nanoinfra:latest status |
| systemd user service | Linux user-level gateway that restarts automatically | Host user's ~/.nanoinfra unless you pass explicit paths | systemctl --user status nanoinfra-gateway |
| macOS LaunchAgent | macOS gateway that starts after login | Host user's ~/.nanoinfra unless the plist passes explicit paths | `launchctl list |
The Process Split
nanoinfra runs the agent in one process and three helpers beside it. The split decides what a compromised agent can reach. For the policy the executor applies, read capability-gates.md.
| Process | Entry point | What it holds | What it never holds |
|---|---|---|---|
| Agent | nanoinfra gateway | the model, the tools, the session store | a credential value, a transport to a host |
| Executor | python -m nanoinfra.gates.executor | the credential store, the four transports, the gate, the audit log | the web tools |
| Fetcher | python -m nanoinfra.gates.fetcher | web_fetch and web_search | a credential, a transport, the right to start a program |
| MCP host | python -m nanoinfra.gates.mcp_host | the stdio MCP servers your config names | a credential, a transport to a host |
Each helper listens on one Unix socket. The agent writes one request and reads one reply. A TCP socket would widen an egress policy to include the executor, so the transport is a Unix socket in every case.
The executor listens on two more sockets.
On the operator socket, only an operator answers, and the agent never does. See The Operator Socket.
On the scrub socket, the agent asks for text with the stored credential values removed. The scrub needs those values to find them, so it runs where they already live. Without this socket the agent would decrypt the whole credential store on every turn that persisted anything. The agent sends text and reads text back. It never receives a value.
One request carries many texts. A session file holds one record per message plus one item per tool call. A scrub per text would open hundreds of connections on a single save. The executor still reads the credential store once per request, and caches no result across two requests. So a secret you create during a turn is removed from that same turn.
Who Starts Each Process
| Runtime | Executor | Fetcher | MCP host |
|---|---|---|---|
| Docker image, started as root | the entrypoint, on its own account | the entrypoint, on its own account | the entrypoint, on its own account |
| Docker image, started as non-root | the gateway, on the agent's account | the gateway, on the agent's account | the gateway, on the agent's account |
pip install plus nanoinfra gateway | the gateway, on the agent's account | the gateway, on the agent's account | the gateway, on the agent's account |
| systemd user service or LaunchAgent | the gateway, on the agent's account | the gateway, on the agent's account | the gateway, on the agent's account |
| Python SDK | the SDK, on request | not started | not started |
Only a root start can place two processes on two accounts, so the container entrypoint is the supervisor for that image. A gateway start elsewhere still gives the executor a process of its own. That process alone keeps the credential store and the four transports out of the address space that runs the model.
The gateway starts the executor first of the three children, and it stops the executor last. The approvals inbox derives its own socket from the path that this start exports.
The executor start is unconditional, and the other two are not. The gateway starts a fetcher only when tools.web.enable is true. It starts an MCP host only when at least one configured MCP server is a stdio server.
Two facts make the executor different. execute_on_server is a registered tool in every install, so no config value names a switch. A server record can also appear at any moment after boot, with no gateway restart.
NANOINFRA_EXECUTOR_EXTERNAL stops the second start. A container that already placed the executor on its own account keeps that one, because a gateway child would hold the agent's UID instead.
A failed executor start reaches the log and the console, and both name the consequence:
gates: the executor did not start, so every gated action refuses until an executor
answers: <cause>
A deployment without an executor refuses every execute_on_server call as a deployment fault. See troubleshooting.md#capability-gate-problems.
Accounts and Groups in the Container
The image creates five accounts and five groups.
| Account | UID | Runs | Why it is separate |
|---|---|---|---|
nanoinfra | 1000 | the agent | it owns the data dir and the writable virtualenv |
nanoinfra-exec | 1001 | the executor | it holds the plaintext credentials and writes the audit log |
nanoinfra-fetch | 1002 | the fetcher | untrusted web content enters this account |
nanoinfra-mcp | 1003 | the MCP host | it starts the programs that your MCP config names |
nanoinfra-connector | 1004 | the connector host | it makes a marketplace connector package's HTTPS request, so the account holding the credential store does not |
Two processes under one UID get no separation from the kernel. Either one can trace the other and read its memory. So the account that reads a web page is never the account that decrypts a credential.
| Group | Members | Guards |
|---|---|---|
nanoinfra-ipc | nanoinfra, nanoinfra-exec | /run/nanoinfra-exec |
nanoinfra-fetch-ipc | nanoinfra, nanoinfra-fetch | /run/nanoinfra-fetch |
nanoinfra-mcp-ipc | nanoinfra, nanoinfra-mcp | /run/nanoinfra-mcp |
nanoinfra-connector-ipc | nanoinfra-exec, nanoinfra-connector | /run/nanoinfra-connector |
nanoinfra-op | nanoinfra | /run/nanoinfra-exec/operator |
nanoinfra-connector-ipc is the only group the agent is not in. A connector call
starts in the executor after the gate answered. So nothing in the process the model
steers has a reason to reach that socket.
Each pair needs its own group. A member of nanoinfra-ipc can traverse the executor's
socket directory and connect to its socket. The fetcher inside that group could
therefore run a command on every inventory host, and that is the one outcome this split
prevents. The agent belongs to four of the five groups. No helper belongs to another
helper's group.
Each socket directory carries mode 2710, and each socket file carries mode 660:
- The owner is the helper, so only the helper can create or replace the socket.
- The group has traverse rights alone. The agent reaches a known socket name, and it cannot list the directory or create anything in it.
- Other accounts get nothing.
- The setgid bit keeps the group across a rebind, so the agent keeps access after the helper restarts.
That last line was not true in a container until v2.2.1, and the way it failed is worth
knowing if you build your own image. chmod(2) turns off the setgid bit when the file's group is
not the caller's, unless the caller holds CAP_FSETID — and it returns success. The published
compose file drops all capabilities and adds six. FSETID is not among them. So the entrypoint's
chown to a helper group followed by chmod 2710 left every socket directory at 710, silently,
with each guard passing. Setting the mode before the chown keeps the bit and needs no
capability, because chown does not clear setgid on a directory. If you add capabilities to that
list, FSETID is not one you need.
The three socket directories sit under /run rather than under the agent's home. Write rights on a parent directory allow a rename of any entry inside it. A socket directory in the agent's home could therefore be moved aside, and the agent could present its own socket at the expected path. /run is root-owned.
The Operator Socket
The executor binds a second socket for the answers to a suspended approval. For the rule the answer must satisfy, read capability-gates.md#the-approval-path.
| Start | Default path |
|---|---|
| A gateway or SDK start | ~/.nanoinfra/run/operator/executor.op.sock |
| The container entrypoint | /run/nanoinfra-exec/operator/executor.op.sock |
The path always sits in a private subdirectory beside the execute socket, and the file name carries that socket's own name. NANOINFRA_OPERATOR_SOCKET overrides it.
On a single-account start the executor creates that directory at mode 700, so no other account reaches it. The container needs the group bit instead, and the entrypoint prepares the directory before the executor starts:
| Path | Owner and mode |
|---|---|
/run/nanoinfra-exec/operator | nanoinfra-exec:nanoinfra-op, 2710 |
/run/nanoinfra-exec/operator/executor.op.sock | nanoinfra-exec:nanoinfra-op, 660 |
The setgid bit on that directory is the only mechanism this socket has for its group. That is
why v2.2.1 mattered more here than for the other three. The executor sets the group itself on the
sockets it shares through nanoinfra-ipc, because it belongs to that group. It deliberately does
not belong to nanoinfra-op, so for this one it cannot. Root's chown after the bind is racy on a
restart: it can land on the previous run's socket file, which the executor then unlinks.
The entrypoint exports NANOINFRA_OPERATOR_SOCKET before the start, so the executor binds the path that root prepared.
The group is nanoinfra-op and never nanoinfra-ipc. The second group would hand the same reach to the fetcher and to the MCP host. Either helper could then approve an action that it asked for.
Only the agent account joins nanoinfra-op. No helper joins it.
An image built before that group exists keeps the directory at mode 700, and it says what the deployment loses:
[entrypoint] warning: no nanoinfra-op group, so no approval can be answered
[entrypoint] warning: an approve decision then waits and refuses
The residual risk, and it is deliberate. The approvals inbox answers on this socket from inside the gateway process, under the agent's account. The filesystem half of the split therefore protects nothing on that one path. Three facts remain:
- The answer still crosses a process boundary into the executor, and the executor owns the decision.
- The executor still matches the asserted actor against
gates.approversfrom git-reviewed config. - Inside the gateway process the import graph is the whole protection. A test walks that graph and fails when a tool module reaches the answer path.
A tool that runs arbitrary code in the gateway process defeats the import closure. The approver match is then the last rule that holds.
Paths the Executor Owns
| Path | Owner and mode | Purpose |
|---|---|---|
<workspace>/secrets | nanoinfra-exec:nanoinfra-ipc, 2750, records 640 | the credential store. The agent can list metadata and cannot write a record |
~/.nanoinfra/gates | nanoinfra-exec:nanoinfra-ipc, 2750, files 640 | the gate audit log |
~/.nanoinfra and $HOME | root:nanoinfra-ipc, 1775 | the sticky parents of the audit log |
The audit log is group-readable on purpose. The agent process rebuilds denial latches from it, and the WebUI viewer serves it. At mode 700 the agent could not open a segment, and every latch cleared on each boot. Write access stays with the executor.
The sticky bit on the two parent directories closes a rename. Only the owner of an entry, the owner of the directory, or root may rename an entry in a sticky directory. The agent keeps every entry it creates, and it cannot move the executor's one. The executor also pins the device and inode of its audit root, so a rename it cannot prevent stops the executor rather than the latches.
Per-Process Confinement
Each helper process starts under a Landlock ruleset. The kernel applies the rules to the child before the exec, and a ruleset survives an exec, so every program the child starts inherits them.
A sandbox complements the privilege split. It never replaces it. The split must hold after a sandbox layer fails, so no rule here weakens a boundary that the accounts and the sockets already keep. Landlock needs no root, no namespace, and no helper program, so it also works on a plain pip install host.
The agent keeps its own bubblewrap wrapper for local shell commands. This layer changes nothing about that.
What Each Policy Bounds
| Process | The policy bounds |
|---|---|
| Executor | the filesystem and the exec surface |
| Fetcher | outbound TCP ports, plus a filesystem policy that names no workspace path |
| MCP host | the exec surface, the write surface, and no workspace path at all |
Every role gets the same base:
- read access to the system directories, and to the interpreter's own paths.
- read access to a curated list of
/etcentries, rather than to the whole directory. - read access to
config.json. - write access to the temp directories.
- the run directory, with the rights a socket bind needs and no right to create a regular file there.
Each role then adds its own:
- The executor takes write access to the workspace, to the data directory that holds the audit log, and to its working directory. It takes exec rights on the system
bindirectories, because the Ansible backend runsansibleandssh. It reads/etc/ansibleand/etc/ssh. - The fetcher takes an outbound TCP allowlist of ports 53, 80, and 443, plus the port of a configured proxy. Port 53 covers the TCP fallback of a DNS resolver.
- The MCP host takes write and exec rights on the toolchain caches in the home directory, such as
.npm,.cache, and.bun.npx,uvx, andbunxeach write a cache and then run a program out of it.
No role may open a TCP listener. The executor gets no port allowlist on purpose. Contact with the inventory hosts is its whole purpose, so an allowlist there would equal the inventory and restrict nothing.
Two rights depend on the kernel. The TCP rules need Landlock ABI 4. The block on signals and abstract sockets outside the process tree needs ABI 6.
Loud Failure
A silent sandbox failure is worse than no sandbox. Five outcomes exist, and they differ on purpose:
| Case | Result |
|---|---|
| The kernel reports no Landlock support | the helper starts, and the log carries a warning |
| Landlock has no known syscall number on this architecture | the helper starts, and the log carries a warning |
| The kernel reports an ABI and then rejects the ruleset | the helper refuses to start |
| The run directory is absent | the helper refuses to start |
| An optional path is absent | that one rule drops out of the plan |
A kernel without Landlock support is a legitimate host, so a refusal there would make the release unusable. A container profile that predates Landlock answers the probe the same way. A dropped optional rule makes the policy tighter and never wider, so images can differ without a warning.
What the Startup Echo Prints
The gates: line at start ends with the confinement clause. A kernel that supports Landlock reports the ABI it offers, and <abi> below is that number:
confinement: landlock abi <abi> on each helper process, filesystem rules, a tcp port
allowlist for the fetcher, and no tcp listener
Below ABI 4 the same line drops the port clause and reads filesystem rules and no tcp listener. A kernel with no support states the absence just as plainly:
confinement: NOT APPLIED, this kernel reports no landlock support. The three helper
processes keep their own accounts and their own sockets
Each child also logs its own layer. A working layer names the rule count and the controls:
gates: executor confinement: landlock abi <abi>, <n> filesystem rules, no tcp
listener, no signal or abstract socket outside the process tree
A degraded layer names the reason and the boundary that still holds:
gates: fetcher confinement: NOT APPLIED, this kernel reports no landlock support.
The privilege split still holds, and it is the boundary that carries the weight.
In the container the entrypoint starts each helper through the confinement launcher, which reads a role and never a command. A rejected ruleset exits with status 78, and the retry loop stops at once:
[confinement] error: this helper refuses to start unconfined
[entrypoint] error: the executor refuses to start unconfined
[entrypoint] error: every gated action stays refused
Stated Limits
The module names these limits rather than hides them.
- The ELF loader holds EXECUTE. The kernel opens the dynamic loader with exec intent, so a rule on the interpreter alone denies the exec. A grant on the loader also lets the loader run any file it can read. The exec surface therefore bounds intent rather than an escape, and the write surface plus the split carry the weight.
- An
ansible-runnerprojectPathoutside the granted roots gets no write grant.ansible-runnerwrites its artifacts under that path, so a run against it fails. A read grant cannot fix that, and no config key widens the policy. - The MCP host cannot read the workspace. A stdio MCP server that must read files needs a directory outside the workspace. That cost buys the property that matters here: the process that starts a program cannot read the credentials the executor decrypts.
- A write grant carries a read grant. The temp directories therefore become readable to the helper. That is the stated cost of a working
tempfile, and the executor needs a private data directory for Ansible.
Helper Restarts
The entrypoint restarts a helper that exits, after five seconds. Five failed starts in a row stop the retries, and the log says so. A run that lasted a minute counts as a crash rather than a broken start, so it returns the full budget.
The entrypoint waits up to five seconds for each socket. It never blocks the agent, and it never goes quiet:
[entrypoint] warning: no executor socket at /run/nanoinfra-exec/executor.sock after 5s
[entrypoint] warning: gated actions will fail until the executor answers
Environment Variables
| Variable | Who sets it | Who reads it |
|---|---|---|
NANOINFRA_EXECUTOR_SOCKET | the container entrypoint and the gateway | execute_on_server, and the approvals inbox |
NANOINFRA_EXECUTOR_EXTERNAL | the container entrypoint | the gateway, which then starts no second executor |
NANOINFRA_EXECUTOR_USER | you | the gateway, as the account for its executor child |
NANOINFRA_FETCHER_SOCKET | the container entrypoint and the gateway | web_fetch and web_search |
NANOINFRA_FETCHER_EXTERNAL | the container entrypoint | the gateway, which then starts no second fetcher |
NANOINFRA_FETCHER_USER | you | the gateway, as the account for its fetcher child |
NANOINFRA_MCP_HOST_SOCKET | the container entrypoint and the gateway | the agent's MCP client |
NANOINFRA_MCP_HOST_EXTERNAL | the container entrypoint | the gateway, which then starts no second MCP host |
NANOINFRA_MCP_HOST_USER | you | the gateway, as the account for its MCP host child |
NANOINFRA_OPERATOR_SOCKET | the container entrypoint, or you | the executor, as the path for its operator socket |
NANOINFRA_WORKSPACE | you | the container entrypoint, to give the executor the agent's workspace |
Set NANOINFRA_WORKSPACE when only config.json names the workspace. A shell cannot read that file, so the entrypoint would otherwise hand the executor the default path. The entrypoint logs the value it resolved.
NANOINFRA_EXECUTOR_USER, NANOINFRA_FETCHER_USER, and NANOINFRA_MCP_HOST_USER name the account for a helper child. A gateway that runs as root honours them, and the log names the child's account and UID. A gateway that runs as an ordinary user cannot honour them:
No privilege to start the fetcher as 'nanoinfra-fetch': euid 1000 is not root. The
fetcher shares the supervisor's uid, so the split is organisational and not
enforced.
An account that does not exist fails the start rather than falls back, because a silent fallback would run the child under the wrong UID. The Python SDK takes an executor_user argument for the same purpose.
NANOINFRA_EXECUTOR_SOCKET names where the executor listens. The gateway exports it before it starts the child, and the container entrypoint exports the path under /run. Without the variable the client opens ~/.nanoinfra/run/executor.sock. The approvals inbox derives the operator socket from the same path unless NANOINFRA_OPERATOR_SOCKET names another one.
What a Non-Root or Single-Account Start Loses
The entrypoint states each missing property in the log. Never read silence as a guarantee.
A start that is not root cannot place two processes on two accounts:
[entrypoint] warning: this container did not start as root
[entrypoint] warning: the agent and the executor share one uid, so either one can
[entrypoint] warning: ptrace the other and read its memory. The #18 privilege
[entrypoint] warning: split is organisational here, not kernel-enforced.
Such a start still reaches an executor. The gateway starts one under the agent's account, and the log states that the kernel enforces no UID split there:
gates: the executor runs as a separate process under the gateway's own account. The
kernel enforces no uid split here. Set NANOINFRA_EXECUTOR_USER to get one.
The approvals inbox works on that start as well, because the executor binds its operator socket wherever it runs.
An image without the nanoinfra-exec account skips the entrypoint's executor and prints the same warning. An image without nanoinfra-fetch, nanoinfra-mcp or nanoinfra-connector runs that helper under a shared account, and the log names the property that the start lacks:
[entrypoint] warning: no separate nanoinfra-fetch account runs the fetcher
[entrypoint] warning: the fetcher shares the agent's uid, so either one can ptrace
[entrypoint] warning: the other and read its memory. The #19 split is a process
[entrypoint] warning: boundary here, and the kernel does not enforce it.
[entrypoint] warning: the fetcher still holds no credential store and no transport.
A systemd --user unit and a LaunchAgent both run as the account that installed them. Both processes hold one UID there, so the split is organisational on those two managers. A kernel-enforced split needs a system unit with one User= per process, or the container layout.
Residual Risks
The code states these limits, and each one is deliberate.
- The virtualenv stays writable by the agent. An enabled channel may install its declared dependencies at start, so
/app/.venvbelongs to the agent account. The executor imports code from that same virtualenv. The agent account can therefore place code that the executor account later runs. No UID split closes that path. A fully enforced split needs the executor on its own read-only interpreter. - The agent can remove its own workspace entries. The workspace belongs to the agent, so the agent can rename or delete
<workspace>/secrets. That act costs the executor its credentials, and it never reveals one. Confidentiality holds. Availability does not. - An operator with a shell can delete the audit log. root and a shell user can remove the segments. The act is visible, because the segments are then gone. The point of the layout is that the agent account cannot do it.
- A moved audit root stops the executor. The executor refuses to read or write when its audit root changes identity. A rename then costs availability instead of every latch the log holds.
- The approvals inbox answers from inside the gateway process. The agent account holds the operator socket. So the file mode protects nothing on that path. The process boundary, the approver match in config, and an import closure carry the weight instead. See The Operator Socket.
- The sandbox is a second layer and not the boundary. A kernel with no Landlock support runs every helper unconfined. The log says so. The accounts and the sockets still hold. See Per-Process Confinement.
- SSH host-key verification is disabled. See
secrets-and-servers.md.
Docker
[!TIP] The
-v ~/.nanoinfra:/home/nanoinfra/.nanoinfraflag mounts your local config directory into the container, so your config and workspace persist across container restarts. The container runs as the non-root usernanoinfra(UID 1000) and reads config from/home/nanoinfra/.nanoinfra. Always mount your host config directory to/home/nanoinfra/.nanoinfra, not/root/.nanoinfra. If you get Permission denied, fix ownership on the host first:sudo chown -R 1000:1000 ~/.nanoinfra, or pass--user $(id -u):$(id -g)to match your host UID. Podman users can use--userns=keep-idinstead.[!IMPORTANT] The official image is
ghcr.io/nanoinfraorg/nanoinfra, published from this repository'sDockerfileon every version tag. Tags arelatest, the minor (1.0) and the exact version (1.0.1), forlinux/amd64. Pin the exact version in production. Building theDockerfileyourself stays supported and is whatdocker composein the repository does. Docker Hub images under third-party namespaces are not maintained or verified by nanoinfraorg/nanoinfra. Do not mount API keys or bot tokens into them unless you trust the publisher.
[!IMPORTANT] The gateway and WebSocket channel default to
host: "127.0.0.1"inconfig.json(set innanoinfra/config/schema.py). Docker-pport forwarding cannot reach a container's loopback interface. For the host or LAN to reach the exposed ports, set both binds to0.0.0.0in~/.nanoinfra/config.jsonbefore you start the container. To serve the bundled WebUI from Docker, bind the WebSocket channel externally and protect bootstrap withtokenIssueSecret:{"gateway": { "host": "0.0.0.0" },"channels": {"websocket": {"host": "0.0.0.0","port": 8765,"tokenIssueSecret": "your-secret-here"}}}When the WebSocket
hostis0.0.0.0, the channel refuses to start unlesstoken,tokenIssueSecret, or a fully configuredtrustedProxyAuthis also configured. Seewebui.md#lan-accessfor details. The gateway health route itself is intentionally minimal and unauthenticated. When the container binds it to0.0.0.0, publish port18790to host loopback only. Place any remotely monitored health endpoint behind a firewall or reverse proxy. If another host must probe it directly, replace127.0.0.1in the port mapping with a trusted host interface. Then restrict inbound traffic to the monitoring system.
Identity in Front of the Gateway
An authenticating proxy in front of the gateway is what makes an audit record name a person rather than a shared token: Identity and SSO.
Docker Compose
The default image preinstalls WhatsApp dependencies. To bake other enabled
channels into an image (recommended for deployments without PyPI access), pass
a comma-separated NANOINFRA_CHANNELS build argument:
NANOINFRA_CHANNELS=telegram,slack docker compose build
The image keeps nanoinfra in a virtual environment owned by its built-in non-root
runtime user (UID 1000). If an enabled channel was not preinstalled, gateway
startup can therefore install its manifest-declared dependencies. Rebuilding
with NANOINFRA_CHANNELS keeps that installation reproducible instead of relying
on the container's writable layer. If you override the container with a
different --user, bake every enabled channel into the image. That UID is not
guaranteed write access to the virtual environment.
docker compose run --rm nanoinfra-cli onboard # first-time setup
vim ~/.nanoinfra/config.json # add API keys
docker compose up -d nanoinfra-gateway # start gateway
docker compose run --rm nanoinfra-cli agent -m "Hello!" # run CLI
docker compose logs -f nanoinfra-gateway # view logs
docker compose down # stop
The default Compose file drops all Linux capabilities and keeps Docker's default
AppArmor/seccomp profiles enabled. If you explicitly set
"tools.exec.sandbox": "bwrap" in ~/.nanoinfra/config.json, add the bwrap
override file when starting containers:
docker compose -f docker-compose.yml -f docker-compose.bwrap.yml up -d nanoinfra-gateway
docker compose -f docker-compose.yml -f docker-compose.bwrap.yml run --rm nanoinfra-cli agent -m "Hello!"
The override grants CAP_SYS_ADMIN and disables AppArmor/seccomp confinement for
the container so bubblewrap can create its nested namespaces. Use it only when the
bwrap sandbox is enabled.
Docker
# Pull the official image. Pin the exact version in production.
docker pull ghcr.io/nanoinfraorg/nanoinfra:1.0.1
# Or build it yourself, which is what you want if you need an extra or a
# channel's dependencies baked in rather than installed at first start.
docker build -t nanoinfra .
docker build --build-arg NANOINFRA_EXTRAS=bedrock -t nanoinfra .
docker build --build-arg NANOINFRA_CHANNELS=telegram,slack -t nanoinfra .
# Initialize config (first time only)
docker run --rm -it -v ~/.nanoinfra:/home/nanoinfra/.nanoinfra \
ghcr.io/nanoinfraorg/nanoinfra:latest onboard --wizard
# Edit config on host to add API keys
vim ~/.nanoinfra/config.json
# Run the gateway.
#
# `--cap-drop ALL` on its own is not hardening here, it is a downgrade. The
# entrypoint needs five capabilities to place the executor, the fetcher and the
# MCP host on their own accounts. Without them it does not refuse: it warns that
# "the agent and the executor share one uid, so either one can ptrace the other
# and read its memory", and the split becomes organisational rather than
# kernel-enforced. Drop everything, then add back exactly those five.
#
# A uid change clears the permitted set, so no process holds them once it runs.
docker run -d --name nanoinfra \
--cap-drop ALL \
--cap-add SETUID --cap-add SETGID --cap-add CHOWN --cap-add FOWNER --cap-add DAC_OVERRIDE \
-v ~/.nanoinfra:/home/nanoinfra/.nanoinfra \
-p 127.0.0.1:18790:18790 -p 127.0.0.1:8765:8765 \
ghcr.io/nanoinfraorg/nanoinfra:latest gateway
# If `tools.exec.sandbox: "bwrap"` is enabled, add the permissions bubblewrap
# needs for nested namespaces. Without them, `bwrap` may exit with
# `clone3: Operation not permitted`.
docker run -d --name nanoinfra \
--cap-drop ALL \
--cap-add SETUID --cap-add SETGID --cap-add CHOWN --cap-add FOWNER --cap-add DAC_OVERRIDE \
--cap-add SYS_ADMIN \
--security-opt apparmor=unconfined \
--security-opt seccomp=unconfined \
-v ~/.nanoinfra:/home/nanoinfra/.nanoinfra \
-p 127.0.0.1:18790:18790 -p 127.0.0.1:8765:8765 \
ghcr.io/nanoinfraorg/nanoinfra:latest gateway
# Or run a single command
docker run --rm -v ~/.nanoinfra:/home/nanoinfra/.nanoinfra \
ghcr.io/nanoinfraorg/nanoinfra:latest agent -m "Hello!"
docker run --rm -v ~/.nanoinfra:/home/nanoinfra/.nanoinfra \
ghcr.io/nanoinfraorg/nanoinfra:latest status
Publishing 8765 only reaches the channel if channels.websocket.host is "0.0.0.0" in the config, which the channel refuses without a token. The block above this one has the exact settings.
Linux Service
Run the gateway as a systemd user service so it starts automatically and restarts on failure.
Preview the generated unit first:
nanoinfra gateway install-service --manager systemd --dry-run
Install, enable, and start it:
nanoinfra gateway install-service --manager systemd
For a custom instance, pass the same config/workspace selector you use to run the gateway:
nanoinfra gateway install-service \
--manager systemd \
--name nanoinfra-telegram \
--config ~/.nanoinfra-telegram/config.json \
--workspace ~/.nanoinfra-telegram/workspace
Common operations:
systemctl --user status nanoinfra-gateway # check status
systemctl --user restart nanoinfra-gateway # restart after config changes
journalctl --user -u nanoinfra-gateway -f # follow logs
nanoinfra gateway uninstall-service --manager systemd
The installer writes ~/.config/systemd/user/nanoinfra-gateway.service, runs
systemctl --user daemon-reload, enables the unit, and restarts it. It uses the
current Python executable with python -m nanoinfra gateway --foreground, so the
service runs in the same environment you used to install nanoinfra.
Note: User services only run while you are logged in. To keep the gateway running after logout, enable lingering:
loginctl enable-linger $USER
macOS LaunchAgent
Use a LaunchAgent when you want nanoinfra gateway to stay online after you log in, without keeping a terminal open.
Preview the generated plist first:
nanoinfra gateway install-service --manager launchd --dry-run
Install, load, enable, and start it:
nanoinfra gateway install-service --manager launchd
For a custom instance:
nanoinfra gateway install-service \
--manager launchd \
--name nanoinfra-telegram \
--config ~/.nanoinfra-telegram/config.json \
--workspace ~/.nanoinfra-telegram/workspace
Common operations:
launchctl list | grep ai.nanoinfra.gateway
launchctl kickstart -k gui/$(id -u)/ai.nanoinfra.gateway
nanoinfra gateway uninstall-service --manager launchd
The installer writes ~/Library/LaunchAgents/ai.nanoinfra.gateway.plist, uses the
current Python executable with python -m nanoinfra gateway --foreground, and
writes LaunchAgent logs under ~/.nanoinfra/logs/.
Note: if startup fails with "address already in use", stop the manually started
nanoinfra gatewayprocess first.