detached 57617f5

feat(env): Add environment variable support with secret management

- Implement environment variable configuration in the application, allowing for per-route environment files.
- Introduce support for secret providers like `pass` and `sops` for secure credential management.
- Enhance container deployment to load environment variables from the created `.env` files.
- Update README and example configuration files to reflect new environment variable usage and setup instructions.
commit
57617f58615cb63352f92248f07198c51572efca
author
bamen · · 2025-06-08 09:18 UTC
parent
975227e
signature
unsigned
13 files · +965 -49
M README.md +158 -41
@@ -29,8 +29,8 @@ podman build -t myapp .
2929 podman run -p 8080:8080 myapp # Works? Great!
3030
3131 # 2. Push to deploy
32-podman tag myapp registry.yourdomain.com/myapp:latest
33-podman push registry.yourdomain.com/myapp:latest
32+podman tag myapp registry.mydomain.com/myapp:latest
33+podman push registry.mydomain.com/myapp:latest
3434
3535 # 3. That's it. Gordon deploys it automatically.
3636 ```
@@ -43,10 +43,10 @@ Something broke? Just change your config:
4343
4444 ```toml
4545 # Before (in gordon.toml)
46-"app.yourdomain.com" = "myapp:latest"
46+"app.mydomain.com" = "myapp:latest"
4747
4848 # After - instant rollback!
49-"app.yourdomain.com" = "myapp:v1.2.3"
49+"app.mydomain.com" = "myapp:v1.2.3"
5050 ```
5151
5252 Save the file. Gordon redeploys the previous version. Problem solved in seconds.
@@ -54,7 +54,7 @@ Save the file. Gordon redeploys the previous version. Problem solved in seconds.
5454 ## Key Features
5555
5656 ### Local-First Development
57-**Your machine is the build server.** Test locally with Podman's rootless containers, push when ready. No waiting for remote builds.
57+**Your machine is the build server.** Test locally with your favorite container engine, push when ready. No waiting for remote builds.
5858
5959 ### Built-in Container Registry
6060 Your VPS becomes a private container registry (Docker/Podman compatible). No Docker Hub subscription needed.
@@ -124,10 +124,10 @@ systemctl --user enable --now podman.socket
124124 mkdir -p ~/.config/containers
125125 tee ~/.config/containers/registries.conf > /dev/null <<EOF
126126 [registries.search]
127-registries = ['docker.io', 'registry.yourdomain.com']
127+registries = ['docker.io', 'registry.mydomain.com']
128128
129129 [registries.insecure]
130-registries = ['registry.yourdomain.com']
130+registries = ['registry.mydomain.com']
131131
132132 [registries.block]
133133 registries = []
@@ -147,7 +147,7 @@ sudo mv gordon-linux-amd64 /usr/local/bin/gordon
147147 # (or use --config flag)
148148 [server]
149149 port = 8080
150-registry_domain = "registry.yourdomain.com"
150+registry_domain = "registry.mydomain.com"
151151 runtime = "podman-rootless" # Can be auto, docker, podman, podman-rootless
152152
153153 [registry_auth]
@@ -156,9 +156,9 @@ username = "admin"
156156 password = "your-secure-password"
157157
158158 [routes]
159-"app.yourdomain.com" = "myapp:latest"
160-"api.yourdomain.com" = "api:v1"
161-"blog.yourdomain.com" = "wordpress:latest"
159+"app.mydomain.com" = "myapp:latest"
160+"api.mydomain.com" = "api:v1"
161+"blog.mydomain.com" = "wordpress:latest"
162162
163163 [auto_route]
164164 enabled = false # Enable automatic route creation
@@ -169,8 +169,8 @@ enabled = false # Enable automatic route creation
169169
170170 ### 7. Point Cloudflare DNS
171171 ```
172-A *.yourdomain.com → YOUR_VPS_IP
173-A yourdomain.com → YOUR_VPS_IP
172+A *.mydomain.com → YOUR_VPS_IP
173+A mydomain.com → YOUR_VPS_IP
174174 ```
175175
176176 ### 8. Create Systemd Service (Rootless)
@@ -211,13 +211,13 @@ systemctl --user status gordon
211211 ```bash
212212 # Now from your local development machine:
213213 # Authenticate with your Gordon registry
214-podman login registry.yourdomain.com
214+podman login registry.mydomain.com
215215 # Use the username/password from your gordon.toml config
216216
217217 # Deploy your first app (using podman)
218-podman tag myapp:latest registry.yourdomain.com/myapp:latest
219-podman push registry.yourdomain.com/myapp:latest
220-# Visit https://app.yourdomain.com
218+podman tag myapp:latest registry.mydomain.com/myapp:latest
219+podman push registry.mydomain.com/myapp:latest
220+# Visit https://app.mydomain.com
221221 ```
222222
223223 ## Real-World Examples
@@ -231,13 +231,13 @@ podman build -t myapp .
231231 podman run -p 3000:3000 myapp # Test it!
232232
233233 # 2. In gordon.toml, point your domain to the 'latest' tag
234-# "app.yourdomain.com" = "myapp:latest"
234+# "app.mydomain.com" = "myapp:latest"
235235
236236 # 3. Tag your image as 'latest' for your registry
237-podman tag myapp registry.yourdomain.com/myapp:latest
237+podman tag myapp registry.mydomain.com/myapp:latest
238238
239239 # 4. Push to deploy
240-podman push registry.yourdomain.com/myapp:latest
240+podman push registry.mydomain.com/myapp:latest
241241
242242 # Gordon automatically detects the push and deploys the new 'latest' version.
243243 ```
@@ -247,20 +247,18 @@ For more control, especially in production, you can use a combination of version
247247
248248 ```bash
249249 # 1. Always push a specific version tag first. This creates a history.
250-podman tag myapp registry.yourdomain.com/myapp:v1.0.1
251-podman push registry.yourdomain.com/myapp:v1.0.1
250+podman tag myapp registry.mydomain.com/myapp:v1.0.1
251+podman push registry.mydomain.com/myapp:v1.0.1
252252
253253 # 2. Test the new version on a staging domain.
254254 # In gordon.toml:
255-"staging.yourdomain.com" = "myapp:v1.0.1"
255+"staging.mydomain.com" = "myapp:v1.0.1"
256+# Get your production route ready :
257+"app.mydomain.com" = "myapp:latest"
256258
257259 # 3. Once tested and confirmed, promote it to production by updating 'latest'.
258-# This assumes 'myapp' still refers to the image for v1.0.1
259-podman tag myapp registry.yourdomain.com/myapp:latest
260-podman push registry.yourdomain.com/myapp:latest
261-
262-# Your production route, which follows 'latest', will now be updated automatically.
263-# "app.yourdomain.com" = "myapp:latest"
260+podman tag myapp registry.mydomain.com/myapp:latest
261+podman push registry.mydomain.com/myapp:latest
264262 ```
265263
266264 ### Instant Rollback When Things Break
@@ -269,10 +267,10 @@ podman push registry.yourdomain.com/myapp:latest
269267 # Just edit gordon.toml to point to a previously pushed, stable version tag:
270268
271269 # From:
272-"app.yourdomain.com" = "myapp:latest"
270+"app.mydomain.com" = "myapp:latest"
273271
274272 # To:
275-"app.yourdomain.com" = "myapp:v1.0.0"
273+"app.mydomain.com" = "myapp:v1.0.0"
276274
277275 # Save the file. Gordon redeploys the stable version in seconds. No scripts, no drama.
278276 ```
@@ -295,8 +293,8 @@ When you push an image with a domain name as the image name, Gordon automaticall
295293 podman build -t myapp .
296294
297295 # Tag with domain name as the image name
298-podman tag myapp:latest registry.yourdomain.com/myapp.mydomain.dev:latest
299-podman push registry.yourdomain.com/myapp.mydomain.dev:latest
296+podman tag myapp:latest registry.mydomain.com/myapp.mydomain.dev:latest
297+podman push registry.mydomain.com/myapp.mydomain.dev:latest
300298
301299 # Gordon automatically creates:
302300 # "myapp.mydomain.dev" = "myapp.mydomain.dev:latest"
@@ -309,11 +307,11 @@ podman push registry.yourdomain.com/myapp.mydomain.dev:latest
309307 podman build -t myapp .
310308
311309 # Push to different test domains using image names
312-podman tag myapp:latest registry.yourdomain.com/staging.example.com:latest
313-podman push registry.yourdomain.com/staging.example.com:latest
310+podman tag myapp:latest registry.mydomain.com/staging.example.com:latest
311+podman push registry.mydomain.com/staging.example.com:latest
314312
315-podman tag myapp:latest registry.yourdomain.com/demo.example.com:v1.0.0
316-podman push registry.yourdomain.com/demo.example.com:v1.0.0
313+podman tag myapp:latest registry.mydomain.com/demo.example.com:v1.0.0
314+podman push registry.mydomain.com/demo.example.com:v1.0.0
317315
318316 # Both automatically get their own routes:
319317 # staging.example.com -> staging.example.com:latest
@@ -358,7 +356,7 @@ A: You can in theory, but exposing databases on the internet is risky. Database/
358356 A: Runs comfortably on 1GB RAM VPS. Gordon itself uses <15MB of RAM.
359357
360358 **Q: What about secrets?**
361-A: Use environment variables in your container or Docker secrets. Gordon doesn't interfere.
359+A: Gordon provides built-in environment variable support with secret management via `pass` and `sops`. See the Environment Variables section below.
362360
363361 **Q: How does auto-route creation work?**
364362 A: When enabled, Gordon watches for pushed images with domain names as image names (like `staging.example.com:latest`) and automatically creates routes. Perfect for testing deployments without manual config edits.
@@ -420,7 +418,126 @@ Gordon automatically detects available container runtimes in this order:
420418 2. **Podman rootless** (`$XDG_RUNTIME_DIR/podman/podman.sock`)
421419 3. **Podman root** (`/run/podman/podman.sock`)
422420
423-### Port binding
421+### Environment Variables & Secret Management
422+
423+Gordon supports per-route environment variables with secure secret management.
424+
425+#### Configuration
426+```toml
427+[env]
428+# Directory where .env files are stored for each route
429+dir = "./data/env" # Default location
430+# Secret providers for secure credential management
431+providers = ["pass", "sops"] # Unix password manager and SOPS
432+```
433+
434+#### Creating Environment Files
435+Gordon automatically creates empty `.env` files for all configured routes when it starts up. The files are named after the domain with special characters replaced by underscores:
436+
437+```bash
438+# Gordon automatically creates these files for you:
439+# For route "app.mydomain.com"
440+./data/env/app_mydomain_com.env
441+
442+# For route "api-v2.mydomain.com"
443+./data/env/api-v2_mydomain_com.env
444+```
445+
446+#### Environment File Format
447+When Gordon creates env files automatically, they include helpful comments and examples.
448+
449+**Security:** Gordon creates the env directory with `0700` permissions and env files with `0600` permissions (owner read/write only) to protect sensitive information.
450+
451+```bash
452+# ./data/env/app_mydomain_com.env (automatically created by Gordon)
453+# Environment variables for route: app.mydomain.com
454+# Image: myapp:latest
455+
456+NODE_ENV=production
457+PORT=3000
458+DATABASE_URL=postgresql://localhost:5432/prod
459+
460+# Secret references (resolved at container start)
461+API_KEY=${pass:company/api-key}
462+JWT_SECRET=${sops:secrets.yaml:app.jwt_secret}
463+```
464+
465+#### Secret Providers
466+
467+**Unix Password Manager (pass)**
468+```bash
469+# Store secrets with pass
470+pass insert company/api-key
471+pass insert company/database-password
472+
473+# Reference in .env files
474+API_KEY=${pass:company/api-key}
475+DB_PASSWORD=${pass:company/database-password}
476+```
477+
478+**SOPS (Secrets OPerationS)**
479+```bash
480+# Create encrypted YAML file
481+sops secrets.yaml
482+
483+# secrets.yaml content:
484+# app:
485+# jwt_secret: "super-secret-key"
486+# database_url: "postgresql://user:pass@db:5432/prod"
487+
488+# Reference in .env files
489+JWT_SECRET=${sops:secrets.yaml:app.jwt_secret}
490+DATABASE_URL=${sops:secrets.yaml:app.database_url}
491+```
492+
493+#### How It Works
494+1. Gordon loads the `.env` file for each route during container deployment
495+2. Secret references (`${provider:path}`) are resolved using the configured providers
496+3. Environment variables are passed to the container at startup
497+4. Missing env files are silently ignored (optional env vars)
498+5. Secret resolution failures cause deployment to fail for security
499+
500+#### Examples
501+
502+**Development Setup**
503+```toml
504+# gordon.toml
505+[env]
506+dir = "./dev-data/env"
507+providers = ["pass"] # Optional for dev secrets
508+
509+[routes]
510+"app.local" = "myapp:latest"
511+```
512+
513+```bash
514+# ./dev-data/env/app_local.env
515+NODE_ENV=development
516+DEBUG=true
517+DATABASE_URL=postgresql://localhost:5432/dev
518+API_KEY=${pass:dev/api-key} # Optional: use pass for dev secrets
519+```
520+
521+**Production Setup**
522+```toml
523+# gordon.toml
524+[env]
525+dir = "/var/lib/gordon/env"
526+providers = ["pass", "sops"]
527+
528+[routes]
529+"app.company.com" = "company-app:v2.1.0"
530+```
531+
532+```bash
533+# /var/lib/gordon/env/app_company_com.env
534+NODE_ENV=production
535+DATABASE_URL=postgresql://user:${pass:company/db-password}@db:5432/prod
536+API_KEY=${sops:company-secrets.yaml:api.key}
537+JWT_SECRET=${sops:company-secrets.yaml:jwt.secret}
538+```
539+
540+### Port Binding
424541 Gordon requires your Dockerfile to explicitly expose ports using the `EXPOSE` instruction. When multiple ports are exposed, Gordon will use the first exposed port for HTTP traffic.
425542
426543 Example Dockerfile:
@@ -432,10 +549,10 @@ EXPOSE 8080
432549 ### Registry Operations
433550 ```bash
434551 # List images
435-curl -u admin:password https://registry.yourdomain.com/v2/_catalog
552+curl -u admin:password https://registry.mydomain.com/v2/_catalog
436553
437554 # List tags
438-curl -u admin:password https://registry.yourdomain.com/v2/myapp/tags/list
555+curl -u admin:password https://registry.mydomain.com/v2/myapp/tags/list
439556 ```
440557
441558 ## Contributing
@@ -29,8 +29,8 @@ podman build -t myapp .
29 podman run -p 8080:8080 myapp # Works? Great!29 podman run -p 8080:8080 myapp # Works? Great!
30 30
31 # 2. Push to deploy31 # 2. Push to deploy
32-podman tag myapp registry.yourdomain.com/myapp:latest32+podman tag myapp registry.mydomain.com/myapp:latest
33-podman push registry.yourdomain.com/myapp:latest33+podman push registry.mydomain.com/myapp:latest
34 34
35 # 3. That's it. Gordon deploys it automatically.35 # 3. That's it. Gordon deploys it automatically.
36 ```36 ```
@@ -43,10 +43,10 @@ Something broke? Just change your config:
43 43
44 ```toml44 ```toml
45 # Before (in gordon.toml)45 # Before (in gordon.toml)
46-"app.yourdomain.com" = "myapp:latest"46+"app.mydomain.com" = "myapp:latest"
47 47
48 # After - instant rollback!48 # After - instant rollback!
49-"app.yourdomain.com" = "myapp:v1.2.3"49+"app.mydomain.com" = "myapp:v1.2.3"
50 ```50 ```
51 51
52 Save the file. Gordon redeploys the previous version. Problem solved in seconds.52 Save the file. Gordon redeploys the previous version. Problem solved in seconds.
@@ -54,7 +54,7 @@ Save the file. Gordon redeploys the previous version. Problem solved in seconds.
54 ## Key Features54 ## Key Features
55 55
56 ### Local-First Development56 ### Local-First Development
57-**Your machine is the build server.** Test locally with Podman's rootless containers, push when ready. No waiting for remote builds.57+**Your machine is the build server.** Test locally with your favorite container engine, push when ready. No waiting for remote builds.
58 58
59 ### Built-in Container Registry59 ### Built-in Container Registry
60 Your VPS becomes a private container registry (Docker/Podman compatible). No Docker Hub subscription needed.60 Your VPS becomes a private container registry (Docker/Podman compatible). No Docker Hub subscription needed.
@@ -124,10 +124,10 @@ systemctl --user enable --now podman.socket
124 mkdir -p ~/.config/containers124 mkdir -p ~/.config/containers
125 tee ~/.config/containers/registries.conf > /dev/null <<EOF125 tee ~/.config/containers/registries.conf > /dev/null <<EOF
126 [registries.search]126 [registries.search]
127-registries = ['docker.io', 'registry.yourdomain.com']127+registries = ['docker.io', 'registry.mydomain.com']
128 128
129 [registries.insecure]129 [registries.insecure]
130-registries = ['registry.yourdomain.com']130+registries = ['registry.mydomain.com']
131 131
132 [registries.block]132 [registries.block]
133 registries = []133 registries = []
@@ -147,7 +147,7 @@ sudo mv gordon-linux-amd64 /usr/local/bin/gordon
147 # (or use --config flag)147 # (or use --config flag)
148 [server]148 [server]
149 port = 8080149 port = 8080
150-registry_domain = "registry.yourdomain.com"150+registry_domain = "registry.mydomain.com"
151 runtime = "podman-rootless" # Can be auto, docker, podman, podman-rootless151 runtime = "podman-rootless" # Can be auto, docker, podman, podman-rootless
152 152
153 [registry_auth]153 [registry_auth]
@@ -156,9 +156,9 @@ username = "admin"
156 password = "your-secure-password"156 password = "your-secure-password"
157 157
158 [routes]158 [routes]
159-"app.yourdomain.com" = "myapp:latest"159+"app.mydomain.com" = "myapp:latest"
160-"api.yourdomain.com" = "api:v1"160+"api.mydomain.com" = "api:v1"
161-"blog.yourdomain.com" = "wordpress:latest"161+"blog.mydomain.com" = "wordpress:latest"
162 162
163 [auto_route]163 [auto_route]
164 enabled = false # Enable automatic route creation164 enabled = false # Enable automatic route creation
@@ -169,8 +169,8 @@ enabled = false # Enable automatic route creation
169 169
170 ### 7. Point Cloudflare DNS170 ### 7. Point Cloudflare DNS
171 ```171 ```
172-A *.yourdomain.com → YOUR_VPS_IP172+A *.mydomain.com → YOUR_VPS_IP
173-A yourdomain.com → YOUR_VPS_IP173+A mydomain.com → YOUR_VPS_IP
174 ```174 ```
175 175
176 ### 8. Create Systemd Service (Rootless)176 ### 8. Create Systemd Service (Rootless)
@@ -211,13 +211,13 @@ systemctl --user status gordon
211 ```bash211 ```bash
212 # Now from your local development machine:212 # Now from your local development machine:
213 # Authenticate with your Gordon registry213 # Authenticate with your Gordon registry
214-podman login registry.yourdomain.com214+podman login registry.mydomain.com
215 # Use the username/password from your gordon.toml config215 # Use the username/password from your gordon.toml config
216 216
217 # Deploy your first app (using podman)217 # Deploy your first app (using podman)
218-podman tag myapp:latest registry.yourdomain.com/myapp:latest218+podman tag myapp:latest registry.mydomain.com/myapp:latest
219-podman push registry.yourdomain.com/myapp:latest219+podman push registry.mydomain.com/myapp:latest
220-# Visit https://app.yourdomain.com220+# Visit https://app.mydomain.com
221 ```221 ```
222 222
223 ## Real-World Examples223 ## Real-World Examples
@@ -231,13 +231,13 @@ podman build -t myapp .
231 podman run -p 3000:3000 myapp # Test it!231 podman run -p 3000:3000 myapp # Test it!
232 232
233 # 2. In gordon.toml, point your domain to the 'latest' tag233 # 2. In gordon.toml, point your domain to the 'latest' tag
234-# "app.yourdomain.com" = "myapp:latest"234+# "app.mydomain.com" = "myapp:latest"
235 235
236 # 3. Tag your image as 'latest' for your registry236 # 3. Tag your image as 'latest' for your registry
237-podman tag myapp registry.yourdomain.com/myapp:latest237+podman tag myapp registry.mydomain.com/myapp:latest
238 238
239 # 4. Push to deploy239 # 4. Push to deploy
240-podman push registry.yourdomain.com/myapp:latest240+podman push registry.mydomain.com/myapp:latest
241 241
242 # Gordon automatically detects the push and deploys the new 'latest' version.242 # Gordon automatically detects the push and deploys the new 'latest' version.
243 ```243 ```
@@ -247,20 +247,18 @@ For more control, especially in production, you can use a combination of version
247 247
248 ```bash248 ```bash
249 # 1. Always push a specific version tag first. This creates a history.249 # 1. Always push a specific version tag first. This creates a history.
250-podman tag myapp registry.yourdomain.com/myapp:v1.0.1250+podman tag myapp registry.mydomain.com/myapp:v1.0.1
251-podman push registry.yourdomain.com/myapp:v1.0.1251+podman push registry.mydomain.com/myapp:v1.0.1
252 252
253 # 2. Test the new version on a staging domain.253 # 2. Test the new version on a staging domain.
254 # In gordon.toml:254 # In gordon.toml:
255-"staging.yourdomain.com" = "myapp:v1.0.1"255+"staging.mydomain.com" = "myapp:v1.0.1"
256+# Get your production route ready :
257+"app.mydomain.com" = "myapp:latest"
256 258
257 # 3. Once tested and confirmed, promote it to production by updating 'latest'.259 # 3. Once tested and confirmed, promote it to production by updating 'latest'.
258-# This assumes 'myapp' still refers to the image for v1.0.1260+podman tag myapp registry.mydomain.com/myapp:latest
259-podman tag myapp registry.yourdomain.com/myapp:latest261+podman push registry.mydomain.com/myapp:latest
260-podman push registry.yourdomain.com/myapp:latest
261-
262-# Your production route, which follows 'latest', will now be updated automatically.
263-# "app.yourdomain.com" = "myapp:latest"
264 ```262 ```
265 263
266 ### Instant Rollback When Things Break264 ### Instant Rollback When Things Break
@@ -269,10 +267,10 @@ podman push registry.yourdomain.com/myapp:latest
269 # Just edit gordon.toml to point to a previously pushed, stable version tag:267 # Just edit gordon.toml to point to a previously pushed, stable version tag:
270 268
271 # From:269 # From:
272-"app.yourdomain.com" = "myapp:latest"270+"app.mydomain.com" = "myapp:latest"
273 271
274 # To:272 # To:
275-"app.yourdomain.com" = "myapp:v1.0.0"273+"app.mydomain.com" = "myapp:v1.0.0"
276 274
277 # Save the file. Gordon redeploys the stable version in seconds. No scripts, no drama.275 # Save the file. Gordon redeploys the stable version in seconds. No scripts, no drama.
278 ```276 ```
@@ -295,8 +293,8 @@ When you push an image with a domain name as the image name, Gordon automaticall
295 podman build -t myapp .293 podman build -t myapp .
296 294
297 # Tag with domain name as the image name295 # Tag with domain name as the image name
298-podman tag myapp:latest registry.yourdomain.com/myapp.mydomain.dev:latest296+podman tag myapp:latest registry.mydomain.com/myapp.mydomain.dev:latest
299-podman push registry.yourdomain.com/myapp.mydomain.dev:latest297+podman push registry.mydomain.com/myapp.mydomain.dev:latest
300 298
301 # Gordon automatically creates:299 # Gordon automatically creates:
302 # "myapp.mydomain.dev" = "myapp.mydomain.dev:latest"300 # "myapp.mydomain.dev" = "myapp.mydomain.dev:latest"
@@ -309,11 +307,11 @@ podman push registry.yourdomain.com/myapp.mydomain.dev:latest
309 podman build -t myapp .307 podman build -t myapp .
310 308
311 # Push to different test domains using image names309 # Push to different test domains using image names
312-podman tag myapp:latest registry.yourdomain.com/staging.example.com:latest310+podman tag myapp:latest registry.mydomain.com/staging.example.com:latest
313-podman push registry.yourdomain.com/staging.example.com:latest311+podman push registry.mydomain.com/staging.example.com:latest
314 312
315-podman tag myapp:latest registry.yourdomain.com/demo.example.com:v1.0.0313+podman tag myapp:latest registry.mydomain.com/demo.example.com:v1.0.0
316-podman push registry.yourdomain.com/demo.example.com:v1.0.0314+podman push registry.mydomain.com/demo.example.com:v1.0.0
317 315
318 # Both automatically get their own routes:316 # Both automatically get their own routes:
319 # staging.example.com -> staging.example.com:latest317 # staging.example.com -> staging.example.com:latest
@@ -358,7 +356,7 @@ A: You can in theory, but exposing databases on the internet is risky. Database/
358 A: Runs comfortably on 1GB RAM VPS. Gordon itself uses <15MB of RAM.356 A: Runs comfortably on 1GB RAM VPS. Gordon itself uses <15MB of RAM.
359 357
360 **Q: What about secrets?** 358 **Q: What about secrets?**
361-A: Use environment variables in your container or Docker secrets. Gordon doesn't interfere.359+A: Gordon provides built-in environment variable support with secret management via `pass` and `sops`. See the Environment Variables section below.
362 360
363 **Q: How does auto-route creation work?** 361 **Q: How does auto-route creation work?**
364 A: When enabled, Gordon watches for pushed images with domain names as image names (like `staging.example.com:latest`) and automatically creates routes. Perfect for testing deployments without manual config edits.362 A: When enabled, Gordon watches for pushed images with domain names as image names (like `staging.example.com:latest`) and automatically creates routes. Perfect for testing deployments without manual config edits.
@@ -420,7 +418,126 @@ Gordon automatically detects available container runtimes in this order:
420 2. **Podman rootless** (`$XDG_RUNTIME_DIR/podman/podman.sock`)418 2. **Podman rootless** (`$XDG_RUNTIME_DIR/podman/podman.sock`)
421 3. **Podman root** (`/run/podman/podman.sock`) 419 3. **Podman root** (`/run/podman/podman.sock`)
422 420
423-### Port binding421+### Environment Variables & Secret Management
422+
423+Gordon supports per-route environment variables with secure secret management.
424+
425+#### Configuration
426+```toml
427+[env]
428+# Directory where .env files are stored for each route
429+dir = "./data/env" # Default location
430+# Secret providers for secure credential management
431+providers = ["pass", "sops"] # Unix password manager and SOPS
432+```
433+
434+#### Creating Environment Files
435+Gordon automatically creates empty `.env` files for all configured routes when it starts up. The files are named after the domain with special characters replaced by underscores:
436+
437+```bash
438+# Gordon automatically creates these files for you:
439+# For route "app.mydomain.com"
440+./data/env/app_mydomain_com.env
441+
442+# For route "api-v2.mydomain.com"
443+./data/env/api-v2_mydomain_com.env
444+```
445+
446+#### Environment File Format
447+When Gordon creates env files automatically, they include helpful comments and examples.
448+
449+**Security:** Gordon creates the env directory with `0700` permissions and env files with `0600` permissions (owner read/write only) to protect sensitive information.
450+
451+```bash
452+# ./data/env/app_mydomain_com.env (automatically created by Gordon)
453+# Environment variables for route: app.mydomain.com
454+# Image: myapp:latest
455+
456+NODE_ENV=production
457+PORT=3000
458+DATABASE_URL=postgresql://localhost:5432/prod
459+
460+# Secret references (resolved at container start)
461+API_KEY=${pass:company/api-key}
462+JWT_SECRET=${sops:secrets.yaml:app.jwt_secret}
463+```
464+
465+#### Secret Providers
466+
467+**Unix Password Manager (pass)**
468+```bash
469+# Store secrets with pass
470+pass insert company/api-key
471+pass insert company/database-password
472+
473+# Reference in .env files
474+API_KEY=${pass:company/api-key}
475+DB_PASSWORD=${pass:company/database-password}
476+```
477+
478+**SOPS (Secrets OPerationS)**
479+```bash
480+# Create encrypted YAML file
481+sops secrets.yaml
482+
483+# secrets.yaml content:
484+# app:
485+# jwt_secret: "super-secret-key"
486+# database_url: "postgresql://user:pass@db:5432/prod"
487+
488+# Reference in .env files
489+JWT_SECRET=${sops:secrets.yaml:app.jwt_secret}
490+DATABASE_URL=${sops:secrets.yaml:app.database_url}
491+```
492+
493+#### How It Works
494+1. Gordon loads the `.env` file for each route during container deployment
495+2. Secret references (`${provider:path}`) are resolved using the configured providers
496+3. Environment variables are passed to the container at startup
497+4. Missing env files are silently ignored (optional env vars)
498+5. Secret resolution failures cause deployment to fail for security
499+
500+#### Examples
501+
502+**Development Setup**
503+```toml
504+# gordon.toml
505+[env]
506+dir = "./dev-data/env"
507+providers = ["pass"] # Optional for dev secrets
508+
509+[routes]
510+"app.local" = "myapp:latest"
511+```
512+
513+```bash
514+# ./dev-data/env/app_local.env
515+NODE_ENV=development
516+DEBUG=true
517+DATABASE_URL=postgresql://localhost:5432/dev
518+API_KEY=${pass:dev/api-key} # Optional: use pass for dev secrets
519+```
520+
521+**Production Setup**
522+```toml
523+# gordon.toml
524+[env]
525+dir = "/var/lib/gordon/env"
526+providers = ["pass", "sops"]
527+
528+[routes]
529+"app.company.com" = "company-app:v2.1.0"
530+```
531+
532+```bash
533+# /var/lib/gordon/env/app_company_com.env
534+NODE_ENV=production
535+DATABASE_URL=postgresql://user:${pass:company/db-password}@db:5432/prod
536+API_KEY=${sops:company-secrets.yaml:api.key}
537+JWT_SECRET=${sops:company-secrets.yaml:jwt.secret}
538+```
539+
540+### Port Binding
424 Gordon requires your Dockerfile to explicitly expose ports using the `EXPOSE` instruction. When multiple ports are exposed, Gordon will use the first exposed port for HTTP traffic.541 Gordon requires your Dockerfile to explicitly expose ports using the `EXPOSE` instruction. When multiple ports are exposed, Gordon will use the first exposed port for HTTP traffic.
425 542
426 Example Dockerfile:543 Example Dockerfile:
@@ -432,10 +549,10 @@ EXPOSE 8080
432 ### Registry Operations549 ### Registry Operations
433 ```bash550 ```bash
434 # List images551 # List images
435-curl -u admin:password https://registry.yourdomain.com/v2/_catalog552+curl -u admin:password https://registry.mydomain.com/v2/_catalog
436 553
437 # List tags554 # List tags
438-curl -u admin:password https://registry.yourdomain.com/v2/myapp/tags/list555+curl -u admin:password https://registry.mydomain.com/v2/myapp/tags/list
439 ```556 ```
440 557
441 ## Contributing558 ## Contributing
A cmd/reload.go +85 -0
@@ -0,0 +1,85 @@
1+package cmd
2+
3+import (
4+ "fmt"
5+ "os"
6+ "path/filepath"
7+ "syscall"
8+
9+ "github.com/spf13/cobra"
10+)
11+
12+var reloadCmd = &cobra.Command{
13+ Use: "reload",
14+ Short: "Reload configuration and environment variables into running containers",
15+ Long: `Reload the Gordon configuration and environment variables from .env files into running containers.
16+This command reloads the config file and redeploys all containers with updated environment
17+variables from their respective .env files. Containers are recreated to pick up the new configuration and environment variables.`,
18+ Run: runReload,
19+}
20+
21+func init() {
22+ rootCmd.AddCommand(reloadCmd)
23+}
24+
25+func runReload(cmd *cobra.Command, args []string) {
26+ // Find the Gordon PID file to get the process ID
27+ pidFile := findGordonPidFile()
28+ if pidFile == "" {
29+ fmt.Println("Error: Gordon PID file not found. Is Gordon running?")
30+ fmt.Println("Please start Gordon with 'gordon start' first.")
31+ return
32+ }
33+
34+ // Read the PID from the file
35+ pidBytes, err := os.ReadFile(pidFile)
36+ if err != nil {
37+ fmt.Printf("Error reading Gordon PID file: %v\n", err)
38+ return
39+ }
40+
41+ var pid int
42+ if _, err := fmt.Sscanf(string(pidBytes), "%d", &pid); err != nil {
43+ fmt.Printf("Error parsing Gordon PID: %v\n", err)
44+ return
45+ }
46+
47+ // Check if the process is still running
48+ process, err := os.FindProcess(pid)
49+ if err != nil {
50+ fmt.Printf("Error finding Gordon process: %v\n", err)
51+ return
52+ }
53+
54+ // Send SIGUSR1 signal to trigger reload
55+ if err := process.Signal(syscall.SIGUSR1); err != nil {
56+ fmt.Printf("Error sending reload signal to Gordon: %v\n", err)
57+ fmt.Println("Gordon may not be running or may not have permission to send signals.")
58+ return
59+ }
60+
61+ fmt.Println("Manual reload signal sent to Gordon successfully.")
62+ fmt.Println("Check Gordon logs to see the reload progress.")
63+}
64+
65+func findGordonPidFile() string {
66+ // Look for PID file in common locations
67+ locations := []string{
68+ "/tmp/gordon.pid",
69+ "/var/run/gordon.pid",
70+ filepath.Join(os.TempDir(), "gordon.pid"),
71+ }
72+
73+ // Also check in user's home directory
74+ if homeDir, err := os.UserHomeDir(); err == nil {
75+ locations = append(locations, filepath.Join(homeDir, ".gordon.pid"))
76+ }
77+
78+ for _, location := range locations {
79+ if _, err := os.Stat(location); err == nil {
80+ return location
81+ }
82+ }
83+
84+ return ""
85+}
@@ -0,0 +1,85 @@
1+package cmd
2+
3+import (
4+ "fmt"
5+ "os"
6+ "path/filepath"
7+ "syscall"
8+
9+ "github.com/spf13/cobra"
10+)
11+
12+var reloadCmd = &cobra.Command{
13+ Use: "reload",
14+ Short: "Reload configuration and environment variables into running containers",
15+ Long: `Reload the Gordon configuration and environment variables from .env files into running containers.
16+This command reloads the config file and redeploys all containers with updated environment
17+variables from their respective .env files. Containers are recreated to pick up the new configuration and environment variables.`,
18+ Run: runReload,
19+}
20+
21+func init() {
22+ rootCmd.AddCommand(reloadCmd)
23+}
24+
25+func runReload(cmd *cobra.Command, args []string) {
26+ // Find the Gordon PID file to get the process ID
27+ pidFile := findGordonPidFile()
28+ if pidFile == "" {
29+ fmt.Println("Error: Gordon PID file not found. Is Gordon running?")
30+ fmt.Println("Please start Gordon with 'gordon start' first.")
31+ return
32+ }
33+
34+ // Read the PID from the file
35+ pidBytes, err := os.ReadFile(pidFile)
36+ if err != nil {
37+ fmt.Printf("Error reading Gordon PID file: %v\n", err)
38+ return
39+ }
40+
41+ var pid int
42+ if _, err := fmt.Sscanf(string(pidBytes), "%d", &pid); err != nil {
43+ fmt.Printf("Error parsing Gordon PID: %v\n", err)
44+ return
45+ }
46+
47+ // Check if the process is still running
48+ process, err := os.FindProcess(pid)
49+ if err != nil {
50+ fmt.Printf("Error finding Gordon process: %v\n", err)
51+ return
52+ }
53+
54+ // Send SIGUSR1 signal to trigger reload
55+ if err := process.Signal(syscall.SIGUSR1); err != nil {
56+ fmt.Printf("Error sending reload signal to Gordon: %v\n", err)
57+ fmt.Println("Gordon may not be running or may not have permission to send signals.")
58+ return
59+ }
60+
61+ fmt.Println("Manual reload signal sent to Gordon successfully.")
62+ fmt.Println("Check Gordon logs to see the reload progress.")
63+}
64+
65+func findGordonPidFile() string {
66+ // Look for PID file in common locations
67+ locations := []string{
68+ "/tmp/gordon.pid",
69+ "/var/run/gordon.pid",
70+ filepath.Join(os.TempDir(), "gordon.pid"),
71+ }
72+
73+ // Also check in user's home directory
74+ if homeDir, err := os.UserHomeDir(); err == nil {
75+ locations = append(locations, filepath.Join(homeDir, ".gordon.pid"))
76+ }
77+
78+ for _, location := range locations {
79+ if _, err := os.Stat(location); err == nil {
80+ return location
81+ }
82+ }
83+
84+ return ""
85+}
M cmd/start.go +68 -2
@@ -2,8 +2,10 @@ package cmd
22
33 import (
44 "context"
5+ "fmt"
56 "os"
67 "os/signal"
8+ "path/filepath"
79 "sync"
810 "syscall"
911 "time"
@@ -46,6 +48,12 @@ func runStart(cmd *cobra.Command, args []string) {
4648 log.Info().Int("registry_port", cfg.Server.RegistryPort).Msg("Registry server")
4749 log.Info().Int("proxy_port", cfg.Server.Port).Msg("Proxy server")
4850 log.Info().Str("runtime", cfg.Server.Runtime).Msg("Container runtime")
51+
52+ // Create PID file for reload command
53+ pidFile := createPidFile()
54+ if pidFile != "" {
55+ defer removePidFile(pidFile)
56+ }
4957
5058 // Initialize event bus
5159 eventBus := events.NewInMemoryEventBus(100)
@@ -118,6 +126,9 @@ func runStart(cmd *cobra.Command, args []string) {
118126 // Update proxy server config
119127 proxyServer.UpdateConfig(cfg)
120128
129+ // Update container manager config (includes env file creation for new routes)
130+ manager.UpdateConfig(cfg)
131+
121132 // Unsubscribe old handlers before creating new ones
122133 if err := eventBus.Unsubscribe(containerHandler); err != nil {
123134 log.Warn().Err(err).Msg("Failed to unsubscribe old container event handler")
@@ -194,10 +205,30 @@ func runStart(cmd *cobra.Command, args []string) {
194205 }
195206 }()
196207
197- // Wait for interrupt signal
208+ // Wait for interrupt or reload signals
198209 sigChan := make(chan os.Signal, 1)
210+ reloadChan := make(chan os.Signal, 1)
199211 signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
200- <-sigChan
212+ signal.Notify(reloadChan, syscall.SIGUSR1)
213+
214+ // Handle signals in a loop
215+ for {
216+ select {
217+ case <-sigChan:
218+ log.Info().Msg("Received shutdown signal")
219+ goto shutdown
220+ case <-reloadChan:
221+ log.Info().Msg("Received manual reload signal (SIGUSR1)")
222+ // Trigger manual reload event
223+ if err := eventBus.Publish(events.ManualReload, nil); err != nil {
224+ log.Error().Err(err).Msg("Failed to publish manual reload event")
225+ } else {
226+ log.Info().Msg("Manual reload event published successfully")
227+ }
228+ }
229+ }
230+
231+shutdown:
201232
202233 log.Info().Msg("Shutting down servers...")
203234 cancel()
@@ -230,3 +261,38 @@ func runStart(cmd *cobra.Command, args []string) {
230261 log.Warn().Msg("Force shutdown after timeout")
231262 }
232263 }
264+
265+// createPidFile creates a PID file for the Gordon process
266+func createPidFile() string {
267+ pid := os.Getpid()
268+
269+ // Try multiple locations for the PID file
270+ locations := []string{
271+ "/tmp/gordon.pid",
272+ filepath.Join(os.TempDir(), "gordon.pid"),
273+ }
274+
275+ // Also try user's home directory
276+ if homeDir, err := os.UserHomeDir(); err == nil {
277+ locations = append(locations, filepath.Join(homeDir, ".gordon.pid"))
278+ }
279+
280+ for _, location := range locations {
281+ if err := os.WriteFile(location, []byte(fmt.Sprintf("%d", pid)), 0644); err == nil {
282+ log.Debug().Str("pid_file", location).Int("pid", pid).Msg("Created PID file")
283+ return location
284+ }
285+ }
286+
287+ log.Warn().Int("pid", pid).Msg("Failed to create PID file in any location")
288+ return ""
289+}
290+
291+// removePidFile removes the PID file
292+func removePidFile(pidFile string) {
293+ if err := os.Remove(pidFile); err != nil {
294+ log.Warn().Err(err).Str("pid_file", pidFile).Msg("Failed to remove PID file")
295+ } else {
296+ log.Debug().Str("pid_file", pidFile).Msg("Removed PID file")
297+ }
298+}
@@ -2,8 +2,10 @@ package cmd
2 2
3 import (3 import (
4 "context"4 "context"
5+ "fmt"
5 "os"6 "os"
6 "os/signal"7 "os/signal"
8+ "path/filepath"
7 "sync"9 "sync"
8 "syscall"10 "syscall"
9 "time"11 "time"
@@ -46,6 +48,12 @@ func runStart(cmd *cobra.Command, args []string) {
46 log.Info().Int("registry_port", cfg.Server.RegistryPort).Msg("Registry server")48 log.Info().Int("registry_port", cfg.Server.RegistryPort).Msg("Registry server")
47 log.Info().Int("proxy_port", cfg.Server.Port).Msg("Proxy server")49 log.Info().Int("proxy_port", cfg.Server.Port).Msg("Proxy server")
48 log.Info().Str("runtime", cfg.Server.Runtime).Msg("Container runtime")50 log.Info().Str("runtime", cfg.Server.Runtime).Msg("Container runtime")
51+
52+ // Create PID file for reload command
53+ pidFile := createPidFile()
54+ if pidFile != "" {
55+ defer removePidFile(pidFile)
56+ }
49 57
50 // Initialize event bus58 // Initialize event bus
51 eventBus := events.NewInMemoryEventBus(100)59 eventBus := events.NewInMemoryEventBus(100)
@@ -118,6 +126,9 @@ func runStart(cmd *cobra.Command, args []string) {
118 // Update proxy server config126 // Update proxy server config
119 proxyServer.UpdateConfig(cfg)127 proxyServer.UpdateConfig(cfg)
120 128
129+ // Update container manager config (includes env file creation for new routes)
130+ manager.UpdateConfig(cfg)
131+
121 // Unsubscribe old handlers before creating new ones132 // Unsubscribe old handlers before creating new ones
122 if err := eventBus.Unsubscribe(containerHandler); err != nil {133 if err := eventBus.Unsubscribe(containerHandler); err != nil {
123 log.Warn().Err(err).Msg("Failed to unsubscribe old container event handler")134 log.Warn().Err(err).Msg("Failed to unsubscribe old container event handler")
@@ -194,10 +205,30 @@ func runStart(cmd *cobra.Command, args []string) {
194 }205 }
195 }()206 }()
196 207
197- // Wait for interrupt signal208+ // Wait for interrupt or reload signals
198 sigChan := make(chan os.Signal, 1)209 sigChan := make(chan os.Signal, 1)
210+ reloadChan := make(chan os.Signal, 1)
199 signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)211 signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
200- <-sigChan212+ signal.Notify(reloadChan, syscall.SIGUSR1)
213+
214+ // Handle signals in a loop
215+ for {
216+ select {
217+ case <-sigChan:
218+ log.Info().Msg("Received shutdown signal")
219+ goto shutdown
220+ case <-reloadChan:
221+ log.Info().Msg("Received manual reload signal (SIGUSR1)")
222+ // Trigger manual reload event
223+ if err := eventBus.Publish(events.ManualReload, nil); err != nil {
224+ log.Error().Err(err).Msg("Failed to publish manual reload event")
225+ } else {
226+ log.Info().Msg("Manual reload event published successfully")
227+ }
228+ }
229+ }
230+
231+shutdown:
201 232
202 log.Info().Msg("Shutting down servers...")233 log.Info().Msg("Shutting down servers...")
203 cancel()234 cancel()
@@ -230,3 +261,38 @@ func runStart(cmd *cobra.Command, args []string) {
230 log.Warn().Msg("Force shutdown after timeout")261 log.Warn().Msg("Force shutdown after timeout")
231 }262 }
232 }263 }
264+
265+// createPidFile creates a PID file for the Gordon process
266+func createPidFile() string {
267+ pid := os.Getpid()
268+
269+ // Try multiple locations for the PID file
270+ locations := []string{
271+ "/tmp/gordon.pid",
272+ filepath.Join(os.TempDir(), "gordon.pid"),
273+ }
274+
275+ // Also try user's home directory
276+ if homeDir, err := os.UserHomeDir(); err == nil {
277+ locations = append(locations, filepath.Join(homeDir, ".gordon.pid"))
278+ }
279+
280+ for _, location := range locations {
281+ if err := os.WriteFile(location, []byte(fmt.Sprintf("%d", pid)), 0644); err == nil {
282+ log.Debug().Str("pid_file", location).Int("pid", pid).Msg("Created PID file")
283+ return location
284+ }
285+ }
286+
287+ log.Warn().Int("pid", pid).Msg("Failed to create PID file in any location")
288+ return ""
289+}
290+
291+// removePidFile removes the PID file
292+func removePidFile(pidFile string) {
293+ if err := os.Remove(pidFile); err != nil {
294+ log.Warn().Err(err).Str("pid_file", pidFile).Msg("Failed to remove PID file")
295+ } else {
296+ log.Debug().Str("pid_file", pidFile).Msg("Removed PID file")
297+ }
298+}
M examples/development.toml +22 -2
@@ -15,6 +15,13 @@ enabled = false # Disable auth for easier local development
1515 [auto_route]
1616 enabled = true # Enable automatic route creation from image names
1717
18+# Environment variables configuration
19+[env]
20+# Directory where .env files are stored for each route
21+dir = "./dev-data/env"
22+# Secret providers (optional for development)
23+providers = ["pass"] # For development secrets
24+
1825 # Development routes using your domain
1926 [routes]
2027 "app.yourdomain.com" = "myapp:latest" # Main app - latest build
@@ -30,16 +37,29 @@ enabled = true # Enable automatic route creation from image names
3037 "db-admin.yourdomain.com" = "phpmyadmin:latest" # Database admin
3138 "mail.yourdomain.com" = "mailhog/mailhog:latest" # Email testing
3239
40+# Environment Variables Usage:
41+#
42+# For development, create .env files for your routes:
43+# ./dev-data/env/app_yourdomain_com.env
44+# ./dev-data/env/api_yourdomain_com.env
45+#
46+# Example env file content:
47+# NODE_ENV=development
48+# DEBUG=true
49+# DATABASE_URL=postgresql://localhost:5432/dev
50+# API_KEY=${pass:dev/api-key} # Optional: use pass for dev secrets
51+
3352 # Development workflow:
3453 # 1. Point DNS to your Gordon server:
3554 # A *.yourdomain.com → YOUR_GORDON_SERVER_IP
3655 # A yourdomain.com → YOUR_GORDON_SERVER_IP
3756 #
3857 # 2. Build your app: docker build -t myapp:latest .
39-# 3. Push to Gordon registry:
58+# 3. Create env file: ./dev-data/env/app_yourdomain_com.env
59+# 4. Push to Gordon registry:
4060 # docker tag myapp:latest registry.yourdomain.com/myapp:latest
4161 # docker push registry.yourdomain.com/myapp:latest
42-# 4. Access at https://app.yourdomain.com
62+# 5. Access at https://app.yourdomain.com
4363 #
4464 # AUTO-ROUTE FEATURE:
4565 # With auto_route.enabled = true, you can also push images with domain names as image names:
@@ -15,6 +15,13 @@ enabled = false # Disable auth for easier local development
15 [auto_route]15 [auto_route]
16 enabled = true # Enable automatic route creation from image names16 enabled = true # Enable automatic route creation from image names
17 17
18+# Environment variables configuration
19+[env]
20+# Directory where .env files are stored for each route
21+dir = "./dev-data/env"
22+# Secret providers (optional for development)
23+providers = ["pass"] # For development secrets
24+
18 # Development routes using your domain25 # Development routes using your domain
19 [routes]26 [routes]
20 "app.yourdomain.com" = "myapp:latest" # Main app - latest build27 "app.yourdomain.com" = "myapp:latest" # Main app - latest build
@@ -30,16 +37,29 @@ enabled = true # Enable automatic route creation from image names
30 "db-admin.yourdomain.com" = "phpmyadmin:latest" # Database admin37 "db-admin.yourdomain.com" = "phpmyadmin:latest" # Database admin
31 "mail.yourdomain.com" = "mailhog/mailhog:latest" # Email testing38 "mail.yourdomain.com" = "mailhog/mailhog:latest" # Email testing
32 39
40+# Environment Variables Usage:
41+#
42+# For development, create .env files for your routes:
43+# ./dev-data/env/app_yourdomain_com.env
44+# ./dev-data/env/api_yourdomain_com.env
45+#
46+# Example env file content:
47+# NODE_ENV=development
48+# DEBUG=true
49+# DATABASE_URL=postgresql://localhost:5432/dev
50+# API_KEY=${pass:dev/api-key} # Optional: use pass for dev secrets
51+
33 # Development workflow:52 # Development workflow:
34 # 1. Point DNS to your Gordon server:53 # 1. Point DNS to your Gordon server:
35 # A *.yourdomain.com → YOUR_GORDON_SERVER_IP54 # A *.yourdomain.com → YOUR_GORDON_SERVER_IP
36 # A yourdomain.com → YOUR_GORDON_SERVER_IP55 # A yourdomain.com → YOUR_GORDON_SERVER_IP
37 #56 #
38 # 2. Build your app: docker build -t myapp:latest .57 # 2. Build your app: docker build -t myapp:latest .
39-# 3. Push to Gordon registry: 58+# 3. Create env file: ./dev-data/env/app_yourdomain_com.env
59+# 4. Push to Gordon registry:
40 # docker tag myapp:latest registry.yourdomain.com/myapp:latest60 # docker tag myapp:latest registry.yourdomain.com/myapp:latest
41 # docker push registry.yourdomain.com/myapp:latest61 # docker push registry.yourdomain.com/myapp:latest
42-# 4. Access at https://app.yourdomain.com62+# 5. Access at https://app.yourdomain.com
43 #63 #
44 # AUTO-ROUTE FEATURE:64 # AUTO-ROUTE FEATURE:
45 # With auto_route.enabled = true, you can also push images with domain names as image names:65 # With auto_route.enabled = true, you can also push images with domain names as image names:
A examples/minimal.toml +41 -0
@@ -0,0 +1,41 @@
1+# Gordon v2 - Minimal Configuration Example
2+# Perfect for getting started
3+
4+[server]
5+port = 8080
6+registry_port = 5000
7+registry_domain = "registry.local"
8+
9+[registry_auth]
10+enabled = false # Disabled for simplicity
11+
12+# Environment variables (optional)
13+[env]
14+dir = "./data/env" # Default location
15+
16+# Simple routes
17+[routes]
18+"app.local" = "myapp:latest"
19+"api.local" = "myapi:latest"
20+
21+# Usage:
22+# 1. Create env files (optional):
23+# ./data/env/app_local.env
24+# ./data/env/api_local.env
25+#
26+# 2. Example env file content:
27+# NODE_ENV=production
28+# PORT=3000
29+# DATABASE_URL=postgresql://localhost:5432/app
30+#
31+# 3. Add to /etc/hosts:
32+# 127.0.0.1 app.local
33+# 127.0.0.1 api.local
34+# 127.0.0.1 registry.local
35+#
36+# 4. Build and push:
37+# docker build -t myapp:latest .
38+# docker tag myapp:latest registry.local/myapp:latest
39+# docker push registry.local/myapp:latest
40+#
41+# 5. Access at http://app.local:8080⏎̸
@@ -0,0 +1,41 @@
1+# Gordon v2 - Minimal Configuration Example
2+# Perfect for getting started
3+
4+[server]
5+port = 8080
6+registry_port = 5000
7+registry_domain = "registry.local"
8+
9+[registry_auth]
10+enabled = false # Disabled for simplicity
11+
12+# Environment variables (optional)
13+[env]
14+dir = "./data/env" # Default location
15+
16+# Simple routes
17+[routes]
18+"app.local" = "myapp:latest"
19+"api.local" = "myapi:latest"
20+
21+# Usage:
22+# 1. Create env files (optional):
23+# ./data/env/app_local.env
24+# ./data/env/api_local.env
25+#
26+# 2. Example env file content:
27+# NODE_ENV=production
28+# PORT=3000
29+# DATABASE_URL=postgresql://localhost:5432/app
30+#
31+# 3. Add to /etc/hosts:
32+# 127.0.0.1 app.local
33+# 127.0.0.1 api.local
34+# 127.0.0.1 registry.local
35+#
36+# 4. Build and push:
37+# docker build -t myapp:latest .
38+# docker tag myapp:latest registry.local/myapp:latest
39+# docker push registry.local/myapp:latest
40+#
41+# 5. Access at http://app.local:8080⏎̸
M examples/production.toml +27 -3
@@ -15,6 +15,13 @@ enabled = true
1515 username = "deploy-bot"
1616 password = "CHANGE-THIS-IN-PRODUCTION" # Use environment variables in production!
1717
18+# Environment variables configuration
19+[env]
20+# Directory where .env files are stored for each route
21+dir = "/var/lib/gordon/env"
22+# Secret providers for secure credential management
23+providers = ["pass", "sops"]
24+
1825 # Production service routes
1926 [routes]
2027 "app.company.com" = "company-app:v2.1.0" # Main app with pinned version
@@ -27,10 +34,27 @@ password = "CHANGE-THIS-IN-PRODUCTION" # Use environment variables in productio
2734 "grafana.company.com" = "grafana/grafana:latest" # Metrics dashboard
2835 "prometheus.company.com" = "prom/prometheus:latest" # Metrics collection
2936
37+# Environment Variables Usage:
38+#
39+# For each route, create a .env file named after the domain:
40+# /var/lib/gordon/env/app_company_com.env
41+# /var/lib/gordon/env/api_company_com.env
42+#
43+# Example env file content:
44+# NODE_ENV=production
45+# DATABASE_URL=postgresql://user:pass@db:5432/prod
46+# API_KEY=${pass:company/api-key} # From pass
47+# SECRET_KEY=${sops:secrets.yaml:app.secret} # From sops
48+#
49+# Secret syntax:
50+# ${pass:path/to/secret} - Unix password manager
51+# ${sops:file.yaml:key.path} - SOPS encrypted file
52+
3053 # Production deployment workflow:
3154 # 1. Build and tag: docker build -t company-app:v2.1.1 .
3255 # 2. Tag for registry: docker tag company-app:v2.1.1 registry.company.com/company-app:v2.1.1
3356 # 3. Update this config: "app.company.com" = "company-app:v2.1.1"
34-# 4. Restart Gordon to reload config
35-# 5. Push to deploy: docker push registry.company.com/company-app:v2.1.1
36-# 6. Gordon automatically deploys to app.company.com!⏎̸
57+# 4. Create/update env file: /var/lib/gordon/env/app_company_com.env
58+# 5. Restart Gordon to reload config
59+# 6. Push to deploy: docker push registry.company.com/company-app:v2.1.1
60+# 7. Gordon automatically deploys to app.company.com with environment variables!⏎̸
@@ -15,6 +15,13 @@ enabled = true
15 username = "deploy-bot"15 username = "deploy-bot"
16 password = "CHANGE-THIS-IN-PRODUCTION" # Use environment variables in production!16 password = "CHANGE-THIS-IN-PRODUCTION" # Use environment variables in production!
17 17
18+# Environment variables configuration
19+[env]
20+# Directory where .env files are stored for each route
21+dir = "/var/lib/gordon/env"
22+# Secret providers for secure credential management
23+providers = ["pass", "sops"]
24+
18 # Production service routes25 # Production service routes
19 [routes]26 [routes]
20 "app.company.com" = "company-app:v2.1.0" # Main app with pinned version27 "app.company.com" = "company-app:v2.1.0" # Main app with pinned version
@@ -27,10 +34,27 @@ password = "CHANGE-THIS-IN-PRODUCTION" # Use environment variables in productio
27 "grafana.company.com" = "grafana/grafana:latest" # Metrics dashboard34 "grafana.company.com" = "grafana/grafana:latest" # Metrics dashboard
28 "prometheus.company.com" = "prom/prometheus:latest" # Metrics collection35 "prometheus.company.com" = "prom/prometheus:latest" # Metrics collection
29 36
37+# Environment Variables Usage:
38+#
39+# For each route, create a .env file named after the domain:
40+# /var/lib/gordon/env/app_company_com.env
41+# /var/lib/gordon/env/api_company_com.env
42+#
43+# Example env file content:
44+# NODE_ENV=production
45+# DATABASE_URL=postgresql://user:pass@db:5432/prod
46+# API_KEY=${pass:company/api-key} # From pass
47+# SECRET_KEY=${sops:secrets.yaml:app.secret} # From sops
48+#
49+# Secret syntax:
50+# ${pass:path/to/secret} - Unix password manager
51+# ${sops:file.yaml:key.path} - SOPS encrypted file
52+
30 # Production deployment workflow:53 # Production deployment workflow:
31 # 1. Build and tag: docker build -t company-app:v2.1.1 .54 # 1. Build and tag: docker build -t company-app:v2.1.1 .
32 # 2. Tag for registry: docker tag company-app:v2.1.1 registry.company.com/company-app:v2.1.1 55 # 2. Tag for registry: docker tag company-app:v2.1.1 registry.company.com/company-app:v2.1.1
33 # 3. Update this config: "app.company.com" = "company-app:v2.1.1"56 # 3. Update this config: "app.company.com" = "company-app:v2.1.1"
34-# 4. Restart Gordon to reload config57+# 4. Create/update env file: /var/lib/gordon/env/app_company_com.env
35-# 5. Push to deploy: docker push registry.company.com/company-app:v2.1.158+# 5. Restart Gordon to reload config
36-# 6. Gordon automatically deploys to app.company.com!⏎̸59+# 6. Push to deploy: docker push registry.company.com/company-app:v2.1.1
60+# 7. Gordon automatically deploys to app.company.com with environment variables!⏎̸
M internal/config/config.go +73 -0
@@ -16,6 +16,7 @@ type Config struct {
1616 RegistryAuth RegistryAuthConfig `mapstructure:"registry_auth"`
1717 Routes map[string]string `mapstructure:"routes"`
1818 AutoRoute AutoRouteConfig `mapstructure:"auto_route"`
19+ Env EnvConfig `mapstructure:"env"`
1920 }
2021
2122 type ServerConfig struct {
@@ -38,6 +39,16 @@ type AutoRouteConfig struct {
3839 Enabled bool `mapstructure:"enabled"`
3940 }
4041
42+type EnvConfig struct {
43+ Dir string `mapstructure:"dir"`
44+ Providers []string `mapstructure:"providers"`
45+}
46+
47+type SecretProvider struct {
48+ Type string `mapstructure:"type"`
49+ Config map[string]string `mapstructure:"config"`
50+}
51+
4152 type Route struct {
4253 Domain string
4354 Image string
@@ -58,6 +69,8 @@ func Load() (*Config, error) {
5869 viper.SetDefault("server.data_dir", defaultDataDir)
5970 viper.SetDefault("registry_auth.enabled", true)
6071 viper.SetDefault("auto_route.enabled", false)
72+ viper.SetDefault("env.dir", filepath.Join(defaultDataDir, "env"))
73+ viper.SetDefault("env.providers", []string{"pass", "sops"})
6174
6275 // Handle the routes manually since Viper struggles with domain names
6376 cfg.Routes = make(map[string]string)
@@ -88,6 +101,17 @@ func Load() (*Config, error) {
88101 if err := viper.UnmarshalKey("auto_route", &cfg.AutoRoute); err != nil {
89102 return nil, fmt.Errorf("unable to decode auto route config: %v", err)
90103 }
104+
105+ // Get env config
106+ if err := viper.UnmarshalKey("env", &cfg.Env); err != nil {
107+ return nil, fmt.Errorf("unable to decode env config: %v", err)
108+ }
109+
110+ // If env.dir is empty after loading config, use the default
111+ if cfg.Env.Dir == "" {
112+ cfg.Env.Dir = filepath.Join(cfg.Server.DataDir, "env")
113+ log.Debug().Str("env_dir", cfg.Env.Dir).Msg("Config had empty env.dir, using default")
114+ }
91115
92116 // Get routes manually from the raw config
93117 routesRaw := viper.Get("routes")
@@ -131,6 +155,11 @@ func Load() (*Config, error) {
131155 }
132156 }
133157
158+ // Validate env config
159+ if err := validateEnvConfig(&cfg.Env); err != nil {
160+ return nil, fmt.Errorf("invalid env config: %w", err)
161+ }
162+
134163 return &cfg, nil
135164 }
136165
@@ -266,6 +295,50 @@ func (c *Config) AddRoute(domain, image string) error {
266295 Str("domain", domain).
267296 Str("image", image).
268297 Msg("Auto-added route to configuration")
298+
299+ // Create env file for the new route (if env loader is available)
300+ // This will be called from the auto-route handler which has access to the env loader
269301
302+ return nil
303+}
304+
305+// validateEnvConfig validates the environment configuration
306+func validateEnvConfig(envCfg *EnvConfig) error {
307+ // Validate env directory path
308+ if envCfg.Dir != "" {
309+ if filepath.IsAbs(envCfg.Dir) {
310+ // For absolute paths, check if the directory is accessible
311+ if info, err := os.Stat(envCfg.Dir); err != nil {
312+ if !os.IsNotExist(err) {
313+ return fmt.Errorf("env.dir path is not accessible: %w", err)
314+ }
315+ // Directory doesn't exist - that's okay, it will be created
316+ } else if !info.IsDir() {
317+ return fmt.Errorf("env.dir path exists but is not a directory: %s", envCfg.Dir)
318+ }
319+ }
320+ // For relative paths, we can't easily validate without changing working directory
321+ }
322+
323+ // Validate secret providers configuration
324+ validProviders := []string{"pass", "sops"}
325+ for _, providerName := range envCfg.Providers {
326+ if providerName == "" {
327+ return fmt.Errorf("empty provider name in env.providers")
328+ }
329+
330+ isValid := false
331+ for _, validType := range validProviders {
332+ if providerName == validType {
333+ isValid = true
334+ break
335+ }
336+ }
337+ if !isValid {
338+ return fmt.Errorf("unsupported provider '%s' in env.providers. Supported providers: %s",
339+ providerName, strings.Join(validProviders, ", "))
340+ }
341+ }
342+
270343 return nil
271344 }⏎̸
@@ -16,6 +16,7 @@ type Config struct {
16 RegistryAuth RegistryAuthConfig `mapstructure:"registry_auth"`16 RegistryAuth RegistryAuthConfig `mapstructure:"registry_auth"`
17 Routes map[string]string `mapstructure:"routes"`17 Routes map[string]string `mapstructure:"routes"`
18 AutoRoute AutoRouteConfig `mapstructure:"auto_route"`18 AutoRoute AutoRouteConfig `mapstructure:"auto_route"`
19+ Env EnvConfig `mapstructure:"env"`
19 }20 }
20 21
21 type ServerConfig struct {22 type ServerConfig struct {
@@ -38,6 +39,16 @@ type AutoRouteConfig struct {
38 Enabled bool `mapstructure:"enabled"`39 Enabled bool `mapstructure:"enabled"`
39 }40 }
40 41
42+type EnvConfig struct {
43+ Dir string `mapstructure:"dir"`
44+ Providers []string `mapstructure:"providers"`
45+}
46+
47+type SecretProvider struct {
48+ Type string `mapstructure:"type"`
49+ Config map[string]string `mapstructure:"config"`
50+}
51+
41 type Route struct {52 type Route struct {
42 Domain string53 Domain string
43 Image string54 Image string
@@ -58,6 +69,8 @@ func Load() (*Config, error) {
58 viper.SetDefault("server.data_dir", defaultDataDir)69 viper.SetDefault("server.data_dir", defaultDataDir)
59 viper.SetDefault("registry_auth.enabled", true)70 viper.SetDefault("registry_auth.enabled", true)
60 viper.SetDefault("auto_route.enabled", false)71 viper.SetDefault("auto_route.enabled", false)
72+ viper.SetDefault("env.dir", filepath.Join(defaultDataDir, "env"))
73+ viper.SetDefault("env.providers", []string{"pass", "sops"})
61 74
62 // Handle the routes manually since Viper struggles with domain names75 // Handle the routes manually since Viper struggles with domain names
63 cfg.Routes = make(map[string]string)76 cfg.Routes = make(map[string]string)
@@ -88,6 +101,17 @@ func Load() (*Config, error) {
88 if err := viper.UnmarshalKey("auto_route", &cfg.AutoRoute); err != nil {101 if err := viper.UnmarshalKey("auto_route", &cfg.AutoRoute); err != nil {
89 return nil, fmt.Errorf("unable to decode auto route config: %v", err)102 return nil, fmt.Errorf("unable to decode auto route config: %v", err)
90 }103 }
104+
105+ // Get env config
106+ if err := viper.UnmarshalKey("env", &cfg.Env); err != nil {
107+ return nil, fmt.Errorf("unable to decode env config: %v", err)
108+ }
109+
110+ // If env.dir is empty after loading config, use the default
111+ if cfg.Env.Dir == "" {
112+ cfg.Env.Dir = filepath.Join(cfg.Server.DataDir, "env")
113+ log.Debug().Str("env_dir", cfg.Env.Dir).Msg("Config had empty env.dir, using default")
114+ }
91 115
92 // Get routes manually from the raw config116 // Get routes manually from the raw config
93 routesRaw := viper.Get("routes")117 routesRaw := viper.Get("routes")
@@ -131,6 +155,11 @@ func Load() (*Config, error) {
131 }155 }
132 }156 }
133 157
158+ // Validate env config
159+ if err := validateEnvConfig(&cfg.Env); err != nil {
160+ return nil, fmt.Errorf("invalid env config: %w", err)
161+ }
162+
134 return &cfg, nil163 return &cfg, nil
135 }164 }
136 165
@@ -266,6 +295,50 @@ func (c *Config) AddRoute(domain, image string) error {
266 Str("domain", domain).295 Str("domain", domain).
267 Str("image", image).296 Str("image", image).
268 Msg("Auto-added route to configuration")297 Msg("Auto-added route to configuration")
298+
299+ // Create env file for the new route (if env loader is available)
300+ // This will be called from the auto-route handler which has access to the env loader
269 301
302+ return nil
303+}
304+
305+// validateEnvConfig validates the environment configuration
306+func validateEnvConfig(envCfg *EnvConfig) error {
307+ // Validate env directory path
308+ if envCfg.Dir != "" {
309+ if filepath.IsAbs(envCfg.Dir) {
310+ // For absolute paths, check if the directory is accessible
311+ if info, err := os.Stat(envCfg.Dir); err != nil {
312+ if !os.IsNotExist(err) {
313+ return fmt.Errorf("env.dir path is not accessible: %w", err)
314+ }
315+ // Directory doesn't exist - that's okay, it will be created
316+ } else if !info.IsDir() {
317+ return fmt.Errorf("env.dir path exists but is not a directory: %s", envCfg.Dir)
318+ }
319+ }
320+ // For relative paths, we can't easily validate without changing working directory
321+ }
322+
323+ // Validate secret providers configuration
324+ validProviders := []string{"pass", "sops"}
325+ for _, providerName := range envCfg.Providers {
326+ if providerName == "" {
327+ return fmt.Errorf("empty provider name in env.providers")
328+ }
329+
330+ isValid := false
331+ for _, validType := range validProviders {
332+ if providerName == validType {
333+ isValid = true
334+ break
335+ }
336+ }
337+ if !isValid {
338+ return fmt.Errorf("unsupported provider '%s' in env.providers. Supported providers: %s",
339+ providerName, strings.Join(validProviders, ", "))
340+ }
341+ }
342+
270 return nil343 return nil
271 }⏎̸344 }⏎̸
M internal/container/manager.go +47 -0
@@ -9,6 +9,7 @@ import (
99 "github.com/rs/zerolog/log"
1010
1111 "gordon/internal/config"
12+ "gordon/internal/env"
1213 "gordon/pkg/runtime"
1314 )
1415
@@ -17,6 +18,7 @@ type Manager struct {
1718 runtime runtime.Runtime
1819 config *config.Config
1920 containers map[string]*runtime.Container // map[domain] -> container
21+ envLoader *env.Loader
2022 mu sync.RWMutex
2123 }
2224
@@ -43,10 +45,34 @@ func NewManager(cfg *config.Config) (*Manager, error) {
4345 log.Info().Str("runtime", cfg.Server.Runtime).Str("version", version).Msg("Container runtime connected")
4446 }
4547
48+ // Create environment loader
49+ envLoader := env.NewLoader(cfg)
50+
51+ // Register secret providers based on config
52+ for _, providerName := range cfg.Env.Providers {
53+ switch providerName {
54+ case "pass":
55+ envLoader.RegisterSecretProvider("pass", env.NewPassProvider())
56+ case "sops":
57+ envLoader.RegisterSecretProvider("sops", env.NewSopsProvider())
58+ }
59+ }
60+
61+ // Ensure env directory exists
62+ if err := envLoader.EnsureEnvDir(); err != nil {
63+ log.Warn().Err(err).Msg("Failed to ensure env directory exists")
64+ }
65+
66+ // Create empty env files for all configured routes (if they don't exist)
67+ if err := envLoader.CreateEnvFilesForRoutes(); err != nil {
68+ log.Warn().Err(err).Msg("Failed to create env files for routes")
69+ }
70+
4671 return &Manager{
4772 runtime: rt,
4873 config: cfg,
4974 containers: make(map[string]*runtime.Container),
75+ envLoader: envLoader,
5076 }, nil
5177 }
5278
@@ -183,11 +209,19 @@ func (m *Manager) DeployContainer(ctx context.Context, route config.Route) (*run
183209 exposedPorts = []int{80, 8080, 3000} // Fallback to common web server ports
184210 }
185211
212+ // Load environment variables for this route
213+ envVars, err := m.envLoader.LoadEnvForRoute(route.Domain)
214+ if err != nil {
215+ log.Error().Err(err).Str("domain", route.Domain).Msg("Failed to load environment variables for route")
216+ return nil, fmt.Errorf("failed to load environment variables for %s: %w", route.Domain, err)
217+ }
218+
186219 // Create container configuration
187220 containerConfig := &runtime.ContainerConfig{
188221 Image: imageRef,
189222 Name: fmt.Sprintf("gordon-%s", route.Domain),
190223 Ports: exposedPorts,
224+ Env: envVars,
191225 Labels: map[string]string{
192226 "gordon.domain": route.Domain,
193227 "gordon.image": route.Image,
@@ -587,6 +621,19 @@ func (m *Manager) Runtime() runtime.Runtime {
587621 return m.runtime
588622 }
589623
624+// UpdateConfig updates the manager's configuration and creates env files for new routes
625+func (m *Manager) UpdateConfig(cfg *config.Config) {
626+ m.config = cfg
627+
628+ // Update env loader config
629+ m.envLoader.UpdateConfig(cfg)
630+
631+ // Create env files for any new routes
632+ if err := m.envLoader.CreateEnvFilesForRoutes(); err != nil {
633+ log.Warn().Err(err).Msg("Failed to create env files for new routes during config update")
634+ }
635+}
636+
590637 // normalizeImageRef normalizes image references for comparison
591638 func normalizeImageRef(imageRef string) string {
592639 // Split image and tag
@@ -9,6 +9,7 @@ import (
9 "github.com/rs/zerolog/log"9 "github.com/rs/zerolog/log"
10 10
11 "gordon/internal/config"11 "gordon/internal/config"
12+ "gordon/internal/env"
12 "gordon/pkg/runtime"13 "gordon/pkg/runtime"
13 )14 )
14 15
@@ -17,6 +18,7 @@ type Manager struct {
17 runtime runtime.Runtime18 runtime runtime.Runtime
18 config *config.Config19 config *config.Config
19 containers map[string]*runtime.Container // map[domain] -> container20 containers map[string]*runtime.Container // map[domain] -> container
21+ envLoader *env.Loader
20 mu sync.RWMutex22 mu sync.RWMutex
21 }23 }
22 24
@@ -43,10 +45,34 @@ func NewManager(cfg *config.Config) (*Manager, error) {
43 log.Info().Str("runtime", cfg.Server.Runtime).Str("version", version).Msg("Container runtime connected")45 log.Info().Str("runtime", cfg.Server.Runtime).Str("version", version).Msg("Container runtime connected")
44 }46 }
45 47
48+ // Create environment loader
49+ envLoader := env.NewLoader(cfg)
50+
51+ // Register secret providers based on config
52+ for _, providerName := range cfg.Env.Providers {
53+ switch providerName {
54+ case "pass":
55+ envLoader.RegisterSecretProvider("pass", env.NewPassProvider())
56+ case "sops":
57+ envLoader.RegisterSecretProvider("sops", env.NewSopsProvider())
58+ }
59+ }
60+
61+ // Ensure env directory exists
62+ if err := envLoader.EnsureEnvDir(); err != nil {
63+ log.Warn().Err(err).Msg("Failed to ensure env directory exists")
64+ }
65+
66+ // Create empty env files for all configured routes (if they don't exist)
67+ if err := envLoader.CreateEnvFilesForRoutes(); err != nil {
68+ log.Warn().Err(err).Msg("Failed to create env files for routes")
69+ }
70+
46 return &Manager{71 return &Manager{
47 runtime: rt,72 runtime: rt,
48 config: cfg,73 config: cfg,
49 containers: make(map[string]*runtime.Container),74 containers: make(map[string]*runtime.Container),
75+ envLoader: envLoader,
50 }, nil76 }, nil
51 }77 }
52 78
@@ -183,11 +209,19 @@ func (m *Manager) DeployContainer(ctx context.Context, route config.Route) (*run
183 exposedPorts = []int{80, 8080, 3000} // Fallback to common web server ports209 exposedPorts = []int{80, 8080, 3000} // Fallback to common web server ports
184 }210 }
185 211
212+ // Load environment variables for this route
213+ envVars, err := m.envLoader.LoadEnvForRoute(route.Domain)
214+ if err != nil {
215+ log.Error().Err(err).Str("domain", route.Domain).Msg("Failed to load environment variables for route")
216+ return nil, fmt.Errorf("failed to load environment variables for %s: %w", route.Domain, err)
217+ }
218+
186 // Create container configuration219 // Create container configuration
187 containerConfig := &runtime.ContainerConfig{220 containerConfig := &runtime.ContainerConfig{
188 Image: imageRef,221 Image: imageRef,
189 Name: fmt.Sprintf("gordon-%s", route.Domain),222 Name: fmt.Sprintf("gordon-%s", route.Domain),
190 Ports: exposedPorts,223 Ports: exposedPorts,
224+ Env: envVars,
191 Labels: map[string]string{225 Labels: map[string]string{
192 "gordon.domain": route.Domain,226 "gordon.domain": route.Domain,
193 "gordon.image": route.Image,227 "gordon.image": route.Image,
@@ -587,6 +621,19 @@ func (m *Manager) Runtime() runtime.Runtime {
587 return m.runtime621 return m.runtime
588 }622 }
589 623
624+// UpdateConfig updates the manager's configuration and creates env files for new routes
625+func (m *Manager) UpdateConfig(cfg *config.Config) {
626+ m.config = cfg
627+
628+ // Update env loader config
629+ m.envLoader.UpdateConfig(cfg)
630+
631+ // Create env files for any new routes
632+ if err := m.envLoader.CreateEnvFilesForRoutes(); err != nil {
633+ log.Warn().Err(err).Msg("Failed to create env files for new routes during config update")
634+ }
635+}
636+
590 // normalizeImageRef normalizes image references for comparison637 // normalizeImageRef normalizes image references for comparison
591 func normalizeImageRef(imageRef string) string {638 func normalizeImageRef(imageRef string) string {
592 // Split image and tag639 // Split image and tag
A internal/env/loader.go +294 -0
@@ -0,0 +1,294 @@
1+package env
2+
3+import (
4+ "bufio"
5+ "fmt"
6+ "os"
7+ "path/filepath"
8+ "strings"
9+
10+ "gordon/internal/config"
11+
12+ "github.com/rs/zerolog/log"
13+)
14+
15+type Loader struct {
16+ cfg *config.Config
17+ secretProviders map[string]SecretProvider
18+}
19+
20+func NewLoader(cfg *config.Config) *Loader {
21+ return &Loader{
22+ cfg: cfg,
23+ secretProviders: make(map[string]SecretProvider),
24+ }
25+}
26+
27+func (l *Loader) RegisterSecretProvider(name string, provider SecretProvider) {
28+ l.secretProviders[name] = provider
29+}
30+
31+func (l *Loader) LoadEnvForRoute(domain string) ([]string, error) {
32+ envVars := []string{}
33+
34+ // Create domain-safe filename (replace dots and other chars with underscores)
35+ safeDomain := strings.ReplaceAll(domain, ".", "_")
36+ safeDomain = strings.ReplaceAll(safeDomain, ":", "_")
37+ safeDomain = strings.ReplaceAll(safeDomain, "/", "_")
38+
39+ envFile := filepath.Join(l.cfg.Env.Dir, safeDomain+".env")
40+
41+ // Check if env file exists
42+ if _, err := os.Stat(envFile); os.IsNotExist(err) {
43+ log.Debug().
44+ Str("domain", domain).
45+ Str("env_file", envFile).
46+ Msg("No env file found for route")
47+ return envVars, nil
48+ }
49+
50+ log.Debug().
51+ Str("domain", domain).
52+ Str("env_file", envFile).
53+ Msg("Loading env file for route")
54+
55+ file, err := os.Open(envFile)
56+ if err != nil {
57+ return nil, fmt.Errorf("failed to open env file %s: %w", envFile, err)
58+ }
59+ defer file.Close()
60+
61+ scanner := bufio.NewScanner(file)
62+ lineNum := 0
63+
64+ for scanner.Scan() {
65+ lineNum++
66+ line := strings.TrimSpace(scanner.Text())
67+
68+ // Skip empty lines and comments
69+ if line == "" || strings.HasPrefix(line, "#") {
70+ continue
71+ }
72+
73+ // Parse KEY=VALUE format
74+ parts := strings.SplitN(line, "=", 2)
75+ if len(parts) != 2 {
76+ log.Warn().
77+ Str("file", envFile).
78+ Int("line", lineNum).
79+ Str("content", line).
80+ Msg("Invalid env file line format, skipping")
81+ continue
82+ }
83+
84+ key := strings.TrimSpace(parts[0])
85+ value := strings.TrimSpace(parts[1])
86+
87+ // Remove quotes if present
88+ if len(value) >= 2 {
89+ if (strings.HasPrefix(value, "\"") && strings.HasSuffix(value, "\"")) ||
90+ (strings.HasPrefix(value, "'") && strings.HasSuffix(value, "'")) {
91+ value = value[1 : len(value)-1]
92+ }
93+ }
94+
95+ // Resolve secrets if value contains secret syntax
96+ resolvedValue, err := l.resolveSecrets(value)
97+ if err != nil {
98+ log.Error().
99+ Err(err).
100+ Str("key", key).
101+ Str("file", envFile).
102+ Int("line", lineNum).
103+ Msg("Failed to resolve secret")
104+ return nil, fmt.Errorf("failed to resolve secret for %s in %s line %d: %w", key, envFile, lineNum, err)
105+ }
106+
107+ envVars = append(envVars, fmt.Sprintf("%s=%s", key, resolvedValue))
108+ }
109+
110+ if err := scanner.Err(); err != nil {
111+ return nil, fmt.Errorf("error reading env file %s: %w", envFile, err)
112+ }
113+
114+ log.Info().
115+ Str("domain", domain).
116+ Int("count", len(envVars)).
117+ Msg("Loaded environment variables for route")
118+
119+ return envVars, nil
120+}
121+
122+func (l *Loader) resolveSecrets(value string) (string, error) {
123+ // Check if value contains secret syntax: ${provider:path}
124+ if !strings.Contains(value, "${") {
125+ return value, nil
126+ }
127+
128+ result := value
129+ for {
130+ start := strings.Index(result, "${")
131+ if start == -1 {
132+ break
133+ }
134+
135+ end := strings.Index(result[start:], "}")
136+ if end == -1 {
137+ return "", fmt.Errorf("unclosed secret syntax in value: %s", value)
138+ }
139+ end += start
140+
141+ secretRef := result[start+2 : end]
142+ parts := strings.SplitN(secretRef, ":", 2)
143+ if len(parts) != 2 {
144+ return "", fmt.Errorf("invalid secret syntax: expected 'provider:path', got '%s'", secretRef)
145+ }
146+
147+ providerName := parts[0]
148+ secretPath := parts[1]
149+
150+ provider, exists := l.secretProviders[providerName]
151+ if !exists {
152+ return "", fmt.Errorf("unknown secret provider: %s", providerName)
153+ }
154+
155+ secretValue, err := provider.GetSecret(secretPath)
156+ if err != nil {
157+ return "", fmt.Errorf("failed to get secret from provider %s: %w", providerName, err)
158+ }
159+
160+ // Replace the secret reference with the actual value
161+ result = result[:start] + secretValue + result[end+1:]
162+ }
163+
164+ return result, nil
165+}
166+
167+func (l *Loader) EnsureEnvDir() error {
168+ if err := os.MkdirAll(l.cfg.Env.Dir, 0700); err != nil {
169+ return fmt.Errorf("failed to create env directory %s: %w", l.cfg.Env.Dir, err)
170+ }
171+
172+ log.Debug().
173+ Str("env_dir", l.cfg.Env.Dir).
174+ Msg("Ensured env directory exists")
175+
176+ return nil
177+}
178+
179+func (l *Loader) CreateEnvFilesForRoutes() error {
180+ routes := l.cfg.GetRoutes()
181+ if len(routes) == 0 {
182+ log.Debug().Msg("No routes configured, skipping env file creation")
183+ return nil
184+ }
185+
186+ createdCount := 0
187+ for _, route := range routes {
188+ // Create domain-safe filename (same logic as LoadEnvForRoute)
189+ safeDomain := strings.ReplaceAll(route.Domain, ".", "_")
190+ safeDomain = strings.ReplaceAll(safeDomain, ":", "_")
191+ safeDomain = strings.ReplaceAll(safeDomain, "/", "_")
192+
193+ envFile := filepath.Join(l.cfg.Env.Dir, safeDomain+".env")
194+
195+ // Check if file already exists
196+ if _, err := os.Stat(envFile); err == nil {
197+ log.Debug().
198+ Str("domain", route.Domain).
199+ Str("env_file", envFile).
200+ Msg("Env file already exists, skipping creation")
201+ continue
202+ } else if !os.IsNotExist(err) {
203+ log.Warn().
204+ Err(err).
205+ Str("env_file", envFile).
206+ Msg("Error checking env file, skipping creation")
207+ continue
208+ }
209+
210+ // Create empty env file with helpful comments
211+ content := fmt.Sprintf(`# Environment variables for route: %s
212+# Image: %s
213+
214+`, route.Domain, route.Image)
215+
216+ if err := os.WriteFile(envFile, []byte(content), 0600); err != nil {
217+ log.Error().
218+ Err(err).
219+ Str("domain", route.Domain).
220+ Str("env_file", envFile).
221+ Msg("Failed to create env file")
222+ continue
223+ }
224+
225+ createdCount++
226+ log.Info().
227+ Str("domain", route.Domain).
228+ Str("env_file", envFile).
229+ Msg("Created empty env file for route")
230+ }
231+
232+ if createdCount > 0 {
233+ log.Info().
234+ Int("created", createdCount).
235+ Int("total_routes", len(routes)).
236+ Str("env_dir", l.cfg.Env.Dir).
237+ Msg("Created env files for routes")
238+ }
239+
240+ return nil
241+}
242+
243+func (l *Loader) CreateEnvFileForRoute(domain, image string) error {
244+ // Create domain-safe filename (same logic as LoadEnvForRoute)
245+ safeDomain := strings.ReplaceAll(domain, ".", "_")
246+ safeDomain = strings.ReplaceAll(safeDomain, ":", "_")
247+ safeDomain = strings.ReplaceAll(safeDomain, "/", "_")
248+
249+ envFile := filepath.Join(l.cfg.Env.Dir, safeDomain+".env")
250+
251+ // Check if file already exists
252+ if _, err := os.Stat(envFile); err == nil {
253+ log.Debug().
254+ Str("domain", domain).
255+ Str("env_file", envFile).
256+ Msg("Env file already exists, skipping creation")
257+ return nil
258+ } else if !os.IsNotExist(err) {
259+ return fmt.Errorf("error checking env file %s: %w", envFile, err)
260+ }
261+
262+ // Ensure directory exists
263+ if err := l.EnsureEnvDir(); err != nil {
264+ return err
265+ }
266+
267+ // Create empty env file with helpful comments
268+ content := fmt.Sprintf(`# Environment variables for route: %s
269+# Image: %s
270+
271+`, domain, image)
272+
273+ if err := os.WriteFile(envFile, []byte(content), 0600); err != nil {
274+ return fmt.Errorf("failed to create env file %s: %w", envFile, err)
275+ }
276+
277+ log.Info().
278+ Str("domain", domain).
279+ Str("env_file", envFile).
280+ Msg("Created empty env file for new route")
281+
282+ return nil
283+}
284+
285+func (l *Loader) UpdateConfig(cfg *config.Config) {
286+ l.cfg = cfg
287+}
288+
289+func (l *Loader) GetEnvFilePath(domain string) string {
290+ safeDomain := strings.ReplaceAll(domain, ".", "_")
291+ safeDomain = strings.ReplaceAll(safeDomain, ":", "_")
292+ safeDomain = strings.ReplaceAll(safeDomain, "/", "_")
293+ return filepath.Join(l.cfg.Env.Dir, safeDomain+".env")
294+}⏎̸
@@ -0,0 +1,294 @@
1+package env
2+
3+import (
4+ "bufio"
5+ "fmt"
6+ "os"
7+ "path/filepath"
8+ "strings"
9+
10+ "gordon/internal/config"
11+
12+ "github.com/rs/zerolog/log"
13+)
14+
15+type Loader struct {
16+ cfg *config.Config
17+ secretProviders map[string]SecretProvider
18+}
19+
20+func NewLoader(cfg *config.Config) *Loader {
21+ return &Loader{
22+ cfg: cfg,
23+ secretProviders: make(map[string]SecretProvider),
24+ }
25+}
26+
27+func (l *Loader) RegisterSecretProvider(name string, provider SecretProvider) {
28+ l.secretProviders[name] = provider
29+}
30+
31+func (l *Loader) LoadEnvForRoute(domain string) ([]string, error) {
32+ envVars := []string{}
33+
34+ // Create domain-safe filename (replace dots and other chars with underscores)
35+ safeDomain := strings.ReplaceAll(domain, ".", "_")
36+ safeDomain = strings.ReplaceAll(safeDomain, ":", "_")
37+ safeDomain = strings.ReplaceAll(safeDomain, "/", "_")
38+
39+ envFile := filepath.Join(l.cfg.Env.Dir, safeDomain+".env")
40+
41+ // Check if env file exists
42+ if _, err := os.Stat(envFile); os.IsNotExist(err) {
43+ log.Debug().
44+ Str("domain", domain).
45+ Str("env_file", envFile).
46+ Msg("No env file found for route")
47+ return envVars, nil
48+ }
49+
50+ log.Debug().
51+ Str("domain", domain).
52+ Str("env_file", envFile).
53+ Msg("Loading env file for route")
54+
55+ file, err := os.Open(envFile)
56+ if err != nil {
57+ return nil, fmt.Errorf("failed to open env file %s: %w", envFile, err)
58+ }
59+ defer file.Close()
60+
61+ scanner := bufio.NewScanner(file)
62+ lineNum := 0
63+
64+ for scanner.Scan() {
65+ lineNum++
66+ line := strings.TrimSpace(scanner.Text())
67+
68+ // Skip empty lines and comments
69+ if line == "" || strings.HasPrefix(line, "#") {
70+ continue
71+ }
72+
73+ // Parse KEY=VALUE format
74+ parts := strings.SplitN(line, "=", 2)
75+ if len(parts) != 2 {
76+ log.Warn().
77+ Str("file", envFile).
78+ Int("line", lineNum).
79+ Str("content", line).
80+ Msg("Invalid env file line format, skipping")
81+ continue
82+ }
83+
84+ key := strings.TrimSpace(parts[0])
85+ value := strings.TrimSpace(parts[1])
86+
87+ // Remove quotes if present
88+ if len(value) >= 2 {
89+ if (strings.HasPrefix(value, "\"") && strings.HasSuffix(value, "\"")) ||
90+ (strings.HasPrefix(value, "'") && strings.HasSuffix(value, "'")) {
91+ value = value[1 : len(value)-1]
92+ }
93+ }
94+
95+ // Resolve secrets if value contains secret syntax
96+ resolvedValue, err := l.resolveSecrets(value)
97+ if err != nil {
98+ log.Error().
99+ Err(err).
100+ Str("key", key).
101+ Str("file", envFile).
102+ Int("line", lineNum).
103+ Msg("Failed to resolve secret")
104+ return nil, fmt.Errorf("failed to resolve secret for %s in %s line %d: %w", key, envFile, lineNum, err)
105+ }
106+
107+ envVars = append(envVars, fmt.Sprintf("%s=%s", key, resolvedValue))
108+ }
109+
110+ if err := scanner.Err(); err != nil {
111+ return nil, fmt.Errorf("error reading env file %s: %w", envFile, err)
112+ }
113+
114+ log.Info().
115+ Str("domain", domain).
116+ Int("count", len(envVars)).
117+ Msg("Loaded environment variables for route")
118+
119+ return envVars, nil
120+}
121+
122+func (l *Loader) resolveSecrets(value string) (string, error) {
123+ // Check if value contains secret syntax: ${provider:path}
124+ if !strings.Contains(value, "${") {
125+ return value, nil
126+ }
127+
128+ result := value
129+ for {
130+ start := strings.Index(result, "${")
131+ if start == -1 {
132+ break
133+ }
134+
135+ end := strings.Index(result[start:], "}")
136+ if end == -1 {
137+ return "", fmt.Errorf("unclosed secret syntax in value: %s", value)
138+ }
139+ end += start
140+
141+ secretRef := result[start+2 : end]
142+ parts := strings.SplitN(secretRef, ":", 2)
143+ if len(parts) != 2 {
144+ return "", fmt.Errorf("invalid secret syntax: expected 'provider:path', got '%s'", secretRef)
145+ }
146+
147+ providerName := parts[0]
148+ secretPath := parts[1]
149+
150+ provider, exists := l.secretProviders[providerName]
151+ if !exists {
152+ return "", fmt.Errorf("unknown secret provider: %s", providerName)
153+ }
154+
155+ secretValue, err := provider.GetSecret(secretPath)
156+ if err != nil {
157+ return "", fmt.Errorf("failed to get secret from provider %s: %w", providerName, err)
158+ }
159+
160+ // Replace the secret reference with the actual value
161+ result = result[:start] + secretValue + result[end+1:]
162+ }
163+
164+ return result, nil
165+}
166+
167+func (l *Loader) EnsureEnvDir() error {
168+ if err := os.MkdirAll(l.cfg.Env.Dir, 0700); err != nil {
169+ return fmt.Errorf("failed to create env directory %s: %w", l.cfg.Env.Dir, err)
170+ }
171+
172+ log.Debug().
173+ Str("env_dir", l.cfg.Env.Dir).
174+ Msg("Ensured env directory exists")
175+
176+ return nil
177+}
178+
179+func (l *Loader) CreateEnvFilesForRoutes() error {
180+ routes := l.cfg.GetRoutes()
181+ if len(routes) == 0 {
182+ log.Debug().Msg("No routes configured, skipping env file creation")
183+ return nil
184+ }
185+
186+ createdCount := 0
187+ for _, route := range routes {
188+ // Create domain-safe filename (same logic as LoadEnvForRoute)
189+ safeDomain := strings.ReplaceAll(route.Domain, ".", "_")
190+ safeDomain = strings.ReplaceAll(safeDomain, ":", "_")
191+ safeDomain = strings.ReplaceAll(safeDomain, "/", "_")
192+
193+ envFile := filepath.Join(l.cfg.Env.Dir, safeDomain+".env")
194+
195+ // Check if file already exists
196+ if _, err := os.Stat(envFile); err == nil {
197+ log.Debug().
198+ Str("domain", route.Domain).
199+ Str("env_file", envFile).
200+ Msg("Env file already exists, skipping creation")
201+ continue
202+ } else if !os.IsNotExist(err) {
203+ log.Warn().
204+ Err(err).
205+ Str("env_file", envFile).
206+ Msg("Error checking env file, skipping creation")
207+ continue
208+ }
209+
210+ // Create empty env file with helpful comments
211+ content := fmt.Sprintf(`# Environment variables for route: %s
212+# Image: %s
213+
214+`, route.Domain, route.Image)
215+
216+ if err := os.WriteFile(envFile, []byte(content), 0600); err != nil {
217+ log.Error().
218+ Err(err).
219+ Str("domain", route.Domain).
220+ Str("env_file", envFile).
221+ Msg("Failed to create env file")
222+ continue
223+ }
224+
225+ createdCount++
226+ log.Info().
227+ Str("domain", route.Domain).
228+ Str("env_file", envFile).
229+ Msg("Created empty env file for route")
230+ }
231+
232+ if createdCount > 0 {
233+ log.Info().
234+ Int("created", createdCount).
235+ Int("total_routes", len(routes)).
236+ Str("env_dir", l.cfg.Env.Dir).
237+ Msg("Created env files for routes")
238+ }
239+
240+ return nil
241+}
242+
243+func (l *Loader) CreateEnvFileForRoute(domain, image string) error {
244+ // Create domain-safe filename (same logic as LoadEnvForRoute)
245+ safeDomain := strings.ReplaceAll(domain, ".", "_")
246+ safeDomain = strings.ReplaceAll(safeDomain, ":", "_")
247+ safeDomain = strings.ReplaceAll(safeDomain, "/", "_")
248+
249+ envFile := filepath.Join(l.cfg.Env.Dir, safeDomain+".env")
250+
251+ // Check if file already exists
252+ if _, err := os.Stat(envFile); err == nil {
253+ log.Debug().
254+ Str("domain", domain).
255+ Str("env_file", envFile).
256+ Msg("Env file already exists, skipping creation")
257+ return nil
258+ } else if !os.IsNotExist(err) {
259+ return fmt.Errorf("error checking env file %s: %w", envFile, err)
260+ }
261+
262+ // Ensure directory exists
263+ if err := l.EnsureEnvDir(); err != nil {
264+ return err
265+ }
266+
267+ // Create empty env file with helpful comments
268+ content := fmt.Sprintf(`# Environment variables for route: %s
269+# Image: %s
270+
271+`, domain, image)
272+
273+ if err := os.WriteFile(envFile, []byte(content), 0600); err != nil {
274+ return fmt.Errorf("failed to create env file %s: %w", envFile, err)
275+ }
276+
277+ log.Info().
278+ Str("domain", domain).
279+ Str("env_file", envFile).
280+ Msg("Created empty env file for new route")
281+
282+ return nil
283+}
284+
285+func (l *Loader) UpdateConfig(cfg *config.Config) {
286+ l.cfg = cfg
287+}
288+
289+func (l *Loader) GetEnvFilePath(domain string) string {
290+ safeDomain := strings.ReplaceAll(domain, ".", "_")
291+ safeDomain = strings.ReplaceAll(safeDomain, ":", "_")
292+ safeDomain = strings.ReplaceAll(safeDomain, "/", "_")
293+ return filepath.Join(l.cfg.Env.Dir, safeDomain+".env")
294+}⏎̸
A internal/env/providers.go +104 -0
@@ -0,0 +1,104 @@
1+package env
2+
3+import (
4+ "context"
5+ "fmt"
6+ "os/exec"
7+ "strings"
8+ "time"
9+
10+ "github.com/rs/zerolog/log"
11+)
12+
13+type SecretProvider interface {
14+ GetSecret(path string) (string, error)
15+ Name() string
16+}
17+
18+type PassProvider struct {
19+ timeout time.Duration
20+}
21+
22+func NewPassProvider() *PassProvider {
23+ return &PassProvider{
24+ timeout: 10 * time.Second,
25+ }
26+}
27+
28+func (p *PassProvider) Name() string {
29+ return "pass"
30+}
31+
32+func (p *PassProvider) GetSecret(path string) (string, error) {
33+ ctx, cancel := context.WithTimeout(context.Background(), p.timeout)
34+ defer cancel()
35+
36+ cmd := exec.CommandContext(ctx, "pass", "show", path)
37+ output, err := cmd.Output()
38+ if err != nil {
39+ if exitError, ok := err.(*exec.ExitError); ok {
40+ return "", fmt.Errorf("pass command failed: %s", string(exitError.Stderr))
41+ }
42+ return "", fmt.Errorf("failed to execute pass command: %w", err)
43+ }
44+
45+ secret := strings.TrimSpace(string(output))
46+ if secret == "" {
47+ return "", fmt.Errorf("empty secret returned from pass for path: %s", path)
48+ }
49+
50+ log.Debug().
51+ Str("path", path).
52+ Msg("Successfully retrieved secret from pass")
53+
54+ return secret, nil
55+}
56+
57+type SopsProvider struct {
58+ timeout time.Duration
59+}
60+
61+func NewSopsProvider() *SopsProvider {
62+ return &SopsProvider{
63+ timeout: 10 * time.Second,
64+ }
65+}
66+
67+func (s *SopsProvider) Name() string {
68+ return "sops"
69+}
70+
71+func (s *SopsProvider) GetSecret(path string) (string, error) {
72+ ctx, cancel := context.WithTimeout(context.Background(), s.timeout)
73+ defer cancel()
74+
75+ // Extract key from path format: file.yaml:key.nested.path
76+ parts := strings.SplitN(path, ":", 2)
77+ if len(parts) != 2 {
78+ return "", fmt.Errorf("sops path must be in format 'file:key', got: %s", path)
79+ }
80+
81+ filePath := parts[0]
82+ keyPath := parts[1]
83+
84+ // Use sops -d --extract to get specific key
85+ cmd := exec.CommandContext(ctx, "sops", "-d", "--extract", fmt.Sprintf("[\"%s\"]", keyPath), filePath)
86+ output, err := cmd.Output()
87+ if err != nil {
88+ if exitError, ok := err.(*exec.ExitError); ok {
89+ return "", fmt.Errorf("sops command failed: %s", string(exitError.Stderr))
90+ }
91+ return "", fmt.Errorf("failed to execute sops command: %w", err)
92+ }
93+
94+ secret := strings.TrimSpace(string(output))
95+ if secret == "" {
96+ return "", fmt.Errorf("empty secret returned from sops for path: %s", path)
97+ }
98+
99+ log.Debug().
100+ Str("path", path).
101+ Msg("Successfully retrieved secret from sops")
102+
103+ return secret, nil
104+}⏎̸
@@ -0,0 +1,104 @@
1+package env
2+
3+import (
4+ "context"
5+ "fmt"
6+ "os/exec"
7+ "strings"
8+ "time"
9+
10+ "github.com/rs/zerolog/log"
11+)
12+
13+type SecretProvider interface {
14+ GetSecret(path string) (string, error)
15+ Name() string
16+}
17+
18+type PassProvider struct {
19+ timeout time.Duration
20+}
21+
22+func NewPassProvider() *PassProvider {
23+ return &PassProvider{
24+ timeout: 10 * time.Second,
25+ }
26+}
27+
28+func (p *PassProvider) Name() string {
29+ return "pass"
30+}
31+
32+func (p *PassProvider) GetSecret(path string) (string, error) {
33+ ctx, cancel := context.WithTimeout(context.Background(), p.timeout)
34+ defer cancel()
35+
36+ cmd := exec.CommandContext(ctx, "pass", "show", path)
37+ output, err := cmd.Output()
38+ if err != nil {
39+ if exitError, ok := err.(*exec.ExitError); ok {
40+ return "", fmt.Errorf("pass command failed: %s", string(exitError.Stderr))
41+ }
42+ return "", fmt.Errorf("failed to execute pass command: %w", err)
43+ }
44+
45+ secret := strings.TrimSpace(string(output))
46+ if secret == "" {
47+ return "", fmt.Errorf("empty secret returned from pass for path: %s", path)
48+ }
49+
50+ log.Debug().
51+ Str("path", path).
52+ Msg("Successfully retrieved secret from pass")
53+
54+ return secret, nil
55+}
56+
57+type SopsProvider struct {
58+ timeout time.Duration
59+}
60+
61+func NewSopsProvider() *SopsProvider {
62+ return &SopsProvider{
63+ timeout: 10 * time.Second,
64+ }
65+}
66+
67+func (s *SopsProvider) Name() string {
68+ return "sops"
69+}
70+
71+func (s *SopsProvider) GetSecret(path string) (string, error) {
72+ ctx, cancel := context.WithTimeout(context.Background(), s.timeout)
73+ defer cancel()
74+
75+ // Extract key from path format: file.yaml:key.nested.path
76+ parts := strings.SplitN(path, ":", 2)
77+ if len(parts) != 2 {
78+ return "", fmt.Errorf("sops path must be in format 'file:key', got: %s", path)
79+ }
80+
81+ filePath := parts[0]
82+ keyPath := parts[1]
83+
84+ // Use sops -d --extract to get specific key
85+ cmd := exec.CommandContext(ctx, "sops", "-d", "--extract", fmt.Sprintf("[\"%s\"]", keyPath), filePath)
86+ output, err := cmd.Output()
87+ if err != nil {
88+ if exitError, ok := err.(*exec.ExitError); ok {
89+ return "", fmt.Errorf("sops command failed: %s", string(exitError.Stderr))
90+ }
91+ return "", fmt.Errorf("failed to execute sops command: %w", err)
92+ }
93+
94+ secret := strings.TrimSpace(string(output))
95+ if secret == "" {
96+ return "", fmt.Errorf("empty secret returned from sops for path: %s", path)
97+ }
98+
99+ log.Debug().
100+ Str("path", path).
101+ Msg("Successfully retrieved secret from sops")
102+
103+ return secret, nil
104+}⏎̸
M internal/events/handlers.go +44 -1
@@ -25,7 +25,7 @@ func NewContainerEventHandler(manager *container.Manager, config *config.Config)
2525
2626 func (h *ContainerEventHandler) CanHandle(eventType EventType) bool {
2727 switch eventType {
28- case ImagePushed, ConfigReload, ContainerStop, ContainerStart:
28+ case ImagePushed, ConfigReload, ManualReload, ContainerStop, ContainerStart:
2929 return true
3030 default:
3131 return false
@@ -38,6 +38,8 @@ func (h *ContainerEventHandler) Handle(event Event) error {
3838 return h.handleImagePushed(event)
3939 case ConfigReload:
4040 return h.handleConfigReload(event)
41+ case ManualReload:
42+ return h.handleManualReload(event)
4143 case ContainerStop:
4244 return h.handleContainerStop(event)
4345 case ContainerStart:
@@ -167,6 +169,47 @@ func (h *ContainerEventHandler) handleConfigReload(event Event) error {
167169 return nil
168170 }
169171
172+func (h *ContainerEventHandler) handleManualReload(event Event) error {
173+ log.Info().
174+ Str("event_id", event.ID).
175+ Msg("Processing manual reload event - redeploying all containers with updated environment variables")
176+
177+ ctx := context.Background()
178+ routes := h.config.GetRoutes()
179+ redeployedCount := 0
180+ var errors []error
181+
182+ // Redeploy each container to pick up new environment variables
183+ for _, route := range routes {
184+ // Check if container exists for this route
185+ if _, exists := h.manager.GetContainer(route.Domain); !exists {
186+ log.Debug().Str("domain", route.Domain).Msg("No container found for route, skipping")
187+ continue
188+ }
189+
190+ log.Info().Str("domain", route.Domain).Str("image", route.Image).Msg("Redeploying container with updated environment variables")
191+
192+ // Deploy container (this will stop the old one and start a new one with updated env vars)
193+ _, err := h.manager.DeployContainer(ctx, route)
194+ if err != nil {
195+ log.Error().Str("domain", route.Domain).Err(err).Msg("Failed to redeploy container during manual reload")
196+ errors = append(errors, fmt.Errorf("failed to redeploy container for %s: %w", route.Domain, err))
197+ continue
198+ }
199+
200+ redeployedCount++
201+ log.Info().Str("domain", route.Domain).Msg("Container redeployed successfully with updated environment variables")
202+ }
203+
204+ if len(errors) > 0 {
205+ log.Warn().Int("redeployed", redeployedCount).Int("errors", len(errors)).Msg("Manual reload completed with some errors")
206+ return fmt.Errorf("manual reload completed with %d errors: %v", len(errors), errors)
207+ }
208+
209+ log.Info().Int("redeployed", redeployedCount).Int("total", len(routes)).Msg("Manual reload completed successfully")
210+ return nil
211+}
212+
170213 func (h *ContainerEventHandler) handleContainerStop(event Event) error {
171214 containerID := event.ContainerID
172215 if containerID == "" {
@@ -25,7 +25,7 @@ func NewContainerEventHandler(manager *container.Manager, config *config.Config)
25 25
26 func (h *ContainerEventHandler) CanHandle(eventType EventType) bool {26 func (h *ContainerEventHandler) CanHandle(eventType EventType) bool {
27 switch eventType {27 switch eventType {
28- case ImagePushed, ConfigReload, ContainerStop, ContainerStart:28+ case ImagePushed, ConfigReload, ManualReload, ContainerStop, ContainerStart:
29 return true29 return true
30 default:30 default:
31 return false31 return false
@@ -38,6 +38,8 @@ func (h *ContainerEventHandler) Handle(event Event) error {
38 return h.handleImagePushed(event)38 return h.handleImagePushed(event)
39 case ConfigReload:39 case ConfigReload:
40 return h.handleConfigReload(event)40 return h.handleConfigReload(event)
41+ case ManualReload:
42+ return h.handleManualReload(event)
41 case ContainerStop:43 case ContainerStop:
42 return h.handleContainerStop(event)44 return h.handleContainerStop(event)
43 case ContainerStart:45 case ContainerStart:
@@ -167,6 +169,47 @@ func (h *ContainerEventHandler) handleConfigReload(event Event) error {
167 return nil169 return nil
168 }170 }
169 171
172+func (h *ContainerEventHandler) handleManualReload(event Event) error {
173+ log.Info().
174+ Str("event_id", event.ID).
175+ Msg("Processing manual reload event - redeploying all containers with updated environment variables")
176+
177+ ctx := context.Background()
178+ routes := h.config.GetRoutes()
179+ redeployedCount := 0
180+ var errors []error
181+
182+ // Redeploy each container to pick up new environment variables
183+ for _, route := range routes {
184+ // Check if container exists for this route
185+ if _, exists := h.manager.GetContainer(route.Domain); !exists {
186+ log.Debug().Str("domain", route.Domain).Msg("No container found for route, skipping")
187+ continue
188+ }
189+
190+ log.Info().Str("domain", route.Domain).Str("image", route.Image).Msg("Redeploying container with updated environment variables")
191+
192+ // Deploy container (this will stop the old one and start a new one with updated env vars)
193+ _, err := h.manager.DeployContainer(ctx, route)
194+ if err != nil {
195+ log.Error().Str("domain", route.Domain).Err(err).Msg("Failed to redeploy container during manual reload")
196+ errors = append(errors, fmt.Errorf("failed to redeploy container for %s: %w", route.Domain, err))
197+ continue
198+ }
199+
200+ redeployedCount++
201+ log.Info().Str("domain", route.Domain).Msg("Container redeployed successfully with updated environment variables")
202+ }
203+
204+ if len(errors) > 0 {
205+ log.Warn().Int("redeployed", redeployedCount).Int("errors", len(errors)).Msg("Manual reload completed with some errors")
206+ return fmt.Errorf("manual reload completed with %d errors: %v", len(errors), errors)
207+ }
208+
209+ log.Info().Int("redeployed", redeployedCount).Int("total", len(routes)).Msg("Manual reload completed successfully")
210+ return nil
211+}
212+
170 func (h *ContainerEventHandler) handleContainerStop(event Event) error {213 func (h *ContainerEventHandler) handleContainerStop(event Event) error {
171 containerID := event.ContainerID214 containerID := event.ContainerID
172 if containerID == "" {215 if containerID == "" {
M internal/events/types.go +1 -0
@@ -10,6 +10,7 @@ const (
1010 ImagePushed EventType = "image.pushed"
1111 ImageDeleted EventType = "image.deleted"
1212 ConfigReload EventType = "config.reload"
13+ ManualReload EventType = "manual.reload"
1314 ContainerStop EventType = "container.stop"
1415 ContainerStart EventType = "container.start"
1516 ContainerHealthCheck EventType = "container.health_check"
@@ -10,6 +10,7 @@ const (
10 ImagePushed EventType = "image.pushed"10 ImagePushed EventType = "image.pushed"
11 ImageDeleted EventType = "image.deleted"11 ImageDeleted EventType = "image.deleted"
12 ConfigReload EventType = "config.reload"12 ConfigReload EventType = "config.reload"
13+ ManualReload EventType = "manual.reload"
13 ContainerStop EventType = "container.stop"14 ContainerStop EventType = "container.stop"
14 ContainerStart EventType = "container.start"15 ContainerStart EventType = "container.start"
15 ContainerHealthCheck EventType = "container.health_check"16 ContainerHealthCheck EventType = "container.health_check"
M internal/proxy/server.go +1 -0
@@ -285,3 +285,4 @@ func (h *ProxyEventHandler) Handle(event events.Event) error {
285285 return fmt.Errorf("unsupported event type: %s", event.Type)
286286 }
287287
288+
@@ -285,3 +285,4 @@ func (h *ProxyEventHandler) Handle(event events.Event) error {
285 return fmt.Errorf("unsupported event type: %s", event.Type)285 return fmt.Errorf("unsupported event type: %s", event.Type)
286 }286 }
287 287
288+

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