Skip to content
Solo ProTeamBusinessEnterprise

Writing secrets

Creating and updating values without leaking them into shell history, editing JSON in place, generating strong secrets, and moving or deleting safely.

Commands on this page: set · patch · gen · cp · mv · delete · undelete

Creating or updating a value#

set writes a new version. There are three ways to supply the value, and two of them keep it out of your shell history:

bash
pulsvault set acme-web/prod/product/web/db/password --from-file ./pw.txt
cat pw.txt | pulsvault set acme-web/prod/product/web/db/password --from-stdin
pulsvault set acme-web/prod/product/web/db/password 'hunter2'          # last resort
Values on the command line leak into shell history

Prefer --from-file or --from-stdin for anything real. The CLI prints a warning when you pass a value as an argument on a terminal — that warning is not decorative, the value is now in ~/.bash_history and likely in your process list.

Two optional flags travel with a write:

bash
pulsvault set acme-web/prod/product/web/db/port 5432 \
  --content-type application/json \
  --metadata owner=platform --metadata rotates=90d

--metadata is repeatable.

--content-type application/json is about type fidelity, not structure: it stores a number, boolean or list as itself, so 5432 reads back as 5432 rather than "5432". Everything else is text/plain, which is the default.

A JSON object is a folder, not a value

--content-type application/json refuses an object:

text
error: A JSON object ({ }) denotes a folder, not a leaf value (400)

{ } is reserved to mean a folder — it is how the path tree works — so a set of related credentials is a folder of secrets, not one blob:

bash
pulsvault set acme-web/prod/product/web/db/creds/user app
pulsvault set acme-web/prod/product/web/db/creds/password s3cr3t

That is also the better shape: each one can be granted, rotated and validated on its own, which a single JSON document cannot. If you genuinely need one JSON document — something you will patch — write it with a plain set and no --content-type.

Every secret lives inside a folder

…/db/password is valid; a bare …/password at the namespace root is not. The write is rejected, and so is one that would make a path both a folder and a secret. See Paths, plans & roles.

A value with nothing in it is refused#

set will not write an empty — or whitespace-only — value:

bash
cat pw.txt | pulsvault set acme-web/prod/product/web/db/password --from-stdin
cat: pw.txt: No such file or directory
error: stdin was empty — nothing to write.
       the command feeding the pipe may have failed — check its exit status
       to store an empty value on purpose, pass --allow-empty

This exists because of how the failure looks in a shell. cat failed, but in a pipeline the exit status you get back is the last command's — pulsvault's — so the script keeps going. Without the check, that overwrote a live password with zero bytes and exited 0. The server enforces the same rule, so a script that builds the request itself cannot slip past it either.

The error names where the value came from, so you know which end to look at:

How you supplied it What you see
--from-stdin stdin was empty (or stdin held only whitespace)
--from-file F ./pw.txt is empty
inline argument the value is empty

When empty really is the value you want — an optional variable your app reads and expects to find blank — say so:

bash
pulsvault set acme-web/prod/product/web/flags/legacy_mode '' --allow-empty
wrote acme-web/prod/product/web/flags/legacy_mode (version 1)
A trailing newline is content, not emptiness

echo secret > pw.txt leaves a newline at the end of the file, and set stores the file's bytes exactly as they are. Only a value that is entirely empty or whitespace is refused, so writing from a file keeps working the way it always has.

The console asks the same question in its own idiom: clear the value in the secret form and a Store an empty value on purpose checkbox appears under it, which is what --allow-empty is here. Same rule, same opt-in, whichever surface you write from.

Quotes you type become part of the secret#

set stores the bytes you give it. It does not strip quotes, and it does not trim whitespace — so a value you wrapped in quotes out of habit is stored with the quotes:

bash
pulsvault set acme-web/prod/product/web/flags/testing '""'
warning: the value is two " characters, not an empty value
       to store an empty value, pass an empty argument with --allow-empty
wrote acme-web/prod/product/web/flags/testing (version 1)

That secret now holds two characters. pulsvault run exports them verbatim, and the application reads "" where it expected nothing at all — a misconfiguration that shows up in production rather than at the point it was typed.

The same warning appears for quotes around real content and for whitespace around it:

What you typed What you see
'""' or "''" the value is two " characters, not an empty value
'"s3cr3t"' the " characters around the value are part of the secret
's3cr3t ' the value has leading or trailing whitespace, which is stored with it

It is a warning, not a refusal. A secret is opaque bytes: "s3cr3t" is a perfectly good password if that is genuinely the password, and refusing it — or quietly stripping the quotes — would mean the value you read back is not the value you wrote. The write goes through either way.

It is printed only when a terminal is attached, so CI logs stay quiet whatever the shape. --content-type application/json is exempt too: there the quotes are JSON syntax and decode away, so '"s3cr3t"' really does mean the string s3cr3t.

The newline --from-file hands you#

echo s3cr3t > password.txt writes eight bytes, not seven — echo appends a newline, and set stores the file verbatim:

text
$ pulsvault set acme-web/prod/product/db/password --from-file password.txt
warning: the value from file:password.txt ends with whitespace, which is stored and injected with it
       if that newline is not part of the secret:
         printf %s "$(cat FILE)" | pulsvault set <path> --from-stdin
wrote acme-web/prod/product/db/password (version 1)

This is the shape worth catching, because nothing downstream catches it for you: pulsvault run injects DB_PASSWORD=s3cr3t\n, the database rejects the login, and every log you inspect prints the password looking exactly correct.

Quoting is deliberately not warned about for a file or a pipe — CI writes quoted JSON through one all the time — but trailing whitespace almost never survives on purpose. The write still goes through: if the newline is genuinely part of the secret, nothing is in your way.

Let the shell do the quoting

pulsvault set … "$PASSWORD" passes the variable's value; pulsvault set … '"$PASSWORD"' passes six characters including two quotes and a dollar sign. The outer quotes are the shell's and are consumed by it — anything left inside is yours and is stored.

`import` is different, and deliberately so

pulsvault import --format dotenv does strip one pair of surrounding quotes, because FOO="bar" means bar in a .env file and importing it any other way would be wrong. --format json keeps them, because a JSON string already says exactly what it contains. set takes no format and makes no such assumption, which is why it warns instead.

The console asks the same question in its own idiom: type a value shaped this way into the secret form and a Store it exactly as typed checkbox appears under the row, holding the save until you confirm.

Editing one key of a JSON secret#

patch merges instead of replacing — key=value sets a field, key- removes one:

bash
pulsvault patch acme-web/prod/product/web/db/creds username=svc_app legacy_token-

This matters for correctness, not just convenience. Reading a JSON secret, editing it locally and writing it back is a lost-update waiting to happen; patch pins If-Match to the version it just read, so a concurrent write fails cleanly with a conflict instead of silently overwriting.

To assert a specific version instead, pass it explicitly:

bash
pulsvault patch acme-web/prod/product/web/db/creds password=new --if-match 4

--if-match 4 means only if it is still version 4 — a stronger claim than the automatic pin, which only protects against a write landing between your read and your write.

Generating a strong value#

gen creates a cryptographically random secret client-side and stores it, so no human ever chooses or sees it unless they ask:

bash
pulsvault gen acme-web/prod/product/web/db/password --length 32
pulsvault gen acme-web/prod/product/web/db/password --length 40 --symbols --copy
Flag Effect
--length N 8–256, default 24
--symbols include punctuation
--reveal / --copy surface the generated value (otherwise it is stored and never shown)
--content-type / --metadata as for set

Without --reveal or --copy the value is written and never displayed — which is usually what you want for a credential that only a machine will ever read.

Copying and moving#

bash
pulsvault cp acme-web/prod/product/web/db/password acme-web/staging/product/web/db/password
pulsvault mv acme-web/prod/product/web/db/password acme-web/prod/product/web/db/main-password

The two are less alike than they look, and the difference is worth knowing before you reach for either:

What happens Needs History
cp the value is read to your machine and written back at the destination read on the source, write on the destination destination starts at v1
mv one server-side call relocates the secret delete on the source and write on the destination every version comes with it
  • cp duplicates the current value and leaves the source alone. It accepts --if-match N to guard the destination.
  • mv (aliases move, rename) moves the secret atomically — the server checks both permissions before it changes anything, then carries the whole version history in one transaction. It asks for confirmation first; --yes/-y skips the prompt in a script. --if-match N pins the source's current version, so a move loses a race rather than discarding a write that landed while you typed.

Moving a whole folder is a console operation, not a CLI one — see Browsing secrets & folders.

`write` does not include `delete`, so `mv` can be refused where `set` works

They are separate actions, and no starter permission grants delete except <project>-manage-prod. A key holding <project>-write-prod can create and update secrets all day and still be refused here:

text
this key was refused delete on acme-web/prod/product/web/db/password
  This key holds: acme-web-write-prod
  None of them authorized `delete` here. A permission must cover the path AND
  grant the action — `write` does not include `delete`. Check both in the console.

Nothing was moved. Attach a permission that grants delete — see Default permissions.

The move cannot change the project or the environment: {project}/{env} is a fixed mount, and promoting a secret from dev to prod is a decision that should read as two steps, so do it with get and set. Moving between namespaces under one mount is fine.

Deleting, and undoing it#

bash
pulsvault delete   acme-web/prod/product/web/db/password        # soft delete, 30 days to undo
pulsvault undelete acme-web/prod/product/web/db/password        # restore it

delete (alias rm) is reversible for 30 days: the secret moves to the namespace's recycle bin keeping its version history, and undelete (alias restore) brings it back. Reads fail while it is deleted. After 30 days a nightly job removes it for good and undelete reports a failure rather than "not found" — the secret did exist, and saying otherwise would send you hunting for a typo.

Permanently destroying a version is not a CLI operation

Destroying a version's ciphertext is an administrator action in the console — restricted and audited. It is recoverable for 30 days and then permanent, or immediately permanent if purged. The CLI deliberately cannot do either. Use it to purge a value that should never have been stored — retention caps handle routine cleanup.

Optimistic locking#

set, patch, cp and rotate all accept --if-match N. The write is rejected with a conflict if the secret has moved past version N since you read it:

bash
V=$(pulsvault get acme-web/prod/product/web/db/password -o json | jq -r .versionNumber)
# … compute a new value …
pulsvault set acme-web/prod/product/web/db/password --from-stdin --if-match "$V"

This is the difference between "write this" and "write this if nothing changed" — the second is what you want whenever two pipelines might touch the same path.

Was this page helpful?