Run and test
Secrets with tvault
Inject only the credentials each process needs, without copying them into the repository, temporary files, or logs.
Reviewed 2026-10-02
Chalupa separates configuration from secrets. chalupa.yml and .env.example
describe names and contracts; tvault stores encrypted values. A process
receives a secret only while running the command that needs it.
The least-privilege examples on this page require TinyVault v0.19.0 or
newer. Verify the installed binary with tvault --version before using
--only, --prefix, or identity-backed decryption.
--only and --prefix select and decrypt only matching values across direct,
inherited-group, recipient-identity, and local agent reads. An explicit
tvault://... template reference resolves only the referenced key. Selectors
narrow bulk injection; they do not make unrelated values available to the
child process.
Secret map
| Key | Consumer | Scope |
|---|---|---|
DIGITALOCEAN_API_TOKEN |
Local Pulumi tasks | Operator-requested provisioning and destruction. It never reaches the server or control plane. |
CHALUPA_INGEST_KEY (or CHALUPA_INGEST_KEY__<ENVIRONMENT>) |
Agent and chalupa report |
Signs heartbeats, events, and suite runs. See per-environment key names. |
Ingestion keys come in two kinds, and they carry different blast radius:
- Per-environment keys are bound to one environment. A key presents its
public identifier (
cloud.ingestKeyIdinchalupa.yml) beside the signature, and the control plane refuses any write that names a different environment. Managed environments get one automatically; for your own provider account, issue one for the environment in the console's ingest keys page. Root compromise of one server exposes writes for that environment only, and you rotate by issuing a new key and revoking the old one. - The legacy shared key (
CHALUPA_INGEST_KEYwith noingestKeyId) is one credential for every environment that uses it, and its writes land in the platform organization. Rotating it means updating the control plane and recreating or reconfiguring every agent that holds it, and root compromise of one such server grants write authority across all of them, including forged deployment identities. Treat it as a shared credential and keep it to workloads you trust.
Prefer a per-environment key wherever your configuration supports it. Remote CI publishing still requires the shared key today.
Choose the TinyVault source
By default Chalupa reads its own credentials from the TinyVault project
chalupa. A config can name another source, so an employer account and a
personal account never share a project:
credentials:
tvault:
project: acme-work
| Field | Default | Meaning |
|---|---|---|
credentials.tvault.project |
chalupa |
Project the provider token and ingest key are read from. Letters, numbers, dots, underscores and hyphens. |
The setting applies to the provider and ingest credential profiles (chalupa up,
down, report, agent bootstrap, scheduled teardown) and to chalupa setup
when it runs in a directory that already has a chalupa.yml. A scheduled
teardown reads the config's project. The scheduler installer checks the same project when you pass the config
(chalupa teardown schedule:install --config chalupa.yml); without a config
it checks the default project.
Environment groups (tvault get --group G --env E) are not supported here:
tvault serves them without its agent and resolves them from the group's own
project, so non-interactive runs and the scheduled teardown could not read
them. credentials.tvault accepts only project, and a config that declares
group or env is rejected.
Per-environment ingest key names
The ingest key is looked up under a name derived from the environment, then under the legacy fixed name:
CHALUPA_INGEST_KEY__<ENVIRONMENT>, where the environment isname(orname-instance) uppercased with hyphens as underscores: environmentcheckout-areadsCHALUPA_INGEST_KEY__CHECKOUT_A.CHALUPA_INGEST_KEY, the fixed name used before this existed.
Environment names allow no underscores, so two environments can never map to
the same key name. Two environments with different scoped keys therefore sign
with their own key; one environment without its own key falls back to the
fixed name. When a config declares cloud.ingestKeyId and only the fixed name
resolves, Chalupa warns, because that key may belong to another environment.
The key always reaches the child process as CHALUPA_INGEST_KEY, so nothing
downstream changes. An exported variable is only a fallback when TinyVault
holds nothing: CHALUPA_INGEST_KEY__<ENVIRONMENT> first, then
CHALUPA_INGEST_KEY.
Store the key the console issued for that environment (the one that goes
with the cloud.ingestKeyId in your config); a value you generate yourself
fails signature verification because the control plane does not know it:
tvault set --project acme-work CHALUPA_INGEST_KEY__CHECKOUT --stdin >/dev/null < issued-key.txt
See what resolves, without values
chalupa doctor --secrets --config chalupa.yml
chalupa doctor --secrets --config chalupa.yml --json
The report lists every credential the config needs (the provider token, the
ingest key when cloud is set, and the ${VARS} your services interpolate),
where each resolves from (TinyVault project and key, an exported variable, or
the legacy provider file), whether TinyVault is locked or the key is missing,
and any exported variable that differs from the TinyVault value (TinyVault
wins). It never prints a value and contacts no provider. It exits non-zero
when a required credential resolves nowhere. Without --config it reports the
default provider token only.
Diagnose without reading values
Secret names are metadata. Verify availability without unlocking or printing values:
tvault doctor --project chalupa --json
tvault list --project chalupa --json --names-only
tvault status --json also reports whether the vault and local agent are
available. None of these commands returns secret values.
Local operator interface
Starting the local CLI or operator UI, running doctor, or
building an inspect view does not read tvault or contact a provider. The
interface resolves an existing Taskfile credential profile only after the
operator selects an action that needs it. It does not cache config history,
store secret values, or create another credential store.
Inspection is redacted but can still reveal paths, service names, topology, ports, images, and other private metadata. Keep its output and local Pulumi state on the trusted operator workstation.
Store a credential without printing it
Send a credential to tvault over stdin, so the value never appears in your shell history or process list, and redirect the command's own output:
tvault set --project chalupa DIGITALOCEAN_API_TOKEN --stdin >/dev/null < token.txt
Delete the source file afterwards. chalupa setup does this for you when it
stores your provider token. The ingest
key for an environment comes from the console (see
per-environment ingest key names); a value
you generate yourself fails signature verification.
Run with least privilege
Lifecycle actions already pass through fixed credential profiles:
| Workflow | Profile boundary |
|---|---|
chalupa data-up, Pulumi phase of chalupa up, the Taskfile-only data deletion |
Provisioning token only. |
Agent bootstrap phase of chalupa up, chalupa agent-bootstrap |
Ingest key only; delivered through SSH stdin after Pulumi completes. |
Provider phase of chalupa down |
Provisioning token only; writes a private, short-lived receipt after successful destruction. |
Sunk-report phase of chalupa down |
Ingest key only; reads the receipt after the provider subprocess has exited. |
chalupa report |
Ingest key only. |
Do not invoke the internal underscore-prefixed lifecycle targets directly; the public actions establish the credential boundary.
The wrapper builds each child environment from a fixed operational allowlist
instead of copying the parent shell. It retains executable discovery,
user-scoped Bun/Pulumi/tvault state, temporary directories, locale and
terminal behavior, explicit proxy/CA settings, the empty local Pulumi
passphrase and Taskfile inputs. The local SSH agent socket is admitted only
for the ingest-only bootstrap or the exact provider-bound destroy
command. The latter passes it to credential-free log and Monitor flush
subprocesses; every SSH call retains ForwardAgent=no, so the socket is never
forwarded to the server. The allowlist also retains the public control-plane
mode, origin, and
reviewed storage-rate setting when those commands need them. Resolved profile
credentials are then added explicitly.
The empty Pulumi passphrase is a required local-backend convention in this
repository; it does not add meaningful entropy. Local filesystem permissions
and workstation access are therefore the state confidentiality boundary.
Post-provision SSH delivery removes CHALUPA_INGEST_KEY from DigitalOcean
user data and Pulumi state, but the state can still contain generated Compose
configuration and must remain private.
This boundary applies to newly generated user data. It does not erase old checkpoint backups or user data already delivered to an existing server. Before declaring an older environment remediated, replace its disposable compute through the reviewed lifecycle, rotate the fleet key using the documented whole-fleet sequence, and retain or remove historical state only under the operator's normal backup policy. Never inspect a checkpoint by printing its secret values.
The bootstrap stores the key as /etc/chalupa/ingest.key through a
same-directory temporary file and atomic rename. Without private log
collection it is owned by root with mode 0600. A logs-enabled deployment
uses owner/group root:chalupa-logs, mode 0640, and a 0750
/etc/chalupa directory so only root and the isolated collector can traverse
and read the required files. The value travels on stdin, never in the SSH
command or routine output. Initial host-key acceptance uses the same OpenSSH
accept-new trust-on-first-use boundary as the tunnel; it is not independent
host identity verification. Subsequent host-key changes fail closed. To
verify the first connection, pin the server's reported host key fingerprint
first — see Verified first-connection
identity.
Unrelated cloud credentials, application secrets, NODE_OPTIONS, dynamic
loader hooks, and arbitrary parent variables are not forwarded. If a trusted
tool needs another non-secret operational variable, review and add its exact
name to CHILD_ENVIRONMENT_ALLOWLIST; do not restore broad pass-through.
Supported TVAULT_* unlock, identity, agent, and vault-location variables are
visible only to the short-lived tvault credential reader. They are never
forwarded to Pulumi, Task, Bun application commands, or the control plane.
Provisioning handles application-level Compose interpolation separately. The
wrapper scans only selected service values and their declarative command/env
overrides, captures only the referenced host variables, and passes a bounded,
canonical snapshot under one Chalupa-owned runtime name. Pulumi decodes that
snapshot explicitly; the original variable names do not become subprocess
environment capabilities. CONFIG, CHALUPA_*, PULUMI_*, TVAULT_*, and
known DigitalOcean credentials are forbidden interpolation names. Every
non-empty application value taken from the operator shell is redacted by
output path in the preview, including short values under generic names.
Resolved tunnel ports remain visible structural output, and volume mappings
cannot use interpolation. Literal values under common secret-bearing keys and
URL userinfo are also redacted, but the preview is not a general DLP scanner.
Do not place literal secrets under unusual field names in Compose.
For backward compatibility, only the provisioning profile can read
DIGITALOCEAN_API_TOKEN from ~/.config/secrets/env after tvault is missing
or cannot return that key. The parser treats the file as data and never
evaluates shell syntax. Database, migration, admin, ingest, cron, and read-only
provider credentials never use this fallback. Move the provisioning key into
tvault and treat the file as a temporary migration path.
Prefer tvault run --only NAME to exporting an entire project: values exist
only in the subprocess environment, as in the
application secrets example below.
Do not use eval $(tvault env) as the normal workflow. It expands both the
lifetime and scope of credentials in your shell. Do not persist tvault env
output in .env.local.
Application secrets
Compose env_file: is refused (see
what Compose features are supported),
because the file would have to travel to the box as a host file. To give a service a secret such as an API key or a
database password, name it in the service's env override with an
interpolation reference and inject the value only for the launch:
overrides:
api:
env:
STRIPE_TEST_KEY: "${STRIPE_TEST_KEY}"
tvault run --project chalupa --only STRIPE_TEST_KEY -- chalupa up
chalupa up captures only the names your selected services reference, so
--only keeps everything else in the vault out of the process. The preview
redacts the value, and the name stays in the versioned file while the value
never does.
Know where the value ends up: it is resolved into the generated Compose configuration, so it lands in the box's startup configuration and in the local Pulumi state on your workstation. Today neither is encrypted per secret. Use disposable test credentials for these, scope them as narrowly as the provider allows, rotate them after the session, and keep your local state private. Do not use this recipe for production credentials.
tvault:// templates
When a command needs to rename a key, a committed template may contain a reference, never the value:
DIGITALOCEAN_TOKEN=${tvault://chalupa/DIGITALOCEAN_API_TOKEN}
tvault run --env-file <template> -- <command> resolves the reference at
runtime. Its dotenv parser does not evaluate shell substitutions or commands.
Rotation and revocation
Rotate one credential at a time and keep a recovery path:
- create the new version in tvault;
- update the remote consumer;
- verify readiness and the affected workflow;
- restart or replace agents when the HMAC key changes;
- revoke the previous value at the provider;
- inspect metadata with
tvault history --project chalupa <KEY>.
Rotating an ingest key means issuing a new key for the environment, delivering
it again with chalupa agent-bootstrap, and revoking the old one.
Rules for agents
- Use
tvault list --names-onlyfor discovery; do not loop overget. - Inject values into a trusted subprocess with an explicit allowlist.
- Treat proxy URLs as sensitive when they contain credentials, even though proxy variables are operationally allowlisted.
- Never surface stdout from a command that might echo its environment.
- Do not write secrets to
/tmp, artifacts, screenshots, or messages. - Do not unlock and relock the vault around every operation.
- Stop when creating, rotating, or synchronizing a value needs new authority.