detached 459b87f

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"
1313 separator = "--"
1414 tag_patterns = ["preview-*", "pr-*"]
1515 data_copy = true
16+env_copy = true
1617 ```
1718
1819 ## Options
@@ -24,6 +25,7 @@ data_copy = true
2425 | `separator` | string | `"--"` | String inserted between the base domain and the branch slug |
2526 | `tag_patterns` | string array | `[]` | Glob patterns matched against image tags to trigger preview creation |
2627 | `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 |
2729
2830 ### `ttl` Format
2931
@@ -179,8 +181,9 @@ When a push matches a `tag_patterns` entry:
179181
180182 1. Gordon creates a new route: `<app><separator><preview-name><rest-of-domain>` → pushed image
181183 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)
184187
185188 ### TTL and Automatic Teardown
186189
@@ -201,6 +204,35 @@ When `data_copy = true`, Gordon copies the named volumes attached to the base ro
201204 - Cloned volumes are removed on preview teardown unless `volumes.preserve = true`
202205 - Set `data_copy = false` to start previews with empty volumes (faster creation, no production data)
203206
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+
204236 ## Example Config
205237
206238 ```toml
@@ -216,6 +248,7 @@ ttl = "48h"
216248 separator = "--"
217249 tag_patterns = ["preview-*", "pr-*"]
218250 data_copy = true
251+env_copy = true
219252
220253 [routes]
221254 "app.example.com" = "myapp:latest"
@@ -227,9 +260,10 @@ preserve = false
227260 ```
228261
229262 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
231264 - Pushing `myapi:preview-auth-refactor` creates `myapi--auth-refactor.example.com`
232265 - 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
233267
234268 ## Related
235269
@@ -13,6 +13,7 @@ ttl = "48h"
13 separator = "--"13 separator = "--"
14 tag_patterns = ["preview-*", "pr-*"]14 tag_patterns = ["preview-*", "pr-*"]
15 data_copy = true15 data_copy = true
16+env_copy = true
16 ```17 ```
17 18
18 ## Options19 ## 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` Format30 ### `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 image182 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 preview183 2. If `data_copy = true`, volumes from the base route are cloned into the preview
182-3. The preview TTL timer starts184+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 Teardown188 ### 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 Config236 ## Example Config
205 237
206 ```toml238 ```toml
@@ -216,6 +248,7 @@ ttl = "48h"
216 separator = "--"248 separator = "--"
217 tag_patterns = ["preview-*", "pr-*"]249 tag_patterns = ["preview-*", "pr-*"]
218 data_copy = true250 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 volumes263+- 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 teardown265 - 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 ## Related268 ## Related
235 269
M internal/domain/auto.go +1 -0
@@ -20,4 +20,5 @@ type PreviewConfig struct {
2020 Separator string
2121 TagPatterns []string
2222 DataCopy bool
23+ EnvCopy bool
2324 }
@@ -20,4 +20,5 @@ type PreviewConfig struct {
20 Separator string20 Separator string
21 TagPatterns []string21 TagPatterns []string
22 DataCopy bool22 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) {
182182 PreviewConfig: domain.PreviewConfig{
183183 TTL: 2 * time.Second,
184184 Separator: "--",
185+ EnvCopy: true,
185186 },
186187 })
187188 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
388388 }
389389
390390 var baseEnv []string
391- if s.envLoader != nil {
391+ if s.envLoader != nil && req.PreviewConfig.EnvCopy {
392392 var envErr error
393393 baseEnv, envErr = s.envLoader.LoadEnv(ctx, req.BaseRoute)
394394 if envErr != nil {
@@ -388,7 +388,7 @@ func (s *Service) CreatePreview(ctx context.Context, req CreatePreviewRequest) e
388 }388 }
389 389
390 var baseEnv []string390 var baseEnv []string
391- if s.envLoader != nil {391+ if s.envLoader != nil && req.PreviewConfig.EnvCopy {
392 var envErr error392 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 {
3838 PreviewSeparator string
3939 PreviewTagPatterns []string
4040 PreviewDataCopy bool
41+ PreviewEnvCopy bool
4142 NetworkIsolation bool
4243 NetworkPrefix string
4344 Routes map[string]string
@@ -164,6 +165,7 @@ func (s *Service) loadConfigValues() Config {
164165 PreviewSeparator: previewSep,
165166 PreviewTagPatterns: append([]string{}, s.viper.GetStringSlice("auto.preview.tag_patterns")...),
166167 PreviewDataCopy: s.viper.GetBool("auto.preview.data_copy"),
168+ PreviewEnvCopy: !s.viper.IsSet("auto.preview.env_copy") || s.viper.GetBool("auto.preview.env_copy"),
167169 NetworkIsolation: s.viper.GetBool("network_isolation.enabled"),
168170 NetworkPrefix: s.viper.GetString("network_isolation.network_prefix"),
169171 RegistryAuthEnabled: s.viper.GetBool("auth.enabled"),
@@ -1104,6 +1106,7 @@ func (s *Service) GetPreviewConfig() domain.PreviewConfig {
11041106 Separator: s.config.PreviewSeparator,
11051107 TagPatterns: s.config.PreviewTagPatterns,
11061108 DataCopy: s.config.PreviewDataCopy,
1109+ EnvCopy: s.config.PreviewEnvCopy,
11071110 }
11081111 }
11091112
@@ -38,6 +38,7 @@ type Config struct {
38 PreviewSeparator string38 PreviewSeparator string
39 PreviewTagPatterns []string39 PreviewTagPatterns []string
40 PreviewDataCopy bool40 PreviewDataCopy bool
41+ PreviewEnvCopy bool
41 NetworkIsolation bool42 NetworkIsolation bool
42 NetworkPrefix string43 NetworkPrefix string
43 Routes map[string]string44 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

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close