Skip to content
Merged
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
16 changes: 16 additions & 0 deletions cmd/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")}
}
Expand Down
47 changes: 46 additions & 1 deletion cmd/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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)
})
}
}
11 changes: 7 additions & 4 deletions docs/api-reference/environment-variables.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
20 changes: 20 additions & 0 deletions docs/integration/docker-compose.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion docs/integration/docker.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading