docs /Sealed secrets

Sealed secrets

A deployment file, a stack file or a manifest can carry a secret as ciphertext that opens on exactly one host:

[env]
DATABASE_URL = "enc:v1:9hf2…"

The value is sealed for that host's key. It can sit in a public git repo, travel through CI, and be read by anyone; only the run parent on that host opens it, while composing the app's environment, on its way into the process. On Linux the plaintext exists nowhere but that process's memory and the child's environment.

This is the shape Kamal and SOPS users already trust. It is not Vault: there are no leases, no rotation, no identity-based access. For those, run Vault and inject via env.

#The host's key

sudo ply secret hostkey        # apps that run as root (systemd units, deployments)
ply secret hostkey             # apps you run as yourself

The first call makes the key and prints its public half, a short string starting with ply-host-. The secret half is a 0600 file under ply's data directory (/var/lib/ply/host.key for root) that never leaves the host. sudo ply setup makes root's key too, so a freshly installed server is ready before its first deployment file arrives.

The key belongs to the user that runs the app. Root's apps open values sealed for root's key; your rootless apps open values sealed for yours.

#Sealing

On your laptop, or anywhere with the public key:

ply secret seal DATABASE_URL=postgres://app:s3cret@db/app --for ply-host-…
# DATABASE_URL = "enc:v1:…"

Paste the line into [env] of the manifest, the deployment file, or the stack member. --env prints KEY=enc:v1:… for an env file instead. A value of - reads from stdin, which keeps it out of shell history:

printf '%s' "$PASSWORD" | ply secret seal DB_PASSWORD=- --for ply-host-…

Without --for, the value is sealed for the host you are on: the single-server case, where you seal and run in the same place.

A value is sealed under its name. DATABASE_URL = "enc:v1:…" opens only as DATABASE_URL: moving the ciphertext to another variable, or another host, fails with a message that names the variable and never the value. That is the property that stops a sealed database password from being re-labelled as something an app prints.

#What happens at launch

Sealed values can arrive from any source the run parent composes: [env] in the manifest, -e, --env-file, a stack member's env, a deployment's [env] or env_file. After everything is merged, the run parent opens what is sealed and says which names it opened, never the values:

ply: unsealed DATABASE_URL, SMTP_PASSWORD for api

The same line goes to the events journal, so ply why shows it. ply ps --json carries no environment at all.

A sealed value on a host with no key, or one sealed for another host, is an error before anything launches. An app started with enc:v1:… as its database URL is not a useful app.

macOS. The microVM backend hands the environment to the guest through a spec disk in the instance directory, a 0600 file that lives as long as the instance. Sealed values are opened before it is written, so the plaintext is on that disk for the instance's lifetime. Fine for a laptop; say so to yourself before relying on it for more.

#Rotating a host key

There is no rotation command. Make a new key by moving the old file aside and running ply secret hostkey, seal every value again for the new public key, and deploy. Values sealed for the old key stop opening, loudly, which is the point.

#The fleet flow

A fleet repo (Deployments & CD) holds deployment files for every host, and can now hold their secrets too, sealed per host: hosts/web-1/api.toml carries values sealed for web-1's key. The repo stays publishable, and a leaked clone is ciphertext. .env/<name>.env files still work for anything you would rather keep off the repo entirely.