# Secrets in boxd machines

[boxd](https://docs.boxd.sh/?ref=seekrit) runs **machines**: cloud VMs with an
Ubuntu userland, and Docker, Node, Python, Claude Code and Codex preinstalled.
A machine keeps its disk, and can be hibernated, woken, forked and snapshotted
with its running processes intact.

That last part is what matters for secrets. A machine is not a throwaway
container: whatever you put on its disk or into a running process's environment
stays there across hibernation, and goes along into forks and snapshots. So the
usual advice for agent workloads applies with extra weight: prefer [keeping the
credential outside](/docs/guides/sandboxes) to putting it in.

## What persists

| | What carries | What it means |
| --- | --- | --- |
| **Disk** | Survives stop and hibernate, and is copied into every [fork](https://docs.boxd.sh/guides/fork?ref=seekrit) and [snapshot](https://docs.boxd.sh/guides/snapshots?ref=seekrit) | A `.env` or token file written inside a machine comes back with every machine made from it |
| **Memory** | A hibernated machine wakes with the same processes, and a fork is a live copy of memory too | A value in a running process's environment is still there after wake, and is in every live fork |
| **Machine spec** | A machine's [spec](https://docs.boxd.sh/guides/machine-specs?ref=seekrit) `env` is stored with the machine and returned by `machines.get()` | Fine for config and for `{{seekrit:NAME}}` placeholders. Not for credentials |

So:

- **Short-lived machine you drive yourself:** resolve on your side and pass
  values per command. Nothing is written to the machine's disk.
- **Long-lived machine running your own code:** give it one scoped `skt_` token
  and resolve at process start with `seekrit-run`.
- **Machine running an agent or code you don't trust:** keep the credential
  outside. The agent holds placeholders, and `seekrit-proxy` runs on a separate
  broker machine the agent can reach and nothing else.

## Inject per command

Resolve on your machine and pass only the names the command needs. The machine
never holds a seekrit token.

```python
import os

import seekrit
from boxd import Boxd

# 1. Resolve and decrypt on your side, with your token.
secrets = seekrit.Client(token=os.environ["SEEKRIT_TOKEN"]).resolve()

# 2. Hand the machine the names this command needs, and nothing else.
env = {k: secrets[k] for k in ("OPENAI_API_KEY", "TAVILY_API_KEY")}

# 3. Run the command with them. They live in that process only.
boxd = Boxd()  # BOXD_API_KEY from the environment
result = boxd.machines.exec(os.environ["MACHINE_ID"], ["python3", "job.py"], env=env, cwd="/home/boxd/app")
print(result.exit_code, result.stdout)
```

```ts
import { Boxd } from "@boxd-sh/sdk";
import { Seekrit } from "@seekrit/sdk";

const secrets = await new Seekrit({ token: process.env.SEEKRIT_TOKEN }).resolve();

const boxd = new Boxd(); // BOXD_API_KEY from the environment
const result = await boxd.machines.exec(process.env.MACHINE_ID!, {
  command: ["python3", "job.py"],
  cwd: "/home/boxd/app",
  env: {
    OPENAI_API_KEY: secrets.OPENAI_API_KEY,
    TAVILY_API_KEY: secrets.TAVILY_API_KEY,
  },
});
console.log(result.exitCode, result.stdout);
```

Named keys, not the whole dict — see [Pick names, not the whole
environment](/docs/guides/sandboxes#pick-names-not-the-whole-environment).

Create the machine with `isolated` when the command is anything but your own
code. An isolated machine has no in-machine boxd CLI, no integration tokens and
no path to your other machines:

```python
machine = boxd.machines.create("job-1", isolated=True)
```

### How `env` reaches the command

The SDKs apply `env` as `NAME=value` assignments in front of the command line.
Two things follow:

- **One command, not a script line.** In `cd app && python3 job.py`, only `cd`
  gets the values. Use `cwd` and a single command, as above, or wrap the line
  yourself: `["sh", "-c", "cd app && python3 job.py"]`.
- **`$NAME` in the same line is empty.** The shell expands it before the
  assignment applies. Read the variable inside the program, or inside `sh -c`.

A value passed this way is not written to the machine's disk, and the next
`exec` without `env` does not see it. A process you leave running keeps it in
memory, through hibernation and into forks.

## Resolve inside the machine with `seekrit-run`

For a machine that lives on and runs your own code, give it one service token
scoped to one environment, and let each process resolve at its start. A
rotation then lands on the next command, with no new machine.

```python
import os

from boxd import Boxd

boxd = Boxd()
machine_id = os.environ["MACHINE_ID"]
token = os.environ["MACHINE_SEEKRIT_TOKEN"]  # one skt_ token minted for this machine

# Install the launcher into /usr/local/bin.
boxd.machines.exec(machine_id, "curl -fsSL https://run.seekrit.dev/install.sh | sudo sh")

# The token goes in as a file, never on a command line. seekrit-run reads .env.
boxd.machines.exec(machine_id, "install -d -m 700 /home/boxd/app")
boxd.machines.files.upload(machine_id, "/home/boxd/app/.env", f"SEEKRIT_TOKEN={token}\n")
boxd.machines.exec(machine_id, "chmod 600 /home/boxd/app/.env")

# Every command resolves at its own start, so a rotation lands on the next one.
result = boxd.machines.exec(machine_id, ["seekrit-run", "--cache", "--", "python3", "job.py"], cwd="/home/boxd/app")
print(result.exit_code, result.stdout)
```

[`seekrit-run`](/docs/guides/run) fetches ciphertext, decrypts it inside the
machine, and `exec`s the command with the values set. Mint the token per
machine, so revoking it at teardown is a complete cutoff:

```bash
# The token goes to stdout on its own (the confirmation goes to stderr),
# so it can be captured without being echoed.
skt="$(seekrit token create --name boxd-$(date +%s) --app storefront --env production)"
```

Keep three things in mind:

- **The token file is on disk.** It survives hibernation and goes into every
  fork and snapshot of this machine. Don't snapshot a machine you will use as a
  template while it holds a token. Create from the template, then upload one.
- **The command sees `SEEKRIT_TOKEN` too**, because `seekrit-run` loads `.env`
  into its environment. That is why the token should resolve one environment
  and nothing more.
- **Use `--cache`.** Without it, `seekrit-run` starts the command *without* its
  secrets when the API is unreachable. With it, the command gets the last
  values it saw.

> **Warning:** **Do not put an [admin token](/docs/guides/service-tokens) on a machine.** It is org-scoped and can mint more tokens. Mint one `skt_` per machine, scoped to one application and environment. `seekrit token list` shows last-used time, which is how a machine you forgot to delete becomes visible.

## Keep the credential outside the machine

For a machine that runs an agent, the credential should not be in it at all.
Run [`seekrit-proxy`](/docs/guides/agent-proxy) on a separate **broker**
machine, and let the agent machine reach that broker and nothing else:

```text
agent machine (isolated, egress: none)
  OPENAI_BASE_URL=http://seekrit-broker.boxd:7070/openai/v1
  OPENAI_API_KEY={{seekrit:OPENAI_API_KEY}}
        │  private network "seekrit"
        ▼
broker machine: seekrit-proxy, holds SEEKRIT_TOKEN
        │  real key added on the way out, only to api.openai.com /v1/**
        ▼
api.openai.com
```

boxd gives you the two pieces that make this enforcement and not just a
convention:

- An **egress allowlist** of `none` cuts the agent machine off from the
  internet. It is set from outside the machine, so root inside cannot change
  it. Your org's private network is not the internet, so the agent still
  reaches machines it shares a network with.
- A **named network** decides which machines those are. An isolated machine
  reaches exactly the non-isolated machines that share a network with it. Put
  the broker and the agents on one, and an agent can reach the broker but none
  of your other machines.

That is what sets boxd apart from [Boat](/docs/guides/sandboxes/boat), where
`HTTPS_PROXY` is a convention an agent can ignore: here, routing around the
broker leaves the agent with nowhere to go.

### Create the two machines

```python
from boxd import Boxd, Machine

boxd = Boxd()

broker = boxd.machines.create("seekrit-broker", networks=["seekrit"])
boxd.machines.set_auto_hibernate_timeout(broker.id, 0)
boxd.machines.set_auto_suspend_timeout(broker.id, 0)

agent = boxd.machines.create(
    "agent-1",
    spec=Machine.spec()
    .isolated()
    .network("seekrit")
    .egress("none")
    .env({
        "OPENAI_BASE_URL": "http://seekrit-broker.boxd:7070/openai/v1",
        "OPENAI_API_KEY": "{{seekrit:OPENAI_API_KEY}}",
    }),
)
```

The agent's spec holds the placeholder, not the key, so it is fine that the
spec is readable. Keep the broker awake: a hibernated machine does not wake for
traffic from another machine on the private network, so the agent's calls
would time out until something woke it.

### Run the proxy on the broker

`seekrit proxy init` writes the config from a preset. It holds secret *names*
and no values. Listen on the private interface, on a port outside the ones the
machine's public HTTPS URL detects (80, 443, 8080, 8000, 3000, 5000, 5173):

```bash
curl -fsSL https://proxy.seekrit.dev/install.sh | sudo sh

sudo install -d -m 755 /etc/seekrit-proxy
work="$(mktemp -d)"
(cd "$work" && npx -y @seekrit/cli proxy init --preset openai --listen 0.0.0.0:7070)
sudo install -m 644 "$work/seekrit-proxy.toml" /etc/seekrit-proxy/seekrit-proxy.toml
rm -r "$work"
```

> **Warning:** **Never let the machine's HTTPS URL reach the proxy.** `seekrit-proxy` has no inbound authentication. On a port the URL detects (8080 is one), or behind a [named proxy](https://docs.boxd.sh/guides/proxies?ref=seekrit), anyone on the internet who sends a `{{seekrit:NAME}}` placeholder gets the real key used for them. 7070 is not detected.

Put the token in a root-only file and run the proxy as a service, so it comes
back after a reboot:

```bash
# /etc/seekrit-proxy/env, mode 600, owner root
SEEKRIT_TOKEN=skt_...
```

```ini
# /etc/systemd/system/seekrit-proxy.service
[Unit]
Description=seekrit-proxy (credential broker for agent machines)
After=network-online.target
Wants=network-online.target

[Service]
EnvironmentFile=/etc/seekrit-proxy/env
ExecStart=/usr/local/bin/seekrit-proxy --config /etc/seekrit-proxy/seekrit-proxy.toml
DynamicUser=yes
Restart=always
RestartSec=2

[Install]
WantedBy=multi-user.target
```

```bash
sudo systemctl daemon-reload
sudo systemctl enable seekrit-proxy
sudo systemctl restart seekrit-proxy
```

Upload the env file with `boxd.machines.files.upload` rather than writing it
with an `exec` command, so the token never sits on a command line. systemd
reads it as root before dropping to the unit's dynamic user, so the proxy
process gets the token without the file ever being readable by it.

### What the agent sees

Inside the agent machine, the OpenAI SDK, `curl` or any client that honours a
base URL works unchanged:

```bash
curl -s "$OPENAI_BASE_URL/models" -H "Authorization: Bearer $OPENAI_API_KEY"
```

The request reaches OpenAI with the real key. A request straight to
`api.openai.com` fails to resolve, because the allowlist refuses the lookup. A
placeholder for a secret the route doesn't allow, or a path outside `/v1/**`,
gets a `403` from the proxy, and the proxy
[redacts](/docs/guides/agent-proxy#the-response-on-the-way-back) an echoed key
out of the response. The agent's environment, its disk and every fork or
snapshot of it hold the placeholder and nothing else.

### Harnesses that ignore base URLs

Generate the config in [forward mode](/docs/guides/agent-proxy#forward-proxy-transparent-via-https_proxy)
instead, and set `HTTPS_PROXY` and the proxy's CA certificate on the agent:

```bash
npx -y @seekrit/cli proxy init --mode forward --preset openai \
  --forward-listen 0.0.0.0:7071 --unmatched deny \
  --ca-cert /var/lib/seekrit-proxy/ca.pem --ca-key /var/lib/seekrit-proxy/ca-key.pem
```

Two flags here are not optional on boxd:

- **`--unmatched deny`.** The default, `tunnel`, relays a host with no rule
  straight through, untouched. On a broker that would hand an `egress: none`
  agent the whole internet. With `deny`, the agent reaches the hosts you wrote
  a rule for and nothing else.
- **A CA path the service can write.** Forward mode generates its CA on first
  start, and `DynamicUser=yes` leaves the unit nowhere to write. Add
  `StateDirectory=seekrit-proxy` to `[Service]`, which gives it
  `/var/lib/seekrit-proxy`, and copy only the **certificate** to the agent. The
  key stays on the broker.

### For a fleet

One broker serves every agent on its network. Give each tenant its own network
and broker when tenants must not share a proxy, and use [agent
identities](/docs/guides/agent-proxy/policy) so the rules are signed in the
dashboard rather than copied into each config.

## boxd's own secrets

boxd also has [env vars and secrets](https://docs.boxd.sh/guides/env-secrets?ref=seekrit)
of its own: set once for an org, sealed at rest, never returned by the API, and
injected into the machines in scope. A secret can be bound to hosts, and then
the machine holds only a `bxds_` placeholder that boxd swaps for the value on
HTTPS requests to those hosts.

Using it means keeping a second copy of the credential, in boxd:

| | boxd secrets | Resolve with seekrit |
| --- | --- | --- |
| **Where the value sits** | boxd's store, sealed, plus every machine in scope (or nowhere inside, when host-bound) | Your process, for the length of one call, or the broker |
| **A rotation reaches** | New shells and `exec` sessions, after you set it again in boxd | The next command or proxy restart, from seekrit |
| **Two stores to rotate** | Yes | No |

Use seekrit for credentials and keep boxd's env vars for what isn't one.

## boxd's own API key

`BOXD_API_KEY` is a credential too. Keep it in seekrit and give it to the
process that creates machines, not to the machines:

```bash
seekrit run --app platform --env production -- python provision.py
```

boxd API keys are fenced to one org. An isolated machine has no boxd
credential inside it at all.

## Teardown

Delete the machine and revoke its token in the same step:

```python
boxd.machines.delete(machine.id)
```

```bash
seekrit token revoke skt_XXXXXXXX
```

Deleting a machine removes its disk. Snapshots you took of it stay until you
delete them, with whatever was on its disk and in its memory at the time.

## Checklist

- [ ] Agent and untrusted workloads on `isolated` machines
- [ ] Values resolved on your side and passed as named `env` entries, one command each
- [ ] No credential in a machine spec's `env`; placeholders only
- [ ] Long-lived machine: one scoped `skt_` token in a `600` file, `seekrit-run --cache`, never an admin token
- [ ] No token on a machine you will snapshot as a template
- [ ] Agent machine: egress `none`, a named network shared only with the broker
- [ ] Broker: auto-hibernate off, proxy on a port the public URL does not detect, no named proxy for it
- [ ] Forward mode: `--unmatched deny`, so the broker is not a way out
- [ ] Teardown deletes the machine and revokes its token together

## See also

- [Agent sandboxes](/docs/guides/sandboxes) — the two shapes and when each applies
- [Fly.io Sprites](/docs/guides/sandboxes/sprites) and [Boat](/docs/guides/sandboxes/boat) — the other persistent sandboxes
- [`seekrit-run` launcher](/docs/guides/run) — flags, caching, and degradation behaviour
- [Credential broker](/docs/guides/agent-proxy) — full proxy configuration
- [Agent access policy](/docs/guides/agent-proxy/policy) — which hosts and operations an agent may reach
- [Service tokens](/docs/guides/service-tokens) — scoping what a token can read
- [boxd sandboxes](https://docs.boxd.sh/use-cases/sandboxes?ref=seekrit) — isolation, networks and egress on boxd
