Handle secrets
One field carries environment values, at four scopes.
runtime: env_files: - .env # plaintext - {file: .env.production} # the same thing, object form - {file: secrets/prod.env, provider: sops} # encrypted at restWhether an entry is encrypted is a property of the entry, not of the workload
reading it. The only provider is sops.
Rule 1 — which list does a workload resolve?
Section titled “Rule 1 — which list does a workload resolve?”Exactly one, from the most specific declaration present. Lists replace rather than extend.
| Scope | Declared at | |
|---|---|---|
| 1st | most specific | environments.<env>.overrides.workloads.<name>.env_files |
| 2nd | workload | workloads.<name>.env_files |
| 3rd | environment | environments.<name>.env_files |
| 4th | project | runtime.env_files |
A workload wanting the project’s files plus its own restates both. Within the resolved list, entries apply in the order written, a later one overriding an earlier key by key.
Nothing matches an entry against an environment’s name. Environments differ by declaring different lists:
environments: production: server: root@203.0.113.10 env_files: [.env, {file: secrets/production.env, provider: sops}] staging: server: root@203.0.113.20 env_files: [.env, {file: secrets/staging.env, provider: sops}]env_files: [] means the workload receives nothing, which is different from
declaring no list at all. ob canonical shows which you wrote, at every scope.
Rule 2 — what wins inside the container?
Section titled “Rule 2 — what wins inside the container?”Lowest precedence first:
- the referenced Compose service’s own
env_file, for acompose:workload - the resolved
env_filesentries, in order - managed-service connection files
- the service’s
environment— your inlineenv, or the referenced service’s
Level 4 outranks the rest because the container runtime places environment
above env_file, and a generated runtime cannot contradict the runtime that
reads it. Shadowing an entry that way is legitimate — it is your most specific
statement about that container.
Who receives the default list
Section titled “Who receives the default list”The project’s or environment’s list reaches the application’s own workloads —
application, worker, job — and not a daemon.
Application configuration should not land in infrastructure by default: a
database does not want your Stripe key, it wants POSTGRES_PASSWORD, which
belongs in its own env. A daemon that needs a file names one and receives
exactly it.
The common upstream layout shares one file across every service, so converting something like authentik or Immich means naming it on the database too. That costs a line; the opposite default costs a credential in a place nobody looks.
Encrypted entries
Section titled “Encrypted entries”The plaintext may be an environment file or a flat YAML map — both render to the same thing. A nested map, or one declaring no values at all, is refused.
API_TOKEN=value # or: API_TOKEN: valueValues are read literally: a password containing $ is not expanded.
Each encrypted entry is decrypted into its own file inside the release when the release is staged, and stays there. That is deliberate — a scheduled job fires from the host’s timer with no Onebox process alive, including after a reboot, and must resolve the values the deploy resolved.
ob secrets list # value-free IDs, paths, scopes, workloadsob secrets edit secret_0123456789ab # decrypts exactly this source temporarilyob secrets push # updates the current release on the serverEntry IDs are deterministic and contain no secret values. edit may omit the
ID only when the selected environment resolves exactly one editable SOPS
source; ambiguity is refused instead of silently choosing the first file.
push first proves that the local encrypted-entry graph exactly matches the
current release: paths, providers, order, scope, and affected workloads. A
difference is secret_declaration_not_deployed. Add, remove, reorder, or move a
declaration with ob deploy; secret push changes values only.
It also refuses when the current release carries no opaque secret generation at
all — secret_generation_not_deployed, which is what a release staged before
generations existed looks like. Both refusals resolve the same way: run
ob deploy first.
Changed values are prepared under a random, opaque generation. Onebox replaces
every affected workload, verifies that all of them selected that generation,
and only then commits it. A failure recovers every workload to the old
generation. If recovery cannot finish, the checkpoint remains retryable and
the command reports secret_recovery_incomplete; it never reports a mixed
generation as success. Generation identifiers are not content hashes.
SOPS exit status 200 means the editor made no change. In JSON mode
ob secrets edit reports outcome: no_op and exits successfully.
What never leaves your machine
Section titled “What never leaves your machine”No resolved value from any entry appears in the project file, generated
runtime metadata, plan, or Onebox-generated structured fields — plaintext
included. Container logs and ob exec output remain operator-controlled
passthrough and can reveal whatever the process prints.
Plaintext is not less sensitive than encrypted, only less protected.
Two things Onebox deliberately does not model: the *_FILE convention some
images use to read a secret from a path, and values a local build hook reads —
those are not container environment.
Assert before you deploy
Section titled “Assert before you deploy”runtime.env_checks checks dotenv files before the server is contacted:
runtime: env_checks: - file: .env.production require: [DATABASE_URL, API_TOKEN] present: [OPTIONAL_FEATURE]require names keys that must exist with non-empty values. present names keys
that must be declared but may intentionally be empty. A miss is
preflight_env_incomplete, locally, before anything is contacted.