Remote sandbox host (team box)

Run grain as a service on a shared machine; connect with the CLI over HTTP, SSH tunnels, or SDKs — with firewall and token rules.

grain is local-first, but a single beefy host can serve a team: one daemon, many sandboxes, developers connect over SSH, the remote CLI (GRAIN_API / --api), or SDKs. This guide covers a practical setup and the security controls you must not skip.

What this is (and is not)

This is This is not
A shared lab / agent / CI sandbox machine Multi-tenant hard isolation for untrusted customers
One control plane with resource caps and a token Per-user RBAC, quotas, or billing
Developers using SSH + CLI, remote CLI, or SDKs A drop-in Kubernetes cluster
Guest isolation via microVMs A substitute for your VPN, firewall, and OS patching

If you need true multi-tenant SaaS isolation, put grain behind stronger controls (or separate hosts per trust domain). See Security model.

Architecture

Developer laptop                         Shared host (Linux + KVM recommended)
┌──────────────────────────┐             ┌──────────────────────────────────┐
│ grain CLI                │── HTTPS ──►│ grain daemon (systemd)           │
│  GRAIN_API + GRAIN_TOKEN │             │   unix socket + TCP API          │
│ or curl / Go/TS/Python   │             │   QEMU microVMs                  │
│                          │── SSH ────►│   shell on host (ops / image pull)│
│ browser → :8080          │◄─ tunnel ──│   hostfwd 127.0.0.1 only          │
└──────────────────────────┘             └──────────────────────────────────┘

Behaviors to remember:

  1. Default CLI talks to a unix socket on the machine where it runs.
  2. Remote CLI (GRAIN_API or --api) talks HTTP to the daemon. Guest agent ops (exec, shell, cp, fs) are proxied through the API — you do not need hostfwd ports on your laptop.
  3. Published ports still bind to 127.0.0.1 on the grain host. Reach apps with SSH tunnels.
  4. Mounts (-v) are paths on the sandbox host, not the laptop.
  5. grain up / down / image / proxy / logs are local-host only — run them on the server (or over SSH).

Security model (read this first)

Control plane is powerful

Whoever can call authenticated POST /vms can create VMs, exec as root in guests (depending on image), and read secrets the host injects. Treat the API like root on a shared lab.

Hard rules grain enforces

Rule Behavior
Non-loopback bind without token Daemon refuses to start if api is not loopback and api_token is empty
Remote CLI to non-loopback CLI requires GRAIN_TOKEN / api_token
Unix socket mode Socket is 0600; still prefer token if untrusted local users share the host
GET /healthz Always unauthenticated (liveness only)

Firewall checklist (required for production-ish hosts)

Do not expose an unauthenticated or world-reachable grain API.

1. Prefer loopback + SSH tunnel (simplest, safest default)

# /etc/grain/config.yaml on the server
api: 127.0.0.1:7474
api_token: "long-random-secret"
# laptop
ssh -N -L 7474:127.0.0.1:7474 sandbox.example.com
export GRAIN_API=http://127.0.0.1:7474
export GRAIN_TOKEN=long-random-secret
grain ls

Host firewall: no inbound 7474 from the internet.

2. If you must bind a LAN or public interface

api: 0.0.0.0:7474          # or a private NIC IP
api_token: "long-random-secret"   # REQUIRED — daemon will not start without it

Then lock the port down:

# Example: nftables / ufw — allow only VPN / bastion CIDR
sudo ufw default deny incoming
sudo ufw allow OpenSSH
sudo ufw allow from 10.8.0.0/24 to any port 7474 proto tcp   # VPN clients only
sudo ufw enable

# Or iptables sketch:
# iptables -A INPUT -p tcp --dport 7474 -s 10.8.0.0/24 -j ACCEPT
# iptables -A INPUT -p tcp --dport 7474 -j DROP

Never leave 0.0.0.0:7474 open on a public cloud security group without:

3. TLS reverse proxy (company HTTPS)

Keep grain on loopback; terminate TLS on Caddy/nginx/Envoy:

grain.internal {
  reverse_proxy 127.0.0.1:7474
}
export GRAIN_API=https://grain.internal
export GRAIN_TOKEN=

Add mTLS or SSO at the proxy if you need per-user identity — grain’s token is a shared secret, not OAuth.

4. Other host surfaces

Surface Exposure Notes
SSH to host Admin / developers Prefer keys, no password, fail2ban optional
QEMU guest hostfwd 127.0.0.1 only Tunnel from laptop; do not republish as 0.0.0.0
Egress proxy :3128 Often 0.0.0.0 for SLIRP Firewall carefully — guests need it; the public internet should not
Metrics Often on API port Same auth as API when token set (except /healthz)

5. Token hygiene

openssl rand -hex 32   # generate
# store in your secret manager; rotate by updating config + restarting grain
# distribute to CI as GRAIN_TOKEN; never commit to git

1. Prepare the host

Supported hosts: Linux or macOS only (not Windows/WSL). Prefer Linux with KVM.

sudo apt-get update
sudo apt-get install -y qemu-system qemu-utils curl git

curl -fsSL https://raw.githubusercontent.com/cxdy/grain/main/scripts/install.sh | bash
grain doctor
grain image pull grain-ubuntu

Dedicated service user:

sudo useradd --system --home /var/lib/grain --shell /usr/sbin/nologin grain || true
sudo mkdir -p /var/lib/grain /etc/grain
sudo chown -R grain:grain /var/lib/grain
# KVM access on Linux:
# sudo usermod -aG kvm grain

2. Daemon config

/etc/grain/config.yaml example (loopback + token + caps):

data_dir: /var/lib/grain
socket: /var/lib/grain/grain.sock
api: 127.0.0.1:7474
api_token: "replace-with-long-random-secret"

image: auto
cpus: 2
memory_mb: 2048
disk_gb: 16
ready_timeout: 3m
log_level: info

max_vms: 16
max_cpus_total: 32
max_memory_mb_total: 65536
max_cpus_per_vm: 8
max_memory_mb_per_vm: 16384

profiles:
  agent:
    cpus: 4
    memory_mb: 8192
    disk_gb: 20
    image: grain-ubuntu
    mounts:
      - {host: /var/lib/grain/workspaces, guest: "/work"}

3. systemd

Example unit: deploy/systemd/grain.service.

sudo install -m 644 deploy/systemd/grain.service /etc/systemd/system/grain.service
sudo systemctl daemon-reload
sudo systemctl enable --now grain
sudo systemctl status grain

export GRAIN_TOKEN=# same as api_token
curl -sS -H "Authorization: Bearer $GRAIN_TOKEN" http://127.0.0.1:7474/healthz
curl -sS -H "Authorization: Bearer $GRAIN_TOKEN" http://127.0.0.1:7474/info

4. How developers connect

Pattern A — SSH + local CLI on the host

ssh sandbox.example.com
export GRAIN_TOKEN=# if set
grain --config /etc/grain/config.yaml ls
grain --config /etc/grain/config.yaml new --profile agent -n alice-dev
grain --config /etc/grain/config.yaml sh alice-dev

Use unique names ($USER-…). Run grain image pull only on the host.

# Terminal 1: tunnel API (when api: 127.0.0.1:7474 on server)
ssh -N -L 7474:127.0.0.1:7474 sandbox.example.com

# Terminal 2: laptop
export GRAIN_API=http://127.0.0.1:7474
export GRAIN_TOKEN=long-random-secret

grain ls
grain new -n alice-1 --wait agent
grain x alice-1 -- uname -a
grain sh alice-1                 # WebSocket via daemon proxy
grain cp ./script.sh alice-1:/tmp/script.sh
grain fs ls alice-1 /tmp
grain rm alice-1

Equivalents:

# flag instead of env
grain --api http://127.0.0.1:7474 ls

# config on laptop (~/.grain/config.yaml) — client-only knobs
# api_url: http://127.0.0.1:7474
# api_token: "…"   # or rely on GRAIN_TOKEN

Priority: --api flag > env GRAIN_API > config api_url.

Works remotely Local-only (run on host / SSH)
ls, new, rm, stop, start, … up, down
x, sh (agent), cp, fs, stats image ls/pull/import
fwd add/rm (metadata + host-side) proxy *, logs (serial files)
secrets inject / list (host store on server) doctor (host tools)

Pattern C — SDKs / curl

curl -sS -H "Authorization: Bearer $GRAIN_TOKEN" "$GRAIN_API/vms"
c, _ := client.DialHTTP(os.Getenv("GRAIN_API"), os.Getenv("GRAIN_TOKEN"))
new GrainClient({ baseURL: process.env.GRAIN_API!, token: process.env.GRAIN_TOKEN })
GrainClient.http(os.environ["GRAIN_API"], token=os.environ["GRAIN_TOKEN"])

5. Guest ports from a laptop

# on host / remote CLI: grain new -n web -P 8080:80 …
ssh -N -L 8080:127.0.0.1:8080 sandbox.example.com
open http://127.0.0.1:8080

6. Workspaces and mounts

sudo mkdir -p /var/lib/grain/workspaces/{alice,bob}
# clone on the host, then:
grain new --profile agent -n alice-1 -v /var/lib/grain/workspaces/alice:/work

Laptop paths like /Users/alice/src do not exist on the server.

7. Ops checklist

Item Recommendation
Auth Always set api_token on shared hosts
API bind Prefer 127.0.0.1 + SSH tunnel or TLS proxy
Firewall Deny 7474 from the public internet; restrict by VPN/bastion
Caps max_vms, max_cpus_total, max_memory_mb_total
Images Pre-pull golden on the host
Logs journalctl -u grain -f; serial logs only on host
Upgrades Drain / restart unit; re-pull golden when agent changes
Trust domain One token ≈ one team; split hosts for hostile tenants

8. Limitations

See also