Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,15 @@
- In tests: use `t.Context()` instead of `context.Background()` or `context.TODO()`
- Prefer `fmt.Fprintf` over `WriteString(fmt.Sprintf(...))`

## Environment variables

- Every environment variable the binary reads is registered in
`docs/envvars.md` (name, read site, moment, default) — consult it before
adding a read, and update it in the same commit when you do
(`docs/envvars_registry_test.go` fails otherwise). The moment a variable is
read decides whether the project `.env` can set it; the registry explains
the three moments.

## Git

- **All commits MUST be signed off (DCO)**. Always pass `--signoff` (`-s`) to `git commit` and `git commit --amend`.
Expand Down
97 changes: 97 additions & 0 deletions docs/envvars.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
# Environment variables Compose reads

Single registry of every environment variable the `docker compose` binary
itself recognizes: where it is read, at which moment, and its default. The
user-facing subset is documented on docs.docker.com; this file is the
exhaustive, code-accurate map, kept next to the code so a change to a read
site updates it in the same commit.

Variables consumed by services (interpolation in the compose file, `env_file`,
`environment:`) are out of scope — they belong to the model, not the binary.
So are the variables the docker CLI itself handles (`DOCKER_HOST`,
`DOCKER_CONTEXT`, `DOCKER_CONFIG`, TLS settings, …): compose inherits them
through `command.Cli` without reading them.

## When a variable is read — the three moments that decide what can set it

1. **Flag-default time** — evaluated while the cobra command tree is built,
before any file is read. Only the actual process environment works; the
project's `.env` can never influence these.
2. **After `.env` injection** — `setEnvWithDotEnv`
(`cmd/compose/compose.go`) runs early in `PersistentPreRunE` and exports
into the process environment every `COMPOSE_*` key found in the project's
env files that is not already set in the process environment. Variables
read after that point (still via `os.Getenv`/`os.LookupEnv`) can therefore
come from the shell **or** from the project `.env` — shell wins. Caveat:
the injection is skipped entirely for remote (OCI/Git) configs.
3. **Project environment** — read from `project.Environment` (the merged
os-env ∪ env-files mapping compose-go builds, os wins) after the project
is loaded. Same effective precedence as (2), but per-command: only
commands that load a project get a value.

A variable read at flag-default time and never re-read cannot be set from the
project `.env` — that asymmetry is a recurring source of bug reports; when
touching a read site, prefer moments (2) or (3).

## Project selection and loading

| Variable | Equivalent flag | Read | Default / values |
|---|---|---|---|
| `COMPOSE_FILE` | `-f` | compose-go (`cli.WithConfigFileEnv`), moment 3-ish: honored from shell and project `.env` | default file discovery (`compose.yaml`…) |
| `COMPOSE_PATH_SEPARATOR` | — | compose-go, splits `COMPOSE_FILE` | `:` (unix), `;` (windows) |
| `COMPOSE_PROJECT_NAME` | `-p` | `os.Getenv` in `projectOrName`/`toProjectName` (`cmd/compose/compose.go`), moment 2 | derived from project directory name |
| `COMPOSE_PROFILES` | `--profile` | compose-go | none |
| `COMPOSE_ENV_FILES` | `--env-file` | flag default (moment 1 — cannot come from `.env`, by construction) | `.env` in project dir |
| `COMPOSE_DISABLE_ENV_FILE` | — | compose-go | `false` |
| `COMPOSE_CONVERT_WINDOWS_PATHS` | — | compose-go (volume paths in the model) | `false` |
| `COMPOSE_COMPATIBILITY` | `--compatibility` | project environment (moment 3, `cmd/compose/compose.go` + `pkg/compose/loader.go`) | `false` |

## Runtime behavior

| Variable | Equivalent flag | Read | Default / values |
|---|---|---|---|
| `COMPOSE_PARALLEL_LIMIT` | `--parallel` | `resolveMaxConcurrency`, moment 2; flag wins | `-1` (unlimited) |
| `COMPOSE_ANSI` | `--ansi` | `resolveAnsiMode`, moment 2; flag wins. Also checked directly in `cmd/compose/hooks.go` for OSC8 hyperlinks | `auto` / `never` / `always` |
| `COMPOSE_PROGRESS` | `--progress` | flag default (moment 1 — cannot come from `.env`) | `auto` / `tty` / `plain` / `json` / `quiet` |
| `COMPOSE_STATUS_STDOUT` | — | package `init()` (moment 1) | `false`; `true` routes status/progress output to stdout instead of stderr |
| `COMPOSE_MENU` | `--menu` | `up` only, moment 2; flag wins | `true` when attached to a TTY |
| `COMPOSE_IGNORE_ORPHANS` | — | project environment (moment 3) — by `up` and `run` only; `create` shares the options struct but reads neither orphan variable | `false` |
| `COMPOSE_REMOVE_ORPHANS` | `--remove-orphans` | inconsistent (see #14139): `up` reads it at moment 2, `down`/`kill` at moment 1 (so the project `.env` works for `up` but not for `down`/`kill`), `run` never reads it | `false` |
| `DOCKER_DEFAULT_PLATFORM` | — | project environment (moment 3), resolved into `service.Platform` by `applyPlatforms` (`cmd/compose/options.go`) for every container-creating command — the value feeds the service config-hash, so commands MUST resolve it identically or containers spuriously recreate | none |

## Build

| Variable | Read | Default / values |
|---|---|---|
| `BUILDX_BUILDER` | classic (non-bake) build path picks the buildx builder by name (`cmd/compose/build.go`) | current buildx builder |
| `BUILDKIT_PROGRESS` | bake path, fallback progress mode when compose's own mode is `auto` (`pkg/compose/build_bake.go`) | — |

## Watch

| Variable | Read | Default / values |
|---|---|---|
| `COMPOSE_WATCH_WINDOWS_BUFFER_SIZE` | `pkg/watch/notify.go`, Windows only | `65536` |

## Remote configs

| Variable | Read | Default / values |
|---|---|---|
| `COMPOSE_EXPERIMENTAL_GIT_REMOTE` | `pkg/remote/git.go`, process env | `true` — despite the EXPERIMENTAL name (kept for backward compatibility) this is an opt-out: set a falsy value to disable |
| `COMPOSE_EXPERIMENTAL_OCI_REMOTE` | `pkg/remote/oci.go`, process env | `true` — same opt-out semantics as its git sibling |

## Observability

| Variable | Read | Default / values |
|---|---|---|
| `OTEL_EXPORTER_OTLP_ENDPOINT` (and standard `OTEL_*` family) | `internal/tracing`, standard OpenTelemetry SDK resolution; a Docker-context-declared OTLP endpoint is used as an additional exporter | disabled |
| `NO_COLOR` | `cmd/compose/compose.go`, `cmd/compose/hooks.go` (moment 2); any non-empty value | color enabled |

## Historical — recognized only to tell you they do nothing

`COMPOSE_EXPERIMENTAL` (unwired feature-flag mechanism) and `COMPOSE_BAKE`
(bake is simply the default build path) are gone: no code reads them, setting
them has no effect.

| Variable | Status |
|---|---|
| `COMPOSE_EXPERIMENTAL_WATCH_TAR` | read by `pkg/compose/watch.go` only to warn that a falsy value is ignored — the tar-based synchronization is the only implementation |
76 changes: 76 additions & 0 deletions docs/envvars_registry_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
/*
Copyright 2020 Docker Compose CLI authors

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package docs

import (
"io/fs"
"os"
"path/filepath"
"regexp"
"strings"
"testing"

"gotest.tools/v3/assert"
)

var composeVarLiteral = regexp.MustCompile(`"(COMPOSE_[A-Z_0-9]+)"`)

// Every COMPOSE_* variable named in production code must have a row in
// docs/envvars.md — the registry is only useful while it is exhaustive, and
// nothing but this test keeps it so. e2e sources are excluded: they set
// variables to exercise them, they don't define new ones.
func TestEnvVarRegistryIsExhaustive(t *testing.T) {
registry, err := os.ReadFile("envvars.md")
assert.NilError(t, err)

inCode := map[string][]string{}
for _, root := range []string{"../cmd", "../pkg", "../internal"} {
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
if d.Name() == "e2e" {
return filepath.SkipDir
}
return nil
}
if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") {
return nil
}
src, err := os.ReadFile(path)
if err != nil {
return err
}
for _, m := range composeVarLiteral.FindAllStringSubmatch(string(src), -1) {
name := m[1]
if name == "COMPOSE_" { // prefix checks, not variables
continue
}
inCode[name] = append(inCode[name], path)
}
return nil
})
assert.NilError(t, err)
}
assert.Assert(t, len(inCode) > 10, "the sweep found suspiciously few variables — did the source layout move?")

for name, sites := range inCode {
assert.Assert(t, strings.Contains(string(registry), "`"+name+"`"),
"%s is read in code (%s) but has no row in docs/envvars.md — add it (or remove the dead read)", name, strings.Join(sites, ", "))
}
}
Loading