seekrit
Docs/boxd

Secrets in boxd machines

boxd 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 to putting it in.

What persists

What carriesWhat it means
DiskSurvives stop and hibernate, and is copied into every fork and snapshotA .env or token file written inside a machine comes back with every machine made from it
MemoryA hibernated machine wakes with the same processes, and a fork is a live copy of memory tooA value in a running process's environment is still there after wake, and is in every live fork
Machine specA machine's spec 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.

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)
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.

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:

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.

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 fetches ciphertext, decrypts it inside the machine, and execs the command with the values set. Mint the token per machine, so revoking it at teardown is a complete cutoff:

# 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.
caution

Do not put an admin token 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 on a separate broker machine, and let the agent machine reach that broker and nothing else:

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, 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

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):

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"
caution

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, 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:

# /etc/seekrit-proxy/env, mode 600, owner root
SEEKRIT_TOKEN=skt_...
# /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
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:

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 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 instead, and set HTTPS_PROXY and the proxy's CA certificate on the agent:

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 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 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 secretsResolve with seekrit
Where the value sitsboxd'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 reachesNew shells and exec sessions, after you set it again in boxdThe next command or proxy restart, from seekrit
Two stores to rotateYesNo

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:

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:

boxd.machines.delete(machine.id)
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