Secrets in Fly.io Sprites
Sprites 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 capture the whole filesystem, credentials included |
The last one is the one that surprises people, so it gets its own section. 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:
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:
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: 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.
--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 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:
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 authenticates with the token, fetches the
environment's ciphertext, decrypts it inside the Sprite, and execs 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 apiforces 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.
Bootstrapping the token
Write it from outside, once, with the filesystem API — never by echoing it into a command, which puts it in the exec session's argv:
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:
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.
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 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, 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:
{
"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.
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 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:
#!/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
# 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.
--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 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:
seekrit proxy init --preset openai --preset anthropic
Then tighten it with methods and paths,
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.
This is a weaker boundary than Cloudflare's outbound handlers, and the reason is the VM. On 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 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:
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 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:
- You put
STRIPE_SECRET_KEYin a file on the Sprite in March. - You checkpoint before an OS upgrade.
- You rotate the key in April, updating the file.
- In May something breaks and you
sprite restorethe March checkpoint. - 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.
- 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.
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 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 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 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:
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:
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:
npx plugins add seekritdev/agent-plugin
The hosted MCP server 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:
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 throughexec - Service command is
seekrit-run -- …, not a service--envfull of values -
--cacheon, so a wake that races the network doesn't start uncredentialed -
api.seekrit.devallowed in the network policy, if one is set - Agent workload? Proxy as a service,
--needsordering, placeholders in--env - No checkpoint taken with plaintext values on the disk
- URL left on
--url-auth spriteunless it genuinely needs to be public - Token revoked in the same step that destroys the Sprite
See also
- Agent sandboxes — the two shapes and when each is right
seekrit-runlauncher — flags, caching, and the graceful-degradation rules- Credential broker — the credential the workload names but never holds
- Agent access policy — bounding which hosts and operations an agent may reach
- Service tokens — scoping what a token can read
- Rotation — replacing a value, and why revoking at the provider is the other half
- Sync to Fly.io — for Fly Machines apps, which are the other problem