# Secrets in Fly.io Sprites

[Sprites](https://sprites.dev) are Fly.io's persistent sandboxes: a hardware-isolated
Firecracker microVM with a full Ubuntu userland and a 100 GB ext4 filesystem that
**survives hibernation**. It sleeps when idle, wakes in 100–500 ms on the next
request, and everything you installed is still there.

That persistence is the whole point of the product, and it is also what makes
the secrets question different here than on every other page in this section.
E2B, Modal, Daytona and Vercel Sandbox forget. A Sprite remembers — including
whatever credential you left in it.

## What persistence changes

Three things stop being true when the sandbox has a disk:

| | Ephemeral sandbox | Sprite |
| --- | --- | --- |
| **A value written to disk** | Dies with the container | Stays until you delete it, across every wake |
| **Rotation** | Lands on the next sandbox you create | Never lands — the Sprite you provisioned three weeks ago still holds the old value |
| **Snapshots** | None | [Checkpoints](https://docs.sprites.dev/concepts/checkpoints) capture the whole filesystem, credentials included |

The last one is the one that surprises people, so it gets [its own
section](#checkpoints-are-backups-of-whatever-you-left-lying-around). The short
version: restoring a checkpoint taken before a rotation puts the retired
credential back on the machine, and it still works unless you revoked it.

The fix for all three is the same and it is the rule this page is built on:
**a Sprite should hold one credential — a scoped seekrit service token — and
resolve everything else at process start.**

## Which shape to use

| | Inject at `exec` | Resolve at service start | Broker with a proxy |
| --- | --- | --- | --- |
| **What the Sprite holds** | The values, for one command | One scoped `skt_` token | One scoped `skt_` token, in a process the workload doesn't run |
| **Where the values live** | That process's environment | That process's environment | Only inside the proxy, and in the outbound request |
| **Rotation lands** | Next `exec` | Next cold boot or restart | Next proxy restart |
| **In a checkpoint** | Nothing, unless you snapshot mid-command | The token | The token |
| **Use when** | One-off commands from your machine | A service you wrote — a dev server, a build, a training run | The Sprite runs an agent or model-generated code |

Sprites' headline use case is running code you did not write, which means the
third column is the common case, not the exotic one. Start there if the thing in
the Sprite is a coding agent.

## Inject at `exec`

For one-off commands driven from your machine, resolve on the host and pass the
names you meant. Nothing touches the Sprite's disk:

```bash
export SEEKRIT_TOKEN=skt_…            # your token, on your machine

sprite exec --env "DATABASE_URL=$(seekrit secrets get DATABASE_URL --app storefront --env production)" \
  -- ./scripts/migrate.sh
```

Or the whole environment, for a command you trust, without writing a file:

```bash
seekrit run --app storefront --env production -- \
  sh -c 'sprite exec --env "DATABASE_URL=$DATABASE_URL,STRIPE_KEY=$STRIPE_SECRET_KEY" -- ./migrate.sh'
```

This is the [standard sandbox shape](/docs/guides/sandboxes#the-shape-that-is-the-same-everywhere):
your process holds the token, the Sprite gets values and no ability to ask for
more. It is also the shape that does not survive — the values are gone when the
command exits, which is exactly right for a migration and useless for a server.

> **Note:** `--env` values land in the command's environment, readable at `/proc/<pid>/environ` by anything else running in the Sprite. That is fine when the Sprite is yours and busy with one job; it is not a boundary against another process in the same VM.

## Resolve at service start

A [Service](https://docs.sprites.dev/concepts/services) is the only thing on a
Sprite that reliably comes back: the runtime starts it at boot, restarts it when
it crashes, and brings it up in dependency order after a cold wake. Which makes
the service definition the natural place to put credentials — and the wrong one.

`sprite-env services create … --env "STRIPE_KEY=sk_live_…"` stores that value in
the service definition, where it is replayed on every wake, printed by
`sprite-env services get`, and captured by the next checkpoint. It is a `.env`
file with extra steps.

Make the service command resolve instead:

```bash
sprite-env services create api \
  --cmd seekrit-run \
  --args "--env-file,/etc/seekrit.env,--cache,--,node,server.js" \
  --dir /home/sprite/app \
  --http-port 3000
```

[`seekrit-run`](/docs/guides/run) authenticates with the token, fetches the
environment's **ciphertext**, decrypts it inside the Sprite, and `exec`s your
command with the values set. Three things follow from putting it in the service
command rather than in the service's `--env`:

- **Values never touch the disk.** They exist in one process's memory, and that
  process is replaced by `exec` — no wrapper holding a copy.
- **Rotation lands on the next start.** A cold boot restarts every service, so a
  Sprite that slept long enough to go cold picks up whatever is current when it
  comes back — and `sprite-env services restart api` forces it. You never have to
  find and re-provision the Sprite itself. (A *warm* wake resumes the process
  rather than restarting it, so a warm-only Sprite keeps what it resolved until
  something restarts the service.)
- **A checkpoint captures the token, not the values.** Still a credential, but
  one you can scope and revoke — see the [checkpoint
  section](#checkpoints-are-backups-of-whatever-you-left-lying-around).

### Bootstrapping the token

Write it from outside, once, with the [filesystem
API](https://docs.sprites.dev/api/v001-rc48/filesystem) — never by echoing it
into a command, which puts it in the exec session's argv:

```bash
seekrit token create --name sprite-api-42 --app storefront --env production
# prints:  skt_XXXXXXXX_…    (shown once)

curl -X PUT "https://api.sprites.dev/v1/sprites/api-42/fs/write?path=/etc/seekrit.env&workingDir=/" \
  -H "Authorization: Bearer $SPRITE_TOKEN" \
  --data-binary 'SEEKRIT_TOKEN=skt_XXXXXXXX_…'

sprite exec -s api-42 -- chmod 600 /etc/seekrit.env
```

And install the launcher into the Sprite once — it persists, so this is setup,
not a boot step:

```bash
sprite exec -s api-42 -- sh -c 'curl -fsSL https://run.seekrit.dev/install.sh | sh'
```

A service with `--http-port` also takes the Sprite's URL, which is private to
your Fly.io org by default and can be flipped to public with
`sprite config update --url-auth public`. Flip it deliberately: a debug route
that dumps `process.env` is harmless behind org auth and a published credential
without it. Fly.io's own docs put it plainly — don't serve secrets, environment
variables, or unrestricted filesystem access from a Sprite's HTTP service.

> **Warning:** **One token, one Sprite, one environment.** Mint it per Sprite so `seekrit token revoke` at `sprite destroy` is a complete cutoff, and never put an [admin token](/docs/guides/service-tokens) on a Sprite — it is org-scoped and can mint more tokens. `seekrit token list` shows last-used time, so a Sprite you forgot to tear down is visible rather than silent.

### Let seekrit through the egress policy

If you have tightened the Sprite's [network
policy](https://docs.sprites.dev/concepts/networking), the DNS allowlist is
default-deny once it exists, and a blocked lookup returns `REFUSED`. `seekrit-run`
degrades gracefully rather than failing loudly, so a missing rule shows up as an
app that starts without its credentials:

```json
{
  "rules": [
    { "include": "defaults" },
    { "domain": "api.seekrit.dev", "action": "allow" }
  ]
}
```

The policy is read-only from inside the Sprite (`/.sprite/policy/network.json`),
which is what makes it worth pairing with the broker shape below: an agent cannot
widen its own egress.

> **Tip:** Keep `--cache` on for anything long-running. A Sprite wakes on a request and starts its services immediately; if that wake races a transient network problem, `--cache` falls back to the last **encrypted** response it saw instead of starting your server uncredentialed. A refused resolve (`401`/`403`) deletes the entry, so revoking the token still takes effect.

## Broker for agent workloads

When the Sprite is running a coding agent, an eval harness, or anything built
from model output, injection is the wrong shape for the usual reason: a process
that can read `os.environ` can exfiltrate what it finds there. Run
[`seekrit-proxy`](/docs/guides/agent-proxy) as its own service and give the agent
placeholders.

`seekrit-proxy` reads its token from `SEEKRIT_TOKEN`, and a service's `--env` is
the wrong place for it — a service definition is readable from inside the Sprite
with `sprite-env services get`, which is exactly the process you are trying to
keep the credential away from. Put the token-loading in a wrapper script instead:

```bash
#!/bin/bash
# /usr/local/bin/start-proxy — written once with the filesystem API, chmod +x.
set -a; . /etc/seekrit.env; set +a
exec seekrit-proxy --config /etc/seekrit-proxy.toml
```

```bash
# The proxy comes up first, on every cold boot.
sprite-env services create seekrit-proxy --cmd /usr/local/bin/start-proxy

# The agent starts after it, and holds no key.
sprite-env services create agent \
  --cmd python3 --args "agent.py" \
  --dir /home/sprite/agent \
  --needs seekrit-proxy \
  --env "OPENAI_BASE_URL=http://127.0.0.1:8080/openai/v1,OPENAI_API_KEY={{seekrit:OPENAI_API_KEY}}"
```

`--needs` is doing real work here: on a cold boot the runtime starts services in
dependency order, so the agent never comes up against a proxy that is not
listening yet.

> **Note:** `--args` splits on commas, so a shell one-liner cannot go there — it would be torn into fragments at every comma. A wrapper script sidesteps that; the [Services REST API](https://docs.sprites.dev/api/v001-rc48/services) takes `args` as a real JSON array if you would rather keep it in your provisioning code.

Generate the config rather than writing it by hand — it contains secret *names*
and no values, so it is safe to commit and safe to `fs/write` onto the Sprite:

```bash
seekrit proxy init --preset openai --preset anthropic
```

Then tighten it with [`methods` and `paths`](/docs/guides/agent-proxy#bound-what-the-agent-may-do-not-just-which-key-it-may-use),
so a legitimate credential still cannot reach an operation you did not grant.

### Compose it with the egress policy

The two mechanisms answer different questions and they stack:

| | Sprites network policy | seekrit-proxy allowlist |
| --- | --- | --- |
| **Decides** | Which hosts the Sprite may reach at all | Which secret may be injected toward which host, and for which methods and paths |
| **Enforced by** | DNS, outside the Sprite, read-only from inside | The proxy process |
| **Bypassable from inside** | No | The agent can skip the proxy — and then reaches an allowed host with no credential |

An agent that routes around the proxy gets an unauthenticated request. An agent
that tries a host the policy does not allow gets `REFUSED`. Set the network
policy to the upstreams your proxy fronts plus `api.seekrit.dev`, and the two
together mean the only way out is through a rule you wrote.

> **Warning:** **This is a weaker boundary than Cloudflare's outbound handlers, and the reason is the VM.** On [Cloudflare Sandbox](/docs/guides/sandboxes/cloudflare-sandbox) the credential lives in the Worker, in a different process on a different machine from the container. Here the proxy is a process inside the same Sprite, and a workload with root can read another process's memory. What it does buy, and it is most of the value: the agent's own environment, logs, crash dumps, and every filesystem checkpoint hold a placeholder rather than a key. It defeats prompt injection, a leaked transcript, and casual exfiltration. It does not defeat a determined attacker who already has root in the VM — for that, keep the credential off the Sprite entirely and put the proxy on a host you control.

### Hold the Sprite up while the agent works

A service does not keep a Sprite awake; only traffic and the [Tasks
API](https://docs.sprites.dev/keeping-sprites-running) do. An agent mid-turn on a
Sprite that pauses loses its outbound connections. Register a task while it works
and heartbeat it — the same thing Fly.io's own Managed Agents example does:

```bash
sprite-env curl -X POST /v1/tasks -d '{"name": "agent", "expire": "5m"}'
# … refresh every 60s while the agent runs, DELETE on exit
```

The short expiry is the safety net: an agent that crashes without cleaning up
leaves a task that expires on its own, and the Sprite pauses rather than billing
forever.

## Checkpoints are backups of whatever you left lying around

A [checkpoint](https://docs.sprites.dev/concepts/checkpoints) captures the entire
filesystem — every file, every permission, every installed package — so you can
roll back a bad upgrade. It does not know which of those bytes are a credential.

That produces a failure mode with no analogue on an ephemeral sandbox:

1. You put `STRIPE_SECRET_KEY` in a file on the Sprite in March.
2. You checkpoint before an OS upgrade.
3. You rotate the key in April, updating the file.
4. In May something breaks and you `sprite restore` the March checkpoint.
5. The March key is back on a running machine, and it works — because rotating a
   secret in seekrit changes what the *next* resolve returns. It does not reach
   into a filesystem snapshot, and it does not invalidate the old value at the
   provider.

Three rules follow:

- **Don't put values on the disk.** Resolve at start, as above, and a restore
  brings back a launcher rather than a credential.
- **Revoke at the provider when you rotate**, not just in seekrit. That is true
  everywhere; checkpoints just make the gap between "replaced" and "revoked"
  long enough to matter. See [rotation](/docs/concepts/rotation).
- **Treat the bootstrap token as checkpointed.** It is on the disk by design, so
  a restore resurrects it. Scope it to one environment, mint it per Sprite, and
  revoke it when the Sprite is destroyed — a resurrected token that was revoked
  in April fails closed.

> **Note:** Checkpoints capture in-memory state too, so `/dev/shm` and other tmpfs tricks are not an escape hatch. Assume anything present in the Sprite at checkpoint time is in the checkpoint.

## Sprites Connectors, and why a seekrit token isn't one

Sprites has its own credential-brokering feature.
[Connectors](https://docs.sprites.dev/concepts/connectors) store an OAuth token
or API key in your Fly.io organization and route calls through
`api.sprites.dev/v1/gateway/<provider>/<connection_id>/<path>`, identifying the
calling Sprite from Fly.io's request signature. Deny-by-default, scoped by Sprite
name prefix or labels, with endpoint allow- and block-lists.

**Use it for what it covers.** For GitHub and OpenRouter it is strictly better
than putting a token in the Sprite, and every Sprite ships a `sprite-api-gateway`
skill so an agent inside one discovers and uses connectors without being taught
the URLs.

What it does not cover is everything that is not an HTTP API behind their
gateway: a `DATABASE_URL`, object-storage credentials, a webhook signing secret,
an SSH key, a license key, a provider they do not front. That is the half seekrit
is for.

And there is one composition that looks appealing and does not work: wrapping
seekrit as a **Custom API connector** so the Sprite never holds `SEEKRIT_TOKEN`.
A seekrit service token is `skt_<id>_<pkcs8>` — it **embeds a private key**, not
just a bearer credential. `/v1/resolve` returns ciphertext and wrapped data keys
that only that key can open. Broker it and the gateway ends up holding the key
material while the Sprite receives ciphertext it cannot decrypt. The token has to
be where the decryption happens, which is the point of the
[zero-knowledge model](/docs/concepts/encryption) rather than a limitation of it.

The division that does work:

| | Reach for |
| --- | --- |
| GitHub, OpenRouter, an API you wrap as a Custom API connector | Sprites Connectors |
| Databases, storage, webhooks, SSH, anything not an HTTP API | seekrit + `seekrit-run` |
| An agent that must not hold any key, for any of the above | `seekrit-proxy`, behind the network policy |

## Worked example: Claude Managed Agents

Fly.io's [Claude Managed Agents
integration](https://docs.sprites.dev/integrations/claude-managed-agents) runs
each agent session in its own Sprite. Its worker writes the Anthropic credentials
to `/root/runner.env`, and the service command sources the file and deletes it:

```bash
set -a; . /root/runner.env; set +a; rm -f /root/runner.env; …
```

That is a hand-rolled `seekrit-run`, and it is careful for good reasons — the key
stays out of process listings and off the disk after boot. Two things it cannot
do: survive a cold boot (the file is gone, so a restarted service sources nothing
and the runner comes up without its credential), and pick up a rotated
environment key without the worker re-provisioning the Sprite.

Note which of those five variables actually needs protecting. `ANTHROPIC_WORK_ID`
and `ANTHROPIC_SESSION_ID` are per-session identifiers, not credentials, and they
belong in the service definition. `ANTHROPIC_ENVIRONMENT_KEY` is the long-lived
one, and it is the one to move into seekrit:

```python
c.put(f"/v1/sprites/{name}/fs/write",
      params={"path": "/etc/seekrit.env", "workingDir": "/"},
      content=f"SEEKRIT_TOKEN={session_token}\n".encode()).raise_for_status()

cmd = (
    "sprite-env curl -X POST /v1/tasks -d '{\"name\": \"agent-runner\", \"expire\": \"5m\"}' >/dev/null 2>&1; "
    "( while true; do sprite-env curl -X PUT /v1/tasks/agent-runner -d '{\"expire\": \"5m\"}' >/dev/null 2>&1; sleep 60; done ) & heartbeat=$!; "
    # ANTHROPIC_ENVIRONMENT_KEY (and BASE_URL) arrive from seekrit, decrypted here.
    f"seekrit-run --env-file /etc/seekrit.env --cache -- python3 {RUNNER_PATH}; "
    "kill \"$heartbeat\" 2>/dev/null || true; "
    "sprite-env curl -X DELETE /v1/tasks/agent-runner >/dev/null 2>&1 || true; "
    "sprite-env services stop agent-runner >/dev/null 2>&1 || true")

c.put(f"/v1/sprites/{name}/services/agent-runner",
      json={
          "cmd": "bash",
          "args": ["-lc", cmd],
          "needs": [],
          # Identifiers, not secrets — fine in the definition.
          "env": {
              "ANTHROPIC_SESSION_ID": session_id,
              "ANTHROPIC_ENVIRONMENT_ID": environment_id,
              "ANTHROPIC_WORK_ID": work_id,
          },
      }).raise_for_status()
```

The environment key now lives in one place instead of being copied into every
session's Sprite, rotating it is one `seekrit secrets set`, and a cold-boot
restart re-resolves rather than starting a runner with nothing. The Sprite is
per-session and destroyed at the end, so mint `session_token` per session and
revoke it in the same teardown that calls `DELETE /v1/sprites/{name}`.

## Agents managing secrets from inside a Sprite

A Sprite ships with Claude Code, Codex, Cursor and Gemini preinstalled, so the
agent working in one can also manage seekrit itself. Give it the metadata plane
and it can create applications and environments, list secret names, and audit
grants without ever holding a value:

```bash
npx plugins add seekritdev/agent-plugin
```

The [hosted MCP server](/docs/mcp) at `mcp.seekrit.dev` registers no tool that
returns a secret value — a test enforces the split — so an agent connected to it
can reorganize your secrets and cannot read one. Values stay on the local crypto
plane (`seekrit mcp`), which is the same boundary this page draws everywhere
else.

## Teardown

A Sprite you forget is a credential you forget. Both halves:

```bash
sprite destroy -s api-42
seekrit token revoke skt_XXXXXXXX
```

## Checklist

- [ ] The Sprite holds **one** credential — a scoped `skt_` token, never an admin token
- [ ] Token written with the filesystem API, `chmod 600`, not echoed through `exec`
- [ ] Service command is `seekrit-run -- …`, not a service `--env` full of values
- [ ] `--cache` on, so a wake that races the network doesn't start uncredentialed
- [ ] `api.seekrit.dev` allowed in the network policy, if one is set
- [ ] Agent workload? Proxy as a service, `--needs` ordering, placeholders in `--env`
- [ ] No checkpoint taken with plaintext values on the disk
- [ ] URL left on `--url-auth sprite` unless it genuinely needs to be public
- [ ] Token revoked in the same step that destroys the Sprite

## See also

- [Agent sandboxes](/docs/guides/sandboxes) — the two shapes and when each is right
- [`seekrit-run` launcher](/docs/guides/run) — flags, caching, and the graceful-degradation rules
- [Credential broker](/docs/guides/agent-proxy) — the credential the workload names but never holds
- [Agent access policy](/docs/guides/agent-proxy/policy) — bounding which hosts and operations an agent may reach
- [Service tokens](/docs/guides/service-tokens) — scoping what a token can read
- [Rotation](/docs/concepts/rotation) — replacing a value, and why revoking at the provider is the other half
- [Sync to Fly.io](/docs/guides/third-party-sync/fly) — for Fly Machines apps, which are the other problem
