docs · pre-release
Menu
Configuration / Secrets

Secrets

A Flockfile ships with its secrets blank, because it is committed to the app's own repository. The value has to live somewhere, and $SHEP_HOME/secrets.json is that somewhere: one value per key per environment, read by a {{secret:NAME}} reference and never written into the file it fills in.

Where it lives

Owner-only, mode 0600, the same shape as overrides.json and the KV store: a read-modify-rename under an exclusive lock, keys in sorted order so two writes of the same content land as the same bytes. shep secret set writes it directly and never connects to the shepherd, so filling in a value before the first shep start is the ordinary order, not a workaround.

$ cat $SHEP_HOME/secrets.json
{
  "version": 1,
  "entries": {
    "DB_PASSWORD": {
      "all": "hunter2",
      "staging": "staging-pw"
    }
  }
}

The token

An env value or an arg can carry {{secret:NAME}}, the same doubled-brace grammar Your first Flockfile covers for {{instance}}, {{name}} and {{SHEP_HOME}}. Anything between the braces that is not a known token is refused at shep start, this one included.

careful
A value rendered into args is visible in ps to every user on the host for as long as the process runs, the same exposure shep secret set's positional value carries. shep does not refuse it, because passing configuration as an argument is how a great many programs take it; where a program reads the same setting from either, env keeps the value out of ps. That is all it keeps it out of. The child inherits its environment, so a crash dump, a debug page or anything the child passes on can still carry the value.

out_file and err_file take every token but this one. A resolved log path is not contained the way a resolved env value is: it becomes a filename on disk under $SHEP_HOME/logs and is reported by shep flock, shep describe and shep lookout, which is four more places than the two this design puts a plaintext secret in.

Flockfile.toml
[[app]]
name   = "web"
script = "./server.sh"

[app.env]
PW = "{{secret:DB_PASSWORD}}"

{{secret:vercel/API_KEY}} is the other shape: a namespace and a slash before the key, reading what a provider dog registered as vercel has pushed rather than the operator's own store. More on that below.

$ shep bleats web --no-follow
web | PW=hunter2
web | SHEP_ENVIRONMENT=production

SHEP_ENVIRONMENT is injected on every sheep and every dog, next to SHEP_NAME and SHEP_INSTANCE, and cannot be set by hand in env.

Environments and the all slot

A key can hold a different value per environment, so production and staging differ without two Flockfiles. A sheep's own environment comes from its environment field; unset, it falls back to [daemon] environment in shep.toml, which defaults to production. Neither may be all: that name is reserved for the slot every environment falls back to, so a value set with no --env covers every environment at once instead of being typed out per environment.

A sheep's environment decides what every reference in it resolved to, and that is baked into the child at exec, so changing it on a running sheep parks until the next spawn the same way cwd does. See Overrides.

Keys and environment names share one grammar: 1 to 128 bytes of letters, digits, ., _ or -, never starting with a dot. Neither can hold the / a namespaced reference splits on.

exact environmentUsed first, if the store has a value for it.
allUsed when the sheep's own environment has nothing.
anything elseNever. There is no fallback to a different named environment.
careful
That third row is the point of the whole design. A rule that filled an empty staging slot from production's value would hand a live credential to staging the first day somebody forgot to set one. {{secret:...}} stops at exact-environment, then all, then refuses, and so does shep secret get with no --env. Passing --env reads that one slot instead.

shep secret

Four subcommands, working with no shepherd running, the same as shep set/get/unset:

setStore a value under a key and an environment.
getPrint a value back, gated below.
unsetRemove one slot.
listNames keys and their environments. Never a value.

set takes the value as a positional argument by default, which puts it in ps output and in this shell's history for as long as the command runs. Prefer --stdin for anything that is actually a credential:

$ echo "$DB_PASSWORD" | shep secret set DB_PASSWORD --stdin
KEY          ENVIRONMENT
DB_PASSWORD  all

$ echo "$STAGING_PASSWORD" | shep secret set DB_PASSWORD --env staging --stdin
KEY          ENVIRONMENT
DB_PASSWORD  staging

$ shep secret list
KEY          ENVIRONMENTS
DB_PASSWORD  all, staging

$ shep secret get DB_PASSWORD
error[invalid_config]: printing a stored secret back is off; add `[secrets]` with `allow_read = true` to $SHEP_HOME/shep.toml
$ echo $?
4

A leading NAME=VALUE on shep start or shep add carries the same exposure, through shep's own command line rather than the sheep's. Write KOJI_TOKEN={{secret:KOJI_TOKEN}} there and the reference is what reaches ps and your history, while the value is read from this store at spawn.

--env is exact: unset DB_PASSWORD --env staging touches the staging slot alone and leaves all standing. Omit --env on set or unset and the all slot is what changes.

Reading a value back

get needs [secrets] allow_read = true in shep.toml, default false, the same shape whistle's allow_control gates a different kind of access with:

shep.toml
[secrets]
allow_read = true
$ echo -e '[secrets]\nallow_read = true' >> $SHEP_HOME/shep.toml
$ shep secret get DB_PASSWORD
hunter2
$ shep secret get DB_PASSWORD --env staging
staging-pw

The honest counter is on the record: the file is 0600 and its owner can already read it with cat, so refusing to print is partly theatre. What the gate actually buys is that cat is a deliberate act and shep secret get is a command whose output can land in scrollback during a screen share. Turning it on is a decision rather than a default.

Without --env, get resolves against [daemon] environment's own default rather than any one sheep's: this verb takes no sheep name, so it has no Flockfile to read an override from. The --env staging line above reads that one slot and nothing else, with no fall back to all.

Lookout's own secrets pane (S) reads the same gate for the same reason, and it's a different gate from the one that lets the pane change anything. [secrets] allow_read in shep.toml decides whether a value may be shown; lookout.allow_control in kv.json, the same key the dashboard's action keys use, decides whether the pane may set, delete or add a key. Neither implies the other: a read-only lookout can still reveal with allow_read on, and a controlling one without it can rotate a value it can never read back.

Removing a slot

A slot that was already empty exits 3, so a cleanup script can run twice:

$ shep secret unset DB_PASSWORD --env staging
KEY          ENVIRONMENT
DB_PASSWORD  staging

$ shep secret unset DB_PASSWORD --env staging
error[not_found]: `DB_PASSWORD` has no value for `staging`
$ echo $?
3

Importing a .env

shep import env <file> --app <name> reads a .env into two places at once: matched keys go here, the rest into the named sheep's own env. The sheep has to exist already, since this writes an override and an override is per sheep.

--secret <PATTERN> marks which keys are secrets, an exact key or an anchored glob, the same rule a sheep-name selector reads. A marked key's value lands here, in the slot --env names or the sheep's own environment when it names none, and the sheep's env gets {{secret:KEY}} in its place. That default is never the all slot, though --env all writes it when you ask for it, the same as shep secret set. A key --secret did not claim is stored in the clear, and is copied into flock.json and the handover blob with the rest of the sheep's config, the same as anything else in a Flockfile's env.

None of it reaches the app until its next spawn. The import says so on stderr, and shep reload <name> is what promotes it.

A plain value's own {{...}} is not read as the token above. The import doubles every brace pair on the way in, so a .env line like DB_URL=postgres://u:{{secret:PW}}@db/app reaches the app exactly as the file wrote it, braces and all, rather than resolving as a reference. See Overrides for the doubled form that leaves on disk.

A pattern matching no key in the file, or a collision --force was not passed for, refuses the whole import. Nearly always that leaves both stores untouched, but a refusal reached after the secret store is written counts the keys it left there, and a re-run is clean, since an identical value is not a collision.

A refused spawn

A {{secret:...}} that will not resolve refuses the spawn rather than reaching the child as literal text, and it refuses in one of two shapes depending on whether a namespace is named.

a key nobody has setNothing but a person will ever fill this in. Errored at once, no retry. The message names the key and the environment it looked in.
a namespace no dog has pushed this environment forThe provider dog may just not be up yet, or may have pushed another environment first. waiting-restart, retried on the ordinary restart backoff.

cron below sets environment = "staging" and reads a key nobody has set. worker reads the vercel namespace from above, and no dog has pushed to it yet:

Flockfile.toml
[[app]]
name        = "cron"
script      = "./nightly-report.sh"
environment = "staging"

[app.env]
KEY = "{{secret:REPORT_KEY}}"

Starting both refuses one spawn and defers the other:

2026-09-06T21:59:39.459750Z  WARN shep_daemon::supervisor: no process was started id=1 reason=`KEY`: `{{secret:REPORT_KEY}}` has no value in the `staging` environment

$ shep flock
ID  NAME    STATUS           PID  RESTARTS  EXIT  CFG  CPU  MEM  UPTIME  FOLD  SMIT
1   cron    errored          -    0         -     -    -    -    0s      -     -
2   worker  waiting-restart  -    0         -     -    -    -    0s      -     -

shep describe answers the question a stuck sheep raises without reading a Flockfile and the store side by side: which references this sheep's config carries, resolved in its own environment, and whether each one currently resolves.

$ shep describe cron
ID  NAME  STATUS   PID  RESTARTS  EXIT  CFG  CPU  MEM  UPTIME  FOLD  SMIT
1   cron  errored  -    0         -     -    -    -    0s      -     -

Secrets for cron:
  REPORT_KEY (staging): missing

$ shep describe worker
ID  NAME    STATUS           PID  RESTARTS  EXIT  CFG  CPU  MEM  UPTIME  FOLD  SMIT
2   worker  waiting-restart  -    0         -     -    -    -    0s      -     -

Secrets for worker:
  vercel/API_KEY (production): not cached; a provider may still have it

not cached is not proof the running shepherd lacks the value too, only that the local cache file this verb reads does not have it: a dog pushing with persist = false never reaches that file. See below.

What a provider dog does

A dog cannot be asked for a value at spawn time, because assembling a spawn is the hottest path shep has and a socket round trip on every instance of every app would put a network call in the middle of it. So a provider dog pushes instead: Request::PutSecrets sends every value it holds for one namespace and one environment, replacing whatever that pair held before rather than merging into it, so a key the provider deleted disappears here on the next push too.

One push covers one pair, which is what the second refusal shape above is keyed on. A dog that polls production and then staging has said nothing about staging until the second push lands, so a staging sheep starting in between waits on the restart ladder rather than erroring for good. An empty push counts: a dog saying it holds nothing for a pair is an answer, and a key missing after it is a person's to fix.

careful
A namespace is bookkeeping, not authorization. The name a dog claims is self-declared, and shep has no portable way to check it against the process on the other end of the connection. The real boundary is the control socket itself, under $SHEP_HOME at 0700: anything that can already open it can start an arbitrary process as the shepherd's own user, which is a wider door than any namespace could close. Namespaces keep one provider's values from colliding with another's. They are not a permission system.

Pushed values are cached to $SHEP_HOME/secrets-cache.json by default, so a reboot does not leave every provider-backed sheep waiting on a dog and a network round trip before it can start. persist = false in the dog's own section of dogs.toml turns that off per provider, for an operator who has decided the risk of a cached copy outweighs the wait:

dogs.toml
[vercel]
persist = false

Why there is no encryption

secrets.json is plain JSON at 0600, and that is not an oversight. Encrypting it would need a key stored somewhere shep can read at boot, on the same host, by the same process, at the same mode: a second file to lose rather than a second factor. The attacker this would defend against, one who can read $SHEP_HOME but not a key sitting beside it inside the same 0700 directory, does not exist. A credential that genuinely needs key custody belongs behind a provider dog, which can hold it wherever it likes before pushing a value across.