feat(preview): add env_copy config option (default true)
Env var inheritance is now gated by auto.preview.env_copy, matching the pattern of data_copy for volumes. Defaults to true when not set. Documents the feature including override and disable options.
- commit
- 459b87fe99a463d2efec9d03b5fa5ef4e3f33276
- author
- brice · · 2026-03-18 18:24 UTC
- committer
- brice · · 2026-03-18 22:59 UTC
- parent
- 9b3f809
- signature
- unsigned
5 files · +43 -4
M docs/config/preview.md +37 -3
| @@ -13,6 +13,7 @@ ttl = "48h" | |||
| 13 | 13 | separator = "--" | |
| 14 | 14 | tag_patterns = ["preview-*", "pr-*"] | |
| 15 | 15 | data_copy = true | |
| 16 | + | env_copy = true | |
| 16 | 17 | ``` | |
| 17 | 18 | ||
| 18 | 19 | ## Options | |
| @@ -24,6 +25,7 @@ data_copy = true | |||
| 24 | 25 | | `separator` | string | `"--"` | String inserted between the base domain and the branch slug | | |
| 25 | 26 | | `tag_patterns` | string array | `[]` | Glob patterns matched against image tags to trigger preview creation | | |
| 26 | 27 | | `data_copy` | bool | `true` | Clone the production route's volumes into the preview environment on creation | | |
| 28 | + | | `env_copy` | bool | `true` | Inherit environment variables from the base route's secret store into the preview container | | |
| 27 | 29 | ||
| 28 | 30 | ### `ttl` Format | |
| 29 | 31 | ||
| @@ -179,8 +181,9 @@ When a push matches a `tag_patterns` entry: | |||
| 179 | 181 | ||
| 180 | 182 | 1. Gordon creates a new route: `<app><separator><preview-name><rest-of-domain>` → pushed image | |
| 181 | 183 | 2. If `data_copy = true`, volumes from the base route are cloned into the preview | |
| 182 | - | 3. The preview TTL timer starts | |
| 183 | - | 4. The container is deployed with zero-downtime rules disabled (previews are always cold-starts) | |
| 184 | + | 3. If `env_copy = true`, environment variables from the base route's secret store are loaded and passed to the preview container (without persisting them) | |
| 185 | + | 4. The preview TTL timer starts | |
| 186 | + | 5. The container is deployed with zero-downtime rules disabled (previews are always cold-starts) | |
| 184 | 187 | ||
| 185 | 188 | ### TTL and Automatic Teardown | |
| 186 | 189 | ||
| @@ -201,6 +204,35 @@ When `data_copy = true`, Gordon copies the named volumes attached to the base ro | |||
| 201 | 204 | - Cloned volumes are removed on preview teardown unless `volumes.preserve = true` | |
| 202 | 205 | - Set `data_copy = false` to start previews with empty volumes (faster creation, no production data) | |
| 203 | 206 | ||
| 207 | + | ### Environment Variable Inheritance | |
| 208 | + | ||
| 209 | + | Preview containers receive environment variables from three sources, merged in this order (last wins): | |
| 210 | + | ||
| 211 | + | 1. **Dockerfile `ENV`** — baked into the image at build time | |
| 212 | + | 2. **Base route secrets** — inherited from the production domain's secret store on first deploy (when `env_copy = true`) | |
| 213 | + | 3. **Preview domain secrets** — set directly on the preview domain, picked up on redeploy | |
| 214 | + | ||
| 215 | + | On initial creation, Gordon loads the base route's secrets and passes them to the container. The variables are **not** persisted under the preview domain — they are resolved at deploy time. | |
| 216 | + | ||
| 217 | + | #### Overriding variables for a preview | |
| 218 | + | ||
| 219 | + | To change a variable for a specific preview, set it on the preview domain: | |
| 220 | + | ||
| 221 | + | ```bash | |
| 222 | + | gordon secrets set gordon--pr42.example.com API_URL=https://staging-api.example.com | |
| 223 | + | ``` | |
| 224 | + | ||
| 225 | + | This writes the secret to the preview domain's store and triggers an automatic redeploy (~60s debounce). On redeploy, the container picks up its own domain secrets instead of the inherited base route ones. | |
| 226 | + | ||
| 227 | + | #### Disabling env inheritance | |
| 228 | + | ||
| 229 | + | To start previews with a clean environment (Dockerfile `ENV` only): | |
| 230 | + | ||
| 231 | + | ```toml | |
| 232 | + | [auto.preview] | |
| 233 | + | env_copy = false | |
| 234 | + | ``` | |
| 235 | + | ||
| 204 | 236 | ## Example Config | |
| 205 | 237 | ||
| 206 | 238 | ```toml | |
| @@ -216,6 +248,7 @@ ttl = "48h" | |||
| 216 | 248 | separator = "--" | |
| 217 | 249 | tag_patterns = ["preview-*", "pr-*"] | |
| 218 | 250 | data_copy = true | |
| 251 | + | env_copy = true | |
| 219 | 252 | ||
| 220 | 253 | [routes] | |
| 221 | 254 | "app.example.com" = "myapp:latest" | |
| @@ -227,9 +260,10 @@ preserve = false | |||
| 227 | 260 | ``` | |
| 228 | 261 | ||
| 229 | 262 | With this config: | |
| 230 | - | - Pushing `myapp:pr-99` creates `myapp--99.example.com` with cloned volumes | |
| 263 | + | - Pushing `myapp:pr-99` creates `myapp--99.example.com` with cloned volumes and inherited env vars | |
| 231 | 264 | - Pushing `myapi:preview-auth-refactor` creates `myapi--auth-refactor.example.com` | |
| 232 | 265 | - Both previews expire after 48 hours and volumes are removed on teardown | |
| 266 | + | - Env vars are loaded at deploy time from the base route's secret store and are not persisted for the preview domain | |
| 233 | 267 | ||
| 234 | 268 | ## Related | |
| 235 | 269 | ||
| @@ -13,6 +13,7 @@ ttl = "48h" | |||||
| 13 | separator = "--" | 13 | separator = "--" | ||
| 14 | tag_patterns = ["preview-*", "pr-*"] | 14 | tag_patterns = ["preview-*", "pr-*"] | ||
| 15 | data_copy = true | 15 | data_copy = true | ||
| 16 | + | env_copy = true | |||
| 16 | ``` | 17 | ``` | ||
| 17 | 18 | ||||
| 18 | ## Options | 19 | ## Options | ||
| @@ -24,6 +25,7 @@ data_copy = true | |||||
| 24 | | `separator` | string | `"--"` | String inserted between the base domain and the branch slug | | 25 | | `separator` | string | `"--"` | String inserted between the base domain and the branch slug | | ||
| 25 | | `tag_patterns` | string array | `[]` | Glob patterns matched against image tags to trigger preview creation | | 26 | | `tag_patterns` | string array | `[]` | Glob patterns matched against image tags to trigger preview creation | | ||
| 26 | | `data_copy` | bool | `true` | Clone the production route's volumes into the preview environment on creation | | 27 | | `data_copy` | bool | `true` | Clone the production route's volumes into the preview environment on creation | | ||
| 28 | + | | `env_copy` | bool | `true` | Inherit environment variables from the base route's secret store into the preview container | | |||
| 27 | 29 | ||||
| 28 | ### `ttl` Format | 30 | ### `ttl` Format | ||
| 29 | 31 | ||||
| @@ -179,8 +181,9 @@ When a push matches a `tag_patterns` entry: | |||||
| 179 | 181 | ||||
| 180 | 1. Gordon creates a new route: `<app><separator><preview-name><rest-of-domain>` → pushed image | 182 | 1. Gordon creates a new route: `<app><separator><preview-name><rest-of-domain>` → pushed image | ||
| 181 | 2. If `data_copy = true`, volumes from the base route are cloned into the preview | 183 | 2. If `data_copy = true`, volumes from the base route are cloned into the preview | ||
| 182 | - | 3. The preview TTL timer starts | 184 | + | 3. If `env_copy = true`, environment variables from the base route's secret store are loaded and passed to the preview container (without persisting them) |
| 183 | - | 4. The container is deployed with zero-downtime rules disabled (previews are always cold-starts) | 185 | + | 4. The preview TTL timer starts |
| 186 | + | 5. The container is deployed with zero-downtime rules disabled (previews are always cold-starts) | |||
| 184 | 187 | ||||
| 185 | ### TTL and Automatic Teardown | 188 | ### TTL and Automatic Teardown | ||
| 186 | 189 | ||||
| @@ -201,6 +204,35 @@ When `data_copy = true`, Gordon copies the named volumes attached to the base ro | |||||
| 201 | - Cloned volumes are removed on preview teardown unless `volumes.preserve = true` | 204 | - Cloned volumes are removed on preview teardown unless `volumes.preserve = true` | ||
| 202 | - Set `data_copy = false` to start previews with empty volumes (faster creation, no production data) | 205 | - Set `data_copy = false` to start previews with empty volumes (faster creation, no production data) | ||
| 203 | 206 | ||||
| 207 | + | ### Environment Variable Inheritance | |||
| 208 | + | ||||
| 209 | + | Preview containers receive environment variables from three sources, merged in this order (last wins): | |||
| 210 | + | ||||
| 211 | + | 1. **Dockerfile `ENV`** — baked into the image at build time | |||
| 212 | + | 2. **Base route secrets** — inherited from the production domain's secret store on first deploy (when `env_copy = true`) | |||
| 213 | + | 3. **Preview domain secrets** — set directly on the preview domain, picked up on redeploy | |||
| 214 | + | ||||
| 215 | + | On initial creation, Gordon loads the base route's secrets and passes them to the container. The variables are **not** persisted under the preview domain — they are resolved at deploy time. | |||
| 216 | + | ||||
| 217 | + | #### Overriding variables for a preview | |||
| 218 | + | ||||
| 219 | + | To change a variable for a specific preview, set it on the preview domain: | |||
| 220 | + | ||||
| 221 | + | ```bash | |||
| 222 | + | gordon secrets set gordon--pr42.example.com API_URL=https://staging-api.example.com | |||
| 223 | + | ``` | |||
| 224 | + | ||||
| 225 | + | This writes the secret to the preview domain's store and triggers an automatic redeploy (~60s debounce). On redeploy, the container picks up its own domain secrets instead of the inherited base route ones. | |||
| 226 | + | ||||
| 227 | + | #### Disabling env inheritance | |||
| 228 | + | ||||
| 229 | + | To start previews with a clean environment (Dockerfile `ENV` only): | |||
| 230 | + | ||||
| 231 | + | ```toml | |||
| 232 | + | [auto.preview] | |||
| 233 | + | env_copy = false | |||
| 234 | + | ``` | |||
| 235 | + | ||||
| 204 | ## Example Config | 236 | ## Example Config | ||
| 205 | 237 | ||||
| 206 | ```toml | 238 | ```toml | ||
| @@ -216,6 +248,7 @@ ttl = "48h" | |||||
| 216 | separator = "--" | 248 | separator = "--" | ||
| 217 | tag_patterns = ["preview-*", "pr-*"] | 249 | tag_patterns = ["preview-*", "pr-*"] | ||
| 218 | data_copy = true | 250 | data_copy = true | ||
| 251 | + | env_copy = true | |||
| 219 | 252 | ||||
| 220 | [routes] | 253 | [routes] | ||
| 221 | "app.example.com" = "myapp:latest" | 254 | "app.example.com" = "myapp:latest" | ||
| @@ -227,9 +260,10 @@ preserve = false | |||||
| 227 | ``` | 260 | ``` | ||
| 228 | 261 | ||||
| 229 | With this config: | 262 | With this config: | ||
| 230 | - | - Pushing `myapp:pr-99` creates `myapp--99.example.com` with cloned volumes | 263 | + | - Pushing `myapp:pr-99` creates `myapp--99.example.com` with cloned volumes and inherited env vars |
| 231 | - Pushing `myapi:preview-auth-refactor` creates `myapi--auth-refactor.example.com` | 264 | - Pushing `myapi:preview-auth-refactor` creates `myapi--auth-refactor.example.com` | ||
| 232 | - Both previews expire after 48 hours and volumes are removed on teardown | 265 | - Both previews expire after 48 hours and volumes are removed on teardown | ||
| 266 | + | - Env vars are loaded at deploy time from the base route's secret store and are not persisted for the preview domain | |||
| 233 | 267 | ||||
| 234 | ## Related | 268 | ## Related | ||
| 235 | 269 | ||||
M internal/domain/auto.go +1 -0
| @@ -20,4 +20,5 @@ type PreviewConfig struct { | |||
| 20 | 20 | Separator string | |
| 21 | 21 | TagPatterns []string | |
| 22 | 22 | DataCopy bool | |
| 23 | + | EnvCopy bool | |
| 23 | 24 | } | |
| @@ -20,4 +20,5 @@ type PreviewConfig struct { | |||||
| 20 | Separator string | 20 | Separator string | ||
| 21 | TagPatterns []string | 21 | TagPatterns []string | ||
| 22 | DataCopy bool | 22 | DataCopy bool | ||
| 23 | + | EnvCopy bool | |||
| 23 | } | 24 | } | ||
M internal/usecase/auto/preview/integration_test.go +1 -0
| @@ -182,6 +182,7 @@ func TestPreviewLifecycle_WithEnvInheritance(t *testing.T) { | |||
| 182 | 182 | PreviewConfig: domain.PreviewConfig{ | |
| 183 | 183 | TTL: 2 * time.Second, | |
| 184 | 184 | Separator: "--", | |
| 185 | + | EnvCopy: true, | |
| 185 | 186 | }, | |
| 186 | 187 | }) | |
| 187 | 188 | require.NoError(t, err) | |
| @@ -182,6 +182,7 @@ func TestPreviewLifecycle_WithEnvInheritance(t *testing.T) { | |||||
| 182 | PreviewConfig: domain.PreviewConfig{ | 182 | PreviewConfig: domain.PreviewConfig{ | ||
| 183 | TTL: 2 * time.Second, | 183 | TTL: 2 * time.Second, | ||
| 184 | Separator: "--", | 184 | Separator: "--", | ||
| 185 | + | EnvCopy: true, | |||
| 185 | }, | 186 | }, | ||
| 186 | }) | 187 | }) | ||
| 187 | require.NoError(t, err) | 188 | require.NoError(t, err) | ||
M internal/usecase/auto/preview/service.go +1 -1
| @@ -388,7 +388,7 @@ func (s *Service) CreatePreview(ctx context.Context, req CreatePreviewRequest) e | |||
| 388 | 388 | } | |
| 389 | 389 | ||
| 390 | 390 | var baseEnv []string | |
| 391 | - | if s.envLoader != nil { | |
| 391 | + | if s.envLoader != nil && req.PreviewConfig.EnvCopy { | |
| 392 | 392 | var envErr error | |
| 393 | 393 | baseEnv, envErr = s.envLoader.LoadEnv(ctx, req.BaseRoute) | |
| 394 | 394 | if envErr != nil { | |
| @@ -388,7 +388,7 @@ func (s *Service) CreatePreview(ctx context.Context, req CreatePreviewRequest) e | |||||
| 388 | } | 388 | } | ||
| 389 | 389 | ||||
| 390 | var baseEnv []string | 390 | var baseEnv []string | ||
| 391 | - | if s.envLoader != nil { | 391 | + | if s.envLoader != nil && req.PreviewConfig.EnvCopy { |
| 392 | var envErr error | 392 | var envErr error | ||
| 393 | baseEnv, envErr = s.envLoader.LoadEnv(ctx, req.BaseRoute) | 393 | baseEnv, envErr = s.envLoader.LoadEnv(ctx, req.BaseRoute) | ||
| 394 | if envErr != nil { | 394 | if envErr != nil { | ||
M internal/usecase/config/service.go +3 -0
| @@ -38,6 +38,7 @@ type Config struct { | |||
| 38 | 38 | PreviewSeparator string | |
| 39 | 39 | PreviewTagPatterns []string | |
| 40 | 40 | PreviewDataCopy bool | |
| 41 | + | PreviewEnvCopy bool | |
| 41 | 42 | NetworkIsolation bool | |
| 42 | 43 | NetworkPrefix string | |
| 43 | 44 | Routes map[string]string | |
| @@ -164,6 +165,7 @@ func (s *Service) loadConfigValues() Config { | |||
| 164 | 165 | PreviewSeparator: previewSep, | |
| 165 | 166 | PreviewTagPatterns: append([]string{}, s.viper.GetStringSlice("auto.preview.tag_patterns")...), | |
| 166 | 167 | PreviewDataCopy: s.viper.GetBool("auto.preview.data_copy"), | |
| 168 | + | PreviewEnvCopy: !s.viper.IsSet("auto.preview.env_copy") || s.viper.GetBool("auto.preview.env_copy"), | |
| 167 | 169 | NetworkIsolation: s.viper.GetBool("network_isolation.enabled"), | |
| 168 | 170 | NetworkPrefix: s.viper.GetString("network_isolation.network_prefix"), | |
| 169 | 171 | RegistryAuthEnabled: s.viper.GetBool("auth.enabled"), | |
| @@ -1104,6 +1106,7 @@ func (s *Service) GetPreviewConfig() domain.PreviewConfig { | |||
| 1104 | 1106 | Separator: s.config.PreviewSeparator, | |
| 1105 | 1107 | TagPatterns: s.config.PreviewTagPatterns, | |
| 1106 | 1108 | DataCopy: s.config.PreviewDataCopy, | |
| 1109 | + | EnvCopy: s.config.PreviewEnvCopy, | |
| 1107 | 1110 | } | |
| 1108 | 1111 | } | |
| 1109 | 1112 | ||
| @@ -38,6 +38,7 @@ type Config struct { | |||||
| 38 | PreviewSeparator string | 38 | PreviewSeparator string | ||
| 39 | PreviewTagPatterns []string | 39 | PreviewTagPatterns []string | ||
| 40 | PreviewDataCopy bool | 40 | PreviewDataCopy bool | ||
| 41 | + | PreviewEnvCopy bool | |||
| 41 | NetworkIsolation bool | 42 | NetworkIsolation bool | ||
| 42 | NetworkPrefix string | 43 | NetworkPrefix string | ||
| 43 | Routes map[string]string | 44 | Routes map[string]string | ||
| @@ -164,6 +165,7 @@ func (s *Service) loadConfigValues() Config { | |||||
| 164 | PreviewSeparator: previewSep, | 165 | PreviewSeparator: previewSep, | ||
| 165 | PreviewTagPatterns: append([]string{}, s.viper.GetStringSlice("auto.preview.tag_patterns")...), | 166 | PreviewTagPatterns: append([]string{}, s.viper.GetStringSlice("auto.preview.tag_patterns")...), | ||
| 166 | PreviewDataCopy: s.viper.GetBool("auto.preview.data_copy"), | 167 | PreviewDataCopy: s.viper.GetBool("auto.preview.data_copy"), | ||
| 168 | + | PreviewEnvCopy: !s.viper.IsSet("auto.preview.env_copy") || s.viper.GetBool("auto.preview.env_copy"), | |||
| 167 | NetworkIsolation: s.viper.GetBool("network_isolation.enabled"), | 169 | NetworkIsolation: s.viper.GetBool("network_isolation.enabled"), | ||
| 168 | NetworkPrefix: s.viper.GetString("network_isolation.network_prefix"), | 170 | NetworkPrefix: s.viper.GetString("network_isolation.network_prefix"), | ||
| 169 | RegistryAuthEnabled: s.viper.GetBool("auth.enabled"), | 171 | RegistryAuthEnabled: s.viper.GetBool("auth.enabled"), | ||
| @@ -1104,6 +1106,7 @@ func (s *Service) GetPreviewConfig() domain.PreviewConfig { | |||||
| 1104 | Separator: s.config.PreviewSeparator, | 1106 | Separator: s.config.PreviewSeparator, | ||
| 1105 | TagPatterns: s.config.PreviewTagPatterns, | 1107 | TagPatterns: s.config.PreviewTagPatterns, | ||
| 1106 | DataCopy: s.config.PreviewDataCopy, | 1108 | DataCopy: s.config.PreviewDataCopy, | ||
| 1109 | + | EnvCopy: s.config.PreviewEnvCopy, | |||
| 1107 | } | 1110 | } | ||
| 1108 | } | 1111 | } | ||
| 1109 | 1112 | ||||