Skip to content

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 rest

Whether 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.

ScopeDeclared at
1stmost specificenvironments.<env>.overrides.workloads.<name>.env_files
2ndworkloadworkloads.<name>.env_files
3rdenvironmentenvironments.<name>.env_files
4thprojectruntime.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:
env_files: [.env, {file: secrets/production.env, provider: sops}]
staging:
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:

  1. the referenced Compose service’s own env_file, for a compose: workload
  2. the resolved env_files entries, in order
  3. managed-service connection files
  4. the service’s environment — your inline env, 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.

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.

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: value

Values 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.

Terminal window
ob secrets list # value-free IDs, paths, scopes, workloads
ob secrets edit secret_0123456789ab # decrypts exactly this source temporarily
ob secrets push # updates the current release on the server

Entry 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.

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.

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.