---
title: "Handle secrets"
summary: "How to declare plaintext and SOPS-encrypted environment files, which list a workload resolves, and what wins inside the container."
description: "Environment files, SOPS-encrypted entries, and the two precedence rules that decide what a container sees."
status: shipped
read_when:
  - "Adding secrets to a project"
  - "Working out why a container has the wrong value for a variable"
  - "Sharing one environment file across an upstream stack"
---
One field carries environment values, at four scopes.

```yaml
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?

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:

```yaml
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?

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.

> **Except a connection variable**
>
> Shadowing one is refused with `connection_variable_claimed`. A credential
> generated on the server exists nowhere else, so nothing authored may claim its
> name.

## 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

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.

```sh
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.

## 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

`runtime.env_checks` checks dotenv files **before the server is contacted**:

```yaml
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.