From a0f0cee5388a21ac0a3b5c29d3883bf8ea4c6fc5 Mon Sep 17 00:00:00 2001 From: Major Date: Wed, 22 Jul 2026 10:08:18 +0200 Subject: [PATCH] feat(config): support Docker secrets via API_KEY_FILE MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit API_KEY_FILE points to a file whose content becomes the API key(s), following the _FILE convention of official Docker images — e.g. API_KEY_FILE=/run/secrets/immich_api_key with a Swarm or compose secret. The content is trimmed (secret files usually end with a newline) and supports the same comma-separated multi-key format as API_KEY. Setting both API_KEY and API_KEY_FILE is an error so there is never an ambiguity about which key is in use. The docs already recommended Docker secrets as a best practice without the tool being able to consume them; the integration pages now show how. Closes #73 --- cmd/config.go | 16 +++++++ cmd/config_test.go | 47 ++++++++++++++++++++- docs/api-reference/environment-variables.md | 11 +++-- docs/integration/docker-compose.md | 20 +++++++++ docs/integration/docker.md | 4 +- 5 files changed, 92 insertions(+), 6 deletions(-) diff --git a/cmd/config.go b/cmd/config.go index 1988013..a710e72 100644 --- a/cmd/config.go +++ b/cmd/config.go @@ -267,6 +267,22 @@ func LoadEnvForTesting() LoadEnvConfig { if apiKey == "" { apiKey = os.Getenv("API_KEY") } + /********************************************************************************************** + ** Docker secrets support (issue #73): API_KEY_FILE points to a file whose content is the + ** API key(s), e.g. /run/secrets/immich_api_key. Mutually exclusive with API_KEY so there + ** is never an ambiguity about which key is in use. Secret files commonly end with a + ** newline, hence the TrimSpace. + **********************************************************************************************/ + if apiKeyFile := os.Getenv("API_KEY_FILE"); apiKeyFile != "" { + if apiKey != "" { + return LoadEnvConfig{Logger: logger, Error: fmt.Errorf("API_KEY (or --api-key) and API_KEY_FILE are mutually exclusive; set only one")} + } + content, err := os.ReadFile(apiKeyFile) + if err != nil { + return LoadEnvConfig{Logger: logger, Error: fmt.Errorf("failed to read API_KEY_FILE %s: %w", apiKeyFile, err)} + } + apiKey = strings.TrimSpace(string(content)) + } if apiKey == "" { return LoadEnvConfig{Logger: logger, Error: fmt.Errorf("API_KEY is not set")} } diff --git a/cmd/config_test.go b/cmd/config_test.go index e0e670e..867ee24 100644 --- a/cmd/config_test.go +++ b/cmd/config_test.go @@ -373,7 +373,7 @@ func TestLogLevelConfiguration(t *testing.T) { // Helper function to reset test environment func resetTestEnv() { envVars := []string{ - "API_KEY", "API_URL", "RUN_MODE", "CRON_INTERVAL", + "API_KEY", "API_KEY_FILE", "API_URL", "RUN_MODE", "CRON_INTERVAL", "LOG_LEVEL", "LOG_FORMAT", "LOG_FILE", "DRY_RUN", "RESET_STACKS", "CONFIRM_RESET_STACK", "REPLACE_STACKS", "WITH_ARCHIVED", "WITH_DELETED", @@ -617,3 +617,48 @@ func TestExplicitFalseFlagsBeatEnv(t *testing.T) { t.Error("explicit --fix-trash-after-stacking=false must not be overridden by FIX_TRASH_AFTER_STACKING=true") } } + +func TestAPIKeyFile(t *testing.T) { + tests := []struct { + name string + fileContent string + createFile bool + alsoSetAPIKey bool + wantKey string + wantError string + }{ + {name: "reads the key from the file", createFile: true, fileContent: "secret-key", wantKey: "secret-key"}, + {name: "trims the trailing newline", createFile: true, fileContent: "secret-key\n", wantKey: "secret-key"}, + {name: "supports comma-separated keys", createFile: true, fileContent: "key1,key2\n", wantKey: "key1,key2"}, + {name: "missing file is an error", createFile: false, wantError: "failed to read API_KEY_FILE"}, + {name: "mutually exclusive with API_KEY", createFile: true, fileContent: "secret-key", alsoSetAPIKey: true, wantError: "mutually exclusive"}, + {name: "empty file leaves the key unset", createFile: true, fileContent: "\n", wantError: "API_KEY is not set"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + resetTestEnv() + defer resetTestEnv() + + path := t.TempDir() + "/api_key" + if tt.createFile { + if err := os.WriteFile(path, []byte(tt.fileContent), 0o600); err != nil { + t.Fatalf("writing secret file: %v", err) + } + } + os.Setenv("API_KEY_FILE", path) + if tt.alsoSetAPIKey { + os.Setenv("API_KEY", "env-key") + } + + config := LoadEnvForTesting() + + if tt.wantError != "" { + assert.ErrorContains(t, config.Error, tt.wantError) + return + } + assert.NoError(t, config.Error) + assert.Equal(t, tt.wantKey, apiKey) + }) + } +} diff --git a/docs/api-reference/environment-variables.md b/docs/api-reference/environment-variables.md index 85f406e..a4ad0d9 100644 --- a/docs/api-reference/environment-variables.md +++ b/docs/api-reference/environment-variables.md @@ -4,10 +4,13 @@ This document provides a complete reference of all environment variables support ## Required Variables -| Variable | Description | Example | -| --------- | ------------------- | -------------------------------- | -| `API_KEY` | Immich API key(s) | `API_KEY=key1,key2` | -| `API_URL` | Immich API base URL | `API_URL=http://immich:2283/api` | +| Variable | Description | Example | +| -------------- | -------------------------------------------------------- | -------------------------------------- | +| `API_KEY` | Immich API key(s) | `API_KEY=key1,key2` | +| `API_KEY_FILE` | Read the API key(s) from a file (Docker secrets support) | `API_KEY_FILE=/run/secrets/immich_key` | +| `API_URL` | Immich API base URL | `API_URL=http://immich:2283/api` | + +`API_KEY` and `API_KEY_FILE` are mutually exclusive — set only one. The file content is trimmed (secret files usually end with a newline) and supports the same comma-separated multi-key format as `API_KEY`. ## Run Mode Configuration diff --git a/docs/integration/docker-compose.md b/docs/integration/docker-compose.md index 06a3e19..cef7226 100644 --- a/docs/integration/docker-compose.md +++ b/docs/integration/docker-compose.md @@ -92,6 +92,26 @@ To integrate with an existing Immich installation: docker compose up -d ``` +## Docker Secrets + +To keep the API key out of environment variables and `.env` files, use `API_KEY_FILE` with a Docker secret. `API_KEY` and `API_KEY_FILE` are mutually exclusive; the secret file supports the same comma-separated multi-key format. + +```yaml +services: + immich-stack: + container_name: immich_stack + image: ghcr.io/majorfi/immich-stack:latest + environment: + - API_KEY_FILE=/run/secrets/immich_api_key + - API_URL=http://immich-server:2283/api + secrets: + - immich_api_key + +secrets: + immich_api_key: + file: ./secrets/immich_api_key.txt +``` + ## Logging Configuration ### Viewing Logs diff --git a/docs/integration/docker.md b/docs/integration/docker.md index 5ada3e2..73d427e 100644 --- a/docs/integration/docker.md +++ b/docs/integration/docker.md @@ -181,6 +181,8 @@ docker run -d \ 1. **Security:** - - Use Docker secrets for sensitive data + - Use Docker secrets for sensitive data: point `API_KEY_FILE` at the mounted secret + (e.g. `API_KEY_FILE=/run/secrets/immich_api_key`) instead of putting the key in + `API_KEY` - Restrict container capabilities - Use non-root user