detached 4912d47

Merge pull request #32 from bnema/feat/v2.6.0-attachments-crud-auth-config

feat!: v2.6.0 - Unified auth config & attachments CRUD CLI
commit
4912d478c80c1043086b5b29a96c332e343f41a5
author
bnema · · 2026-01-17 19:28 UTC
committer
GitHub · · 2026-01-17 19:28 UTC
merge of
b9b6391 + 59d74ee · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
34 files · +3458 -980
A docs/cli/attachments.md +184 -0
@@ -0,0 +1,184 @@
1+# Attachments Commands
2+
3+Manage attachments on local or remote Gordon instances.
4+
5+## Requirements
6+
7+Attachments require `network_isolation.enabled = true` in your configuration. Without network isolation, containers use Docker's default bridge network which does not provide DNS resolution - your app won't be able to reach attachments by hostname (e.g., `postgres:5432`).
8+
9+The CLI will warn you if you try to add an attachment without network isolation enabled.
10+
11+## gordon attachments
12+
13+### Subcommands
14+
15+| Subcommand | Description |
16+|------------|-------------|
17+| `list` | List all attachments or attachments for a specific target |
18+| `add` | Add an attachment to a domain or network group |
19+| `remove` | Remove an attachment from a domain or network group |
20+
21+### Alias
22+
23+`gordon attach` is an alias for `gordon attachments`:
24+
25+```bash
26+gordon attach list
27+gordon attach add app.example.com postgres:18
28+gordon attach remove app.example.com postgres:18
29+```
30+
31+---
32+
33+## gordon attachments list
34+
35+List all configured attachments.
36+
37+```bash
38+# List all attachments
39+gordon attachments list
40+
41+# List attachments for a specific domain or group
42+gordon attachments list app.example.com
43+gordon attachments list backend
44+
45+# Remote mode
46+gordon attachments list --remote https://gordon.mydomain.com --token $TOKEN
47+```
48+
49+### Output
50+
51+```
52+Target Attachments
53+--------------------------------------------------------------------------------
54+app.example.com postgres:18, redis:7-alpine
55+api.example.com postgres:18
56+backend (group) rabbitmq:3-management
57+```
58+
59+---
60+
61+## gordon attachments add
62+
63+Add an attachment to a domain or network group.
64+
65+```bash
66+gordon attachments add <domain-or-group> <image>
67+gordon attachments add app.example.com postgres:18
68+gordon attachments add backend redis:7-alpine
69+```
70+
71+### Arguments
72+
73+| Argument | Description |
74+|----------|-------------|
75+| `<domain-or-group>` | The domain name or network group name |
76+| `<image>` | The container image to attach |
77+
78+### Options
79+
80+| Option | Description |
81+|--------|-------------|
82+| `--remote` | Remote Gordon URL |
83+| `--token` | Authentication token for remote |
84+
85+### Examples
86+
87+```bash
88+# Add database to a domain
89+gordon attachments add app.example.com postgres:18
90+
91+# Add cache to a network group (shared by all domains in the group)
92+gordon attachments add backend redis:7-alpine
93+
94+# Remote
95+gordon attachments add app.example.com postgres:18 --remote https://gordon.mydomain.com --token $TOKEN
96+```
97+
98+---
99+
100+## gordon attachments remove
101+
102+Remove an attachment.
103+
104+```bash
105+gordon attachments remove <domain-or-group> <image>
106+gordon attachments remove app.example.com postgres:18
107+```
108+
109+### Arguments
110+
111+| Argument | Description |
112+|----------|-------------|
113+| `<domain-or-group>` | The domain name or network group name |
114+| `<image>` | The container image to remove |
115+
116+### Options
117+
118+| Option | Description |
119+|--------|-------------|
120+| `--remote` | Remote Gordon URL |
121+| `--token` | Authentication token for remote |
122+
123+### Examples
124+
125+```bash
126+# Remove database from a domain
127+gordon attachments remove app.example.com postgres:18
128+
129+# Remove from network group
130+gordon attachments remove backend redis:7-alpine
131+
132+# Remote
133+gordon attachments remove app.example.com postgres:18 --remote https://gordon.mydomain.com --token $TOKEN
134+```
135+
136+---
137+
138+## Workflow Examples
139+
140+### Add Database and Cache
141+
142+```bash
143+# Add PostgreSQL database
144+gordon attachments add app.example.com postgres:18
145+
146+# Add Redis cache
147+gordon attachments add app.example.com redis:7-alpine
148+
149+# Verify
150+gordon attachments list app.example.com
151+```
152+
153+### Shared Services via Network Groups
154+
155+```bash
156+# First, ensure you have a network group defined in your config:
157+# [network_groups]
158+# "backend" = ["app.example.com", "api.example.com"]
159+
160+# Add shared cache to the group
161+gordon attachments add backend redis:7-alpine
162+
163+# Both app.example.com and api.example.com can now access the shared Redis
164+```
165+
166+### CI/CD Integration
167+
168+```bash
169+# In your CI/CD pipeline
170+export GORDON_REMOTE=https://gordon.mydomain.com
171+export GORDON_TOKEN=$GORDON_TOKEN
172+
173+# Add new attachment
174+gordon attachments add app.example.com elasticsearch:8
175+
176+# Trigger redeploy to pick up the new attachment
177+gordon routes deploy app.example.com
178+```
179+
180+## Related
181+
182+- [CLI Overview](./index.md)
183+- [Attachments Configuration](../config/attachments.md)
184+- [Network Groups](../config/network-groups.md)
A docs/cli/auth.md +268 -0
@@ -0,0 +1,268 @@
1+# Authentication Commands
2+
3+Manage Gordon server authentication tokens and passwords.
4+
5+## gordon auth
6+
7+### Subcommands
8+
9+| Subcommand | Description |
10+|------------|-------------|
11+| `token generate` | Generate a new JWT token |
12+| `token list` | List all stored tokens |
13+| `token revoke` | Revoke a token by ID (use `--all` to revoke all tokens) |
14+| `password hash` | Generate bcrypt password hash |
15+| `internal` | Show internal registry credentials |
16+
17+---
18+
19+## gordon auth token generate
20+
21+Generate a new JWT authentication token for registry access.
22+
23+```bash
24+gordon auth token generate --subject <name> [options]
25+```
26+
27+### Options
28+
29+| Option | Default | Description |
30+|--------|---------|-------------|
31+| `--subject` | (required) | Username/subject for the token |
32+| `--scopes` | `push,pull` | Comma-separated scopes |
33+| `--expiry` | `30d` | Duration until expiry (0 = never) |
34+| `-c, --config` | Auto | Path to config file |
35+
36+### Duration Format
37+
38+Supports human-friendly duration units:
39+
40+| Unit | Description | Example |
41+|------|-------------|---------|
42+| `d` | Days (24 hours) | `30d` |
43+| `w` | Weeks (7 days) | `2w` |
44+| `M` | Months (30 days) | `6M` |
45+| `y` | Years (365 days) | `1y` |
46+
47+Compound durations are also supported: `1y6M`, `2w3d`, `1d12h`
48+
49+Standard Go durations also work: `24h`, `30m`, `1h30m`
50+
51+### Examples
52+
53+```bash
54+# CI token that never expires
55+gordon auth token generate --subject github-actions --expiry 0
56+
57+# Read-only token for 30 days
58+gordon auth token generate --subject reader --scopes pull --expiry 30d
59+
60+# Push-only token for 1 year
61+gordon auth token generate --subject builder --scopes push --expiry 1y
62+
63+# Temporary token (24 hours)
64+gordon auth token generate --subject temp --expiry 24h
65+
66+# Token for 6 months
67+gordon auth token generate --subject deploy --expiry 6M
68+```
69+
70+### Output
71+
72+```
73+Token generated successfully!
74+Subject: github-actions
75+Scopes: push, pull
76+Expiry: never
77+
78+Token (use as password with docker login):
79+eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
80+
81+Usage: docker login -u github-actions -p <token> <registry>
82+```
83+
84+---
85+
86+## gordon auth token list
87+
88+List all stored authentication tokens.
89+
90+```bash
91+gordon auth token list [options]
92+```
93+
94+### Output
95+
96+```
97+ID Subject Expires Revoked
98+------------------------------------------------------------------------------------------
99+a1b2c3d4-e5f6-7890-abcd-ef1234567890 github-actions never no
100+b2c3d4e5-f6a7-8901-bcde-f12345678901 deploy-bot 2024-02-15 10:30 no
101+c3d4e5f6-a7b8-9012-cdef-123456789012 old-token 2024-01-01 00:00 yes
102+```
103+
104+---
105+
106+## gordon auth token revoke
107+
108+Revoke a token by its ID.
109+
110+```bash
111+gordon auth token revoke <token-id> [options]
112+gordon auth token revoke --all # Revoke all tokens
113+```
114+
115+### Example
116+
117+```bash
118+gordon auth token revoke a1b2c3d4-e5f6-7890-abcd-ef1234567890
119+```
120+
121+### Output
122+
123+```
124+Token a1b2c3d4-e5f6-7890-abcd-ef1234567890 has been revoked.
125+```
126+
127+---
128+
129+## gordon auth password hash
130+
131+Generate a bcrypt hash for password authentication.
132+
133+```bash
134+gordon auth password hash
135+```
136+
137+Interactively prompts for a password and outputs the bcrypt hash.
138+
139+### Output
140+
141+```
142+Enter password: ********
143+
144+Bcrypt hash (store in your secrets backend):
145+$2a$10$N9qo8uLOickgx2ZMRZoMye...
146+
147+Then reference the path in your config:
148+ [auth]
149+ type = "password"
150+ password_hash = "gordon/auth/password_hash"
151+```
152+
153+---
154+
155+## gordon auth internal
156+
157+Display the auto-generated internal registry credentials.
158+
159+```bash
160+gordon auth internal
161+```
162+
163+Gordon generates temporary credentials for internal communication with its local registry. These credentials are useful for manual recovery when debugging deployment issues.
164+
165+### Output
166+
167+```
168+Internal Registry Credentials
169+==============================
170+Username: gordon-internal
171+Password: 8f3a2b1c4d5e6f7a8b9c0d1e2f3a4b5c...
172+
173+Usage:
174+ docker login localhost:5000 -u gordon-internal -p 8f3a2b1c4d5e6f7a8b9c0d1e2f3a4b5c...
175+```
176+
177+### Notes
178+
179+- Credentials are regenerated each time Gordon starts
180+- Only available while Gordon is running
181+- Used for manual `docker pull` from the local registry during debugging
182+
183+### Use Cases
184+
185+- Debugging image pull failures
186+- Manual recovery after deployment issues
187+- Inspecting images in the local registry
188+
189+---
190+
191+## Token Scopes
192+
193+| Scope | Permission |
194+|-------|------------|
195+| `push` | Push images to registry |
196+| `pull` | Pull images from registry |
197+| `push,pull` | Both push and pull (default) |
198+
199+## Token Expiry Formats
200+
201+| Format | Duration |
202+|--------|----------|
203+| `0` | Never expires |
204+| `24h` | 24 hours |
205+| `720h` | 30 days |
206+| `8760h` | 1 year |
207+
208+---
209+
210+## Workflow Examples
211+
212+### CI/CD Setup
213+
214+```bash
215+# Generate token for CI
216+gordon auth token generate --subject ci-bot --scopes push,pull --expiry 0
217+
218+# Save token as CI secret
219+# GitHub: Settings → Secrets → New repository secret
220+# GitLab: Settings → CI/CD → Variables
221+```
222+
223+### Docker Login
224+
225+```bash
226+# Using generated token
227+docker login -u ci-bot -p <token> registry.mydomain.com
228+
229+# In CI/CD
230+echo "$GORDON_TOKEN" | docker login -u ci-bot --password-stdin registry.mydomain.com
231+```
232+
233+### Token Rotation
234+
235+```bash
236+# Generate new token
237+gordon auth token generate --subject ci-bot --expiry 0
238+
239+# Update CI secrets with new token
240+
241+# Revoke old token
242+gordon auth token revoke <old-token-id>
243+```
244+
245+### Password Setup
246+
247+```bash
248+# Generate hash
249+gordon auth password hash
250+# Enter: mypassword
251+
252+# Store in pass
253+pass insert gordon/auth/password_hash
254+# Paste: $2a$10$...
255+
256+# Configure gordon.toml
257+[auth]
258+enabled = true
259+type = "password"
260+secrets_backend = "pass"
261+username = "deploy"
262+password_hash = "gordon/auth/password_hash"
263+```
264+
265+## Related
266+
267+- [CLI Overview](./index.md)
268+- [Authentication Configuration](../config/auth.md)
D docs/cli/commands.md +0 -743
@@ -1,743 +0,0 @@
1-# CLI Commands
2-
3-Gordon provides a command-line interface for server management, deployment, and authentication.
4-
5-## Commands Overview
6-
7-| Command | Description |
8-|---------|-------------|
9-| `gordon serve` | Start the Gordon server |
10-| `gordon reload` | Reload configuration and sync containers |
11-| `gordon deploy` | Manually deploy or redeploy a specific route |
12-| `gordon logs` | Display Gordon process or container logs |
13-| `gordon version` | Print version information |
14-| `gordon auth` | Manage registry authentication |
15-| `gordon routes` | Manage routes (local or remote) |
16-| `gordon secrets` | Manage secrets (local or remote) |
17-| `gordon remotes` | Manage saved remote Gordon instances |
18-
19-## Quick Reference
20-
21-```bash
22-# Start Gordon
23-gordon serve
24-gordon serve --config /path/to/config.toml
25-
26-# Reload configuration
27-gordon reload
28-
29-# Deploy a specific route
30-gordon deploy myapp.example.com
31-
32-# View logs
33-gordon logs # Gordon process logs
34-gordon logs -f # Follow process logs
35-gordon logs -n 100 # Last 100 lines
36-gordon logs myapp.local # Container logs for myapp.local
37-gordon logs myapp.local -f # Follow container logs
38-
39-# Check version
40-gordon version
41-
42-# Authentication
43-gordon auth token generate --subject ci-bot --expiry 0
44-gordon auth token list
45-gordon auth token revoke <token-id>
46-gordon auth password hash
47-gordon auth internal
48-```
49-
50-## Global Options
51-
52-| Option | Description |
53-|--------|-------------|
54-| `-c, --config` | Path to configuration file |
55-| `--remote` | Remote Gordon URL (e.g., `https://gordon.mydomain.com`) |
56-| `--token` | Authentication token for remote |
57-
58-### Remote Targeting
59-
60-The CLI can target remote Gordon instances using the `--remote` flag or `GORDON_REMOTE` environment variable.
61-
62-**Important:** The remote URL must be the `gordon_domain` configured on the remote Gordon instance. This is the domain that serves both the container registry and the Admin API.
63-
64-```bash
65-# Using flags (use the gordon_domain from remote Gordon config)
66-gordon routes list --remote https://gordon.mydomain.com --token $TOKEN
67-
68-# Using environment variables
69-export GORDON_REMOTE=https://gordon.mydomain.com
70-export GORDON_TOKEN=$TOKEN
71-gordon routes list
72-```
73-
74----
75-
76-## gordon serve
77-
78-Start the Gordon server with registry and proxy components.
79-
80-### Synopsis
81-
82-```bash
83-gordon serve [options]
84-```
85-
86-### Options
87-
88-| Option | Short | Default | Description |
89-|--------|-------|---------|-------------|
90-| `--config` | `-c` | Auto-detected | Path to configuration file |
91-
92-### Description
93-
94-Starts the Gordon server, which includes:
95-
96-- **Container Registry** - Receives image pushes on the registry port
97-- **HTTP Proxy** - Routes traffic to containers on the proxy port
98-- **Event Bus** - Coordinates deployments and updates
99-- **Config Watcher** - Monitors configuration file for changes
100-
101-### Configuration File Detection
102-
103-Gordon looks for configuration in this order:
104-
105-1. Path specified with `--config`
106-2. `/etc/gordon/gordon.toml`
107-3. `~/.config/gordon/gordon.toml`
108-4. `./gordon.toml`
109-
110-### First Run
111-
112-On first run without a config file, Gordon creates a default configuration at `~/.config/gordon/gordon.toml`.
113-
114-```bash
115-# First run - creates default config
116-gordon serve
117-# Edit the config, then restart
118-```
119-
120-### Examples
121-
122-```bash
123-# Basic start
124-gordon serve
125-
126-# With custom config
127-gordon serve --config /path/to/gordon.toml
128-gordon serve -c ./my-config.toml
129-
130-# With environment override
131-GORDON_SERVER_PORT=8080 gordon serve
132-GORDON_LOGGING_LEVEL=debug gordon serve
133-```
134-
135-### Signals
136-
137-Gordon responds to these signals:
138-
139-| Signal | Action |
140-|--------|--------|
141-| `SIGTERM` | Graceful shutdown |
142-| `SIGINT` | Graceful shutdown (Ctrl+C) |
143-| `SIGUSR1` | Reload configuration |
144-| `SIGUSR2` | Manual deploy (used by `gordon deploy`) |
145-
146-### Running with systemd
147-
148-```bash
149-# Create user service
150-mkdir -p ~/.config/systemd/user
151-cat > ~/.config/systemd/user/gordon.service <<EOF
152-[Unit]
153-Description=Gordon Container Platform
154-
155-[Service]
156-Type=simple
157-Restart=always
158-ExecStart=/usr/local/bin/gordon serve
159-
160-[Install]
161-WantedBy=default.target
162-EOF
163-
164-# Enable and start
165-systemctl --user daemon-reload
166-systemctl --user enable --now gordon
167-sudo loginctl enable-linger $USER
168-```
169-
170-### Startup Sequence
171-
172-1. Load configuration
173-2. Initialize logger
174-3. Create PID file
175-4. Connect to Docker/Podman runtime
176-5. Create storage directories
177-6. Initialize services (registry, proxy, auth)
178-7. Register event handlers
179-8. Start config file watcher
180-9. Sync existing containers
181-10. Start HTTP servers
182-
183-### Shutdown Sequence
184-
185-1. Receive shutdown signal
186-2. Stop accepting new requests
187-3. Complete in-flight requests
188-4. Stop managed containers (if configured)
189-5. Remove PID file
190-6. Exit
191-
192----
193-
194-## gordon reload
195-
196-Reload configuration and sync containers to match.
197-
198-### Synopsis
199-
200-```bash
201-gordon reload
202-```
203-
204-### Description
205-
206-Sends `SIGUSR1` to the running Gordon process, triggering:
207-
208-- Configuration file reload
209-- Route synchronization
210-- Deployment of containers for routes missing containers
211-- Attachment deployment
212-
213-### Example
214-
215-```bash
216-# After editing gordon.toml, apply changes without restart
217-vim ~/.config/gordon/gordon.toml
218-gordon reload
219-```
220-
221----
222-
223-## gordon deploy
224-
225-Manually deploy or redeploy a specific route.
226-
227-### Synopsis
228-
229-```bash
230-gordon deploy <domain> [options]
231-```
232-
233-### Arguments
234-
235-| Argument | Description |
236-|----------|-------------|
237-| `<domain>` | The domain name of the route to deploy (required) |
238-
239-### Options
240-
241-| Option | Description |
242-|--------|-------------|
243-| `--remote` | Remote Gordon URL |
244-| `--token` | Authentication token for remote |
245-
246-### Description
247-
248-**Local mode:** Sends `SIGUSR2` to the Gordon process with the specified domain.
249-
250-**Remote mode:** Calls the remote Gordon Admin API to trigger deployment.
251-
252-Both modes trigger:
253-
254-- Fresh image pull (always pulls latest, ignoring cache)
255-- Container redeployment for the specified route
256-
257-### Examples
258-
259-```bash
260-# Local deployment
261-gordon deploy myapp.example.com
262-gordon deploy api.example.com
263-
264-# Remote deployment
265-gordon deploy myapp.example.com --remote https://gordon.mydomain.com --token $TOKEN
266-```
267-
268-### Use Cases
269-
270-- Recover from a failed deployment
271-- Force redeploy without pushing a new image
272-- Manual deployment when automatic deploy didn't trigger
273-- Trigger deployments on remote Gordon instances from CI/CD
274-
275----
276-
277-## gordon logs
278-
279-Display Gordon process logs or container logs.
280-
281-### Synopsis
282-
283-```bash
284-gordon logs [domain] [options]
285-```
286-
287-### Arguments
288-
289-| Argument | Description |
290-|----------|-------------|
291-| `[domain]` | Optional. Container domain to view logs for. Without this, shows Gordon process logs. |
292-
293-### Options
294-
295-| Option | Short | Default | Description |
296-|--------|-------|---------|-------------|
297-| `--config` | `-c` | Auto | Path to config file |
298-| `--follow` | `-f` | false | Follow log output (like `tail -f`) |
299-| `--lines` | `-n` | 50 | Number of lines to show |
300-| `--remote` | | | Remote Gordon URL |
301-| `--token` | | | Authentication token for remote |
302-
303-### Examples
304-
305-```bash
306-# Gordon process logs
307-gordon logs # Last 50 lines
308-gordon logs -f # Follow logs
309-gordon logs -n 100 # Last 100 lines
310-gordon logs -f -n 200 # Follow, starting from last 200 lines
311-
312-# Container logs
313-gordon logs myapp.local # Last 50 lines from container
314-gordon logs myapp.local -f # Follow container logs
315-gordon logs myapp.local -n 100 # Last 100 lines from container
316-
317-# Remote mode
318-gordon logs --remote https://gordon.mydomain.com --token $TOKEN
319-gordon logs myapp.local --remote https://gordon.mydomain.com --token $TOKEN
320-```
321-
322-### Log Locations
323-
324-```bash
325-# Using gordon logs
326-gordon logs -f
327-
328-# Direct file access
329-tail -f ~/.gordon/logs/gordon.log
330-
331-# With systemd
332-journalctl --user -u gordon -f
333-
334-# Container logs via docker (local alternative)
335-docker logs --tail 50 myapp.local
336-docker logs -f myapp.local
337-```
338-
339----
340-
341-## gordon version
342-
343-Print version information.
344-
345-### Synopsis
346-
347-```bash
348-gordon version
349-```
350-
351-### Output
352-
353-```
354-Gordon v2.0.0
355-Commit: abc1234
356-Build Date: 2024-01-15
357-```
358-
359----
360-
361-## gordon auth
362-
363-Manage registry authentication tokens and passwords.
364-
365-### Subcommands
366-
367-| Subcommand | Description |
368-|------------|-------------|
369-| `token generate` | Generate a new JWT token |
370-| `token list` | List all stored tokens |
371-| `token revoke` | Revoke a token by ID |
372-| `password hash` | Generate bcrypt password hash |
373-| `internal` | Show internal registry credentials |
374-
375-### gordon auth token generate
376-
377-Generate a new JWT authentication token for registry access.
378-
379-```bash
380-gordon auth token generate --subject <name> [options]
381-```
382-
383-**Options:**
384-
385-| Option | Default | Description |
386-|--------|---------|-------------|
387-| `--subject` | (required) | Username/subject for the token |
388-| `--scopes` | `push,pull` | Comma-separated scopes |
389-| `--expiry` | `30d` | Duration until expiry (0 = never) |
390-| `-c, --config` | Auto | Path to config file |
391-
392-**Duration format:**
393-
394-Supports human-friendly duration units:
395-
396-| Unit | Description | Example |
397-|------|-------------|---------|
398-| `d` | Days (24 hours) | `30d` |
399-| `w` | Weeks (7 days) | `2w` |
400-| `M` | Months (30 days) | `6M` |
401-| `y` | Years (365 days) | `1y` |
402-
403-Compound durations are also supported: `1y6M`, `2w3d`, `1d12h`
404-
405-Standard Go durations also work: `24h`, `30m`, `1h30m`
406-
407-**Examples:**
408-
409-```bash
410-# CI token that never expires
411-gordon auth token generate --subject github-actions --expiry 0
412-
413-# Read-only token for 30 days
414-gordon auth token generate --subject reader --scopes pull --expiry 30d
415-
416-# Push-only token for 1 year
417-gordon auth token generate --subject builder --scopes push --expiry 1y
418-
419-# Temporary token (24 hours)
420-gordon auth token generate --subject temp --expiry 24h
421-
422-# Token for 6 months
423-gordon auth token generate --subject deploy --expiry 6M
424-```
425-
426-**Output:**
427-
428-```
429-Token generated successfully!
430-Subject: github-actions
431-Scopes: push, pull
432-Expiry: never
433-
434-Token (use as password with docker login):
435-eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
436-
437-Usage: docker login -u github-actions -p <token> <registry>
438-```
439-
440-### gordon auth token list
441-
442-List all stored authentication tokens.
443-
444-```bash
445-gordon auth token list [options]
446-```
447-
448-**Output:**
449-
450-```
451-ID Subject Expires Revoked
452-------------------------------------------------------------------------------------------
453-a1b2c3d4-e5f6-7890-abcd-ef1234567890 github-actions never no
454-b2c3d4e5-f6a7-8901-bcde-f12345678901 deploy-bot 2024-02-15 10:30 no
455-c3d4e5f6-a7b8-9012-cdef-123456789012 old-token 2024-01-01 00:00 yes
456-```
457-
458-### gordon auth token revoke
459-
460-Revoke a token by its ID.
461-
462-```bash
463-gordon auth token revoke <token-id> [options]
464-```
465-
466-**Example:**
467-
468-```bash
469-gordon auth token revoke a1b2c3d4-e5f6-7890-abcd-ef1234567890
470-```
471-
472-**Output:**
473-
474-```
475-Token a1b2c3d4-e5f6-7890-abcd-ef1234567890 has been revoked.
476-```
477-
478-### gordon auth password hash
479-
480-Generate a bcrypt hash for password authentication.
481-
482-```bash
483-gordon auth password hash
484-```
485-
486-Interactively prompts for a password and outputs the bcrypt hash.
487-
488-**Output:**
489-
490-```
491-Enter password: ********
492-
493-Bcrypt hash (store in your secrets backend):
494-$2a$10$N9qo8uLOickgx2ZMRZoMye...
495-
496-Then reference the path in your config:
497- [registry_auth]
498- type = "password"
499- password_hash = "gordon/registry/password_hash"
500-```
501-
502-### gordon auth internal
503-
504-Display the auto-generated internal registry credentials.
505-
506-```bash
507-gordon auth internal
508-```
509-
510-Gordon generates temporary credentials for internal communication with its local registry. These credentials are useful for manual recovery when debugging deployment issues.
511-
512-**Output:**
513-
514-```
515-Internal Registry Credentials
516-==============================
517-Username: gordon-internal
518-Password: 8f3a2b1c4d5e6f7a8b9c0d1e2f3a4b5c...
519-
520-Usage:
521- docker login localhost:5000 -u gordon-internal -p 8f3a2b1c4d5e6f7a8b9c0d1e2f3a4b5c...
522-```
523-
524-**Notes:**
525-- Credentials are regenerated each time Gordon starts
526-- Only available while Gordon is running
527-- Used for manual `docker pull` from the local registry during debugging
528-
529-**Use cases:**
530-- Debugging image pull failures
531-- Manual recovery after deployment issues
532-- Inspecting images in the local registry
533-
534----
535-
536-## Token Scopes
537-
538-| Scope | Permission |
539-|-------|------------|
540-| `push` | Push images to registry |
541-| `pull` | Pull images from registry |
542-| `push,pull` | Both push and pull (default) |
543-
544-## Token Expiry Formats
545-
546-| Format | Duration |
547-|--------|----------|
548-| `0` | Never expires |
549-| `24h` | 24 hours |
550-| `720h` | 30 days |
551-| `8760h` | 1 year |
552-
553----
554-
555-## Workflow Examples
556-
557-### CI/CD Setup
558-
559-```bash
560-# Generate token for CI
561-gordon auth token generate --subject ci-bot --scopes push,pull --expiry 0
562-
563-# Save token as CI secret
564-# GitHub: Settings → Secrets → New repository secret
565-# GitLab: Settings → CI/CD → Variables
566-```
567-
568-### Docker Login
569-
570-```bash
571-# Using generated token
572-docker login -u ci-bot -p <token> registry.mydomain.com
573-
574-# In CI/CD
575-echo "$GORDON_TOKEN" | docker login -u ci-bot --password-stdin registry.mydomain.com
576-```
577-
578-### Token Rotation
579-
580-```bash
581-# Generate new token
582-gordon auth token generate --subject ci-bot --expiry 0
583-
584-# Update CI secrets with new token
585-
586-# Revoke old token
587-gordon auth token revoke <old-token-id>
588-```
589-
590-### Password Setup
591-
592-```bash
593-# Generate hash
594-gordon auth password hash
595-# Enter: mypassword
596-
597-# Store in pass
598-pass insert gordon/registry/password_hash
599-# Paste: $2a$10$...
600-
601-# Configure gordon.toml
602-[registry_auth]
603-enabled = true
604-type = "password"
605-username = "deploy"
606-password_hash = "gordon/registry/password_hash"
607-```
608-
609----
610-
611-## gordon routes
612-
613-Manage routes on local or remote Gordon instances.
614-
615-### Subcommands
616-
617-| Subcommand | Description |
618-|------------|-------------|
619-| `list` | List all routes |
620-| `add` | Add a new route |
621-| `remove` | Remove a route |
622-| `deploy` | Deploy a specific route |
623-
624-### gordon routes list
625-
626-List all configured routes.
627-
628-```bash
629-gordon routes list
630-gordon routes list --remote https://gordon.mydomain.com --token $TOKEN
631-```
632-
633-### gordon routes add
634-
635-Add a new route.
636-
637-```bash
638-gordon routes add <domain> <image>
639-gordon routes add myapp.example.com myapp:latest
640-```
641-
642-### gordon routes remove
643-
644-Remove a route.
645-
646-```bash
647-gordon routes remove <domain>
648-gordon routes remove myapp.example.com
649-```
650-
651-### gordon routes deploy
652-
653-Deploy or redeploy a specific route.
654-
655-```bash
656-gordon routes deploy <domain>
657-gordon routes deploy myapp.example.com
658-```
659-
660----
661-
662-## gordon secrets
663-
664-Manage secrets on local or remote Gordon instances.
665-
666-### Subcommands
667-
668-| Subcommand | Description |
669-|------------|-------------|
670-| `list` | List all secrets for a domain |
671-| `set` | Set a secret value |
672-| `remove` | Remove a secret |
673-
674-### Examples
675-
676-```bash
677-# Local mode (reads/writes secrets directly)
678-gordon secrets list myapp.local
679-gordon secrets set myapp.local DATABASE_URL "postgres://..."
680-gordon secrets remove myapp.local DATABASE_URL
681-
682-# Remote mode
683-gordon secrets list myapp.local --remote https://gordon.mydomain.com --token $TOKEN
684-gordon secrets set myapp.local DATABASE_URL "postgres://..." --remote ... --token ...
685-gordon secrets remove myapp.local DATABASE_URL --remote ... --token ...
686-```
687-
688----
689-
690-## gordon remotes
691-
692-Manage saved remote Gordon instances for easier CLI usage.
693-
694-### Subcommands
695-
696-| Subcommand | Description |
697-|------------|-------------|
698-| `list` | List saved remotes |
699-| `add` | Add a new remote |
700-| `remove` | Remove a saved remote |
701-| `use` | Set active remote |
702-
703-### Examples
704-
705-```bash
706-# Add a remote
707-gordon remotes add prod https://gordon.mydomain.com --token $TOKEN
708-
709-# List saved remotes
710-gordon remotes list
711-
712-# Set active remote
713-gordon remotes use prod
714-
715-# Now use without --remote flag
716-gordon routes list
717-```
718-
719----
720-
721-## Exit Codes
722-
723-| Code | Meaning |
724-|------|---------|
725-| 0 | Success |
726-| 1 | General error |
727-| 2 | Configuration error |
728-
729-## Environment Variables
730-
731-Gordon reads configuration from environment variables:
732-
733-```bash
734-GORDON_SERVER_PORT=8080 gordon serve
735-GORDON_LOGGING_LEVEL=debug gordon serve
736-```
737-
738-Pattern: `GORDON_SECTION_KEY` (uppercase, underscores)
739-
740-## Related
741-
742-- [Core Concepts](../concepts.md)
743-- [Configuration Reference](../config/index.md)
A docs/cli/index.md +136 -0
@@ -0,0 +1,136 @@
1+# CLI Commands
2+
3+Gordon provides a command-line interface for server management, deployment, and authentication.
4+
5+Commands are organized by where they run:
6+
7+- **Server Commands** - Must run on the machine hosting Gordon
8+- **Management Commands** - Work locally or remotely via `--remote` flag
9+- **Client Commands** - CLI utilities that don't require a running Gordon server
10+
11+## Server Commands (local only)
12+
13+| Command | Description | Documentation |
14+|---------|-------------|---------------|
15+| `gordon serve` | Start the Gordon server | [serve](./serve.md) |
16+| `gordon auth` | Manage Gordon server authentication | [auth](./auth.md) |
17+
18+## Management Commands (local or --remote)
19+
20+| Command | Description | Documentation |
21+|---------|-------------|---------------|
22+| `gordon routes` | Manage routes | [routes](./routes.md) |
23+| `gordon attachments` | Manage container attachments | [attachments](./attachments.md) |
24+| `gordon secrets` | Manage secrets | [secrets](./secrets.md) |
25+| `gordon deploy` | Manually deploy or redeploy a route | [serve](./serve.md#gordon-deploy) |
26+| `gordon reload` | Reload configuration and sync containers | [serve](./serve.md#gordon-reload) |
27+| `gordon logs` | Display Gordon process or container logs | [serve](./serve.md#gordon-logs) |
28+| `gordon status` | Show Gordon server status | - |
29+
30+## Client Commands
31+
32+| Command | Description | Documentation |
33+|---------|-------------|---------------|
34+| `gordon remotes` | Manage saved remote Gordon instances | [remotes](./remotes.md) |
35+| `gordon version` | Print version information | [serve](./serve.md#gordon-version) |
36+| `gordon completion` | Generate shell autocompletion scripts | - |
37+
38+## Quick Reference
39+
40+```bash
41+# Start Gordon
42+gordon serve
43+gordon serve --config /path/to/config.toml
44+
45+# Reload configuration
46+gordon reload
47+
48+# Deploy a specific route
49+gordon deploy myapp.example.com
50+
51+# View logs
52+gordon logs # Gordon process logs
53+gordon logs -f # Follow process logs
54+gordon logs -n 100 # Last 100 lines
55+gordon logs myapp.local # Container logs for myapp.local
56+gordon logs myapp.local -f # Follow container logs
57+
58+# Check version
59+gordon version
60+
61+# Authentication
62+gordon auth token generate --subject ci-bot --expiry 0
63+gordon auth token list
64+gordon auth token revoke <token-id>
65+gordon auth password hash
66+gordon auth internal
67+
68+# Routes
69+gordon routes list
70+gordon routes add myapp.example.com myapp:latest
71+gordon routes remove myapp.example.com
72+gordon routes deploy myapp.example.com
73+
74+# Attachments
75+gordon attachments list
76+gordon attachments add app.example.com postgres:18
77+gordon attachments remove app.example.com postgres:18
78+
79+# Secrets
80+gordon secrets list myapp.local
81+gordon secrets set myapp.local DATABASE_URL "postgres://..."
82+gordon secrets remove myapp.local DATABASE_URL
83+
84+# Remotes
85+gordon remotes add prod https://gordon.mydomain.com --token $TOKEN
86+gordon remotes list
87+gordon remotes use prod
88+```
89+
90+## Global Options
91+
92+| Option | Description |
93+|--------|-------------|
94+| `-c, --config` | Path to configuration file |
95+| `--remote` | Remote Gordon URL (e.g., `https://gordon.mydomain.com`) |
96+| `--token` | Authentication token for remote |
97+
98+### Remote Targeting
99+
100+The CLI can target remote Gordon instances using the `--remote` flag or `GORDON_REMOTE` environment variable.
101+
102+**Important:** The remote URL must be the `gordon_domain` configured on the remote Gordon instance. This is the domain that serves both the container registry and the Admin API.
103+
104+```bash
105+# Using flags (use the gordon_domain from remote Gordon config)
106+gordon routes list --remote https://gordon.mydomain.com --token $TOKEN
107+
108+# Using environment variables
109+export GORDON_REMOTE=https://gordon.mydomain.com
110+export GORDON_TOKEN=$TOKEN
111+gordon routes list
112+```
113+
114+## Exit Codes
115+
116+| Code | Meaning |
117+|------|---------|
118+| 0 | Success |
119+| 1 | General error |
120+| 2 | Configuration error |
121+
122+## Environment Variables
123+
124+Gordon reads configuration from environment variables:
125+
126+```bash
127+GORDON_SERVER_PORT=8080 gordon serve
128+GORDON_LOGGING_LEVEL=debug gordon serve
129+```
130+
131+Pattern: `GORDON_SECTION_KEY` (uppercase, underscores)
132+
133+## Related
134+
135+- [Core Concepts](../concepts.md)
136+- [Configuration Reference](../config/index.md)
A docs/cli/remotes.md +256 -0
@@ -0,0 +1,256 @@
1+# Remotes Commands
2+
3+Manage saved remote Gordon instances for easier CLI usage.
4+
5+## gordon remotes
6+
7+### Subcommands
8+
9+| Subcommand | Description |
10+|------------|-------------|
11+| `list` | List saved remotes |
12+| `add` | Add a new remote |
13+| `remove` | Remove a saved remote |
14+| `use` | Set active remote |
15+
16+---
17+
18+## gordon remotes list
19+
20+List all saved remotes.
21+
22+```bash
23+gordon remotes list
24+```
25+
26+### Output
27+
28+```
29+Saved Remotes
30+
31+Name URL Token Status
32+-------------------------------------------------------------------------------
33+prod https://gordon.mydomain.com $PROD_TOKEN active
34+staging https://staging.mydomain.com set
35+dev https://dev.mydomain.com none
36+```
37+
38+---
39+
40+## gordon remotes add
41+
42+Add a new remote.
43+
44+```bash
45+gordon remotes add <name> <url> [options]
46+```
47+
48+### Arguments
49+
50+| Argument | Description |
51+|----------|-------------|
52+| `<name>` | Name for the remote (e.g., `prod`, `staging`) |
53+| `<url>` | Gordon URL (the `gordon_domain` from remote config) |
54+
55+### Options
56+
57+| Option | Description |
58+|--------|-------------|
59+| `--token` | Store token directly in config |
60+| `--token-env` | Store environment variable name (token resolved at runtime) |
61+
62+### Examples
63+
64+```bash
65+# Basic (no token)
66+gordon remotes add prod https://gordon.mydomain.com
67+
68+# With direct token
69+gordon remotes add prod https://gordon.mydomain.com --token eyJ...
70+
71+# With environment variable reference (recommended)
72+gordon remotes add prod https://gordon.mydomain.com --token-env PROD_TOKEN
73+```
74+
75+### Token Security
76+
77+**Option 1: Environment Variable Reference (Recommended)**
78+
79+```bash
80+gordon remotes add prod https://gordon.mydomain.com --token-env PROD_TOKEN
81+```
82+
83+The `remotes.toml` stores only the variable name:
84+
85+```toml
86+[remotes.prod]
87+url = "https://gordon.mydomain.com"
88+token_env = "PROD_TOKEN"
89+```
90+
91+At runtime, Gordon reads `$PROD_TOKEN` from the environment.
92+
93+**Option 2: Direct Token Storage**
94+
95+```bash
96+gordon remotes add prod https://gordon.mydomain.com --token eyJ...
97+```
98+
99+The token is stored directly in `~/.config/gordon/remotes.toml`. Ensure restricted permissions:
100+
101+```bash
102+chmod 600 ~/.config/gordon/remotes.toml
103+```
104+
105+---
106+
107+## gordon remotes remove
108+
109+Remove a saved remote.
110+
111+```bash
112+gordon remotes remove <name>
113+gordon remotes remove <name> --force # Skip confirmation
114+```
115+
116+### Arguments
117+
118+| Argument | Description |
119+|----------|-------------|
120+| `<name>` | Name of the remote to remove |
121+
122+### Options
123+
124+| Option | Description |
125+|--------|-------------|
126+| `--force` | Skip confirmation prompt |
127+
128+### Example
129+
130+```bash
131+gordon remotes remove staging
132+```
133+
134+---
135+
136+## gordon remotes use
137+
138+Set the active remote.
139+
140+```bash
141+gordon remotes use <name>
142+```
143+
144+### Arguments
145+
146+| Argument | Description |
147+|----------|-------------|
148+| `<name>` | Name of the remote to set as active |
149+
150+### Description
151+
152+When a remote is active, it's used automatically for all remote-capable commands without needing to specify `--remote` and `--token`:
153+
154+```bash
155+gordon remotes use prod
156+gordon routes list # Uses prod remote automatically
157+gordon secrets list app.com # Uses prod remote automatically
158+```
159+
160+### Example
161+
162+```bash
163+gordon remotes use prod
164+```
165+
166+---
167+
168+## Configuration File
169+
170+Remotes are stored in `~/.config/gordon/remotes.toml`:
171+
172+```toml
173+active = "prod"
174+
175+[remotes.prod]
176+url = "https://gordon.mydomain.com"
177+token_env = "PROD_TOKEN"
178+
179+[remotes.staging]
180+url = "https://staging.mydomain.com"
181+token = "eyJ..."
182+```
183+
184+## Resolution Precedence
185+
186+When multiple sources specify remote or token, the CLI uses this priority:
187+
188+**Remote URL:**
189+1. `--remote` flag
190+2. `GORDON_REMOTE` environment variable
191+3. Active remote from `remotes.toml`
192+
193+**Token:**
194+1. `--token` flag
195+2. `GORDON_TOKEN` environment variable
196+3. Token from active remote in `remotes.toml`
197+
198+This allows overriding specific values while keeping defaults:
199+
200+```bash
201+# Active remote is prod, but use different token
202+gordon routes list --token $TEMPORARY_TOKEN
203+```
204+
205+---
206+
207+## Workflow Examples
208+
209+### Multi-Environment Setup
210+
211+```bash
212+# Add all environments
213+gordon remotes add prod https://gordon.example.com --token-env PROD_TOKEN
214+gordon remotes add staging https://gordon.staging.example.com --token-env STAGING_TOKEN
215+gordon remotes add dev https://gordon.dev.example.com --token-env DEV_TOKEN
216+
217+# Work with prod
218+gordon remotes use prod
219+gordon routes list
220+gordon secrets list myapp.example.com
221+
222+# Switch to staging
223+gordon remotes use staging
224+gordon routes list
225+```
226+
227+### CI/CD Pipeline
228+
229+```yaml
230+# GitHub Actions example
231+env:
232+ GORDON_REMOTE: ${{ secrets.GORDON_URL }}
233+ GORDON_TOKEN: ${{ secrets.GORDON_TOKEN }}
234+
235+steps:
236+ - name: Deploy to Gordon
237+ run: |
238+ gordon routes deploy myapp.example.com
239+```
240+
241+### Compare Environments
242+
243+```bash
244+# Compare routes across environments
245+gordon routes list --remote https://gordon.example.com --token $PROD_TOKEN
246+gordon routes list --remote https://gordon.staging.example.com --token $STAGING_TOKEN
247+
248+# Or switch between active remotes
249+gordon remotes use prod && gordon routes list
250+gordon remotes use staging && gordon routes list
251+```
252+
253+## Related
254+
255+- [CLI Overview](./index.md)
256+- [Remote CLI Guide](/wiki/guides/remote-cli.md)
A docs/cli/routes.md +148 -0
@@ -0,0 +1,148 @@
1+# Routes Commands
2+
3+Manage routes on local or remote Gordon instances.
4+
5+## gordon routes
6+
7+### Subcommands
8+
9+| Subcommand | Description |
10+|------------|-------------|
11+| `list` | List all routes |
12+| `add` | Add a new route |
13+| `remove` | Remove a route |
14+| `deploy` | Deploy a specific route |
15+
16+---
17+
18+## gordon routes list
19+
20+List all configured routes.
21+
22+```bash
23+gordon routes list
24+gordon routes list --remote https://gordon.mydomain.com --token $TOKEN
25+```
26+
27+### Output
28+
29+```
30+Domain Image Status
31+--------------------------------------------------------------------------------
32+app.example.com myapp:latest running
33+api.example.com myapi:v2.1.0 running
34+admin.example.com admin-panel:latest stopped
35+```
36+
37+---
38+
39+## gordon routes add
40+
41+Add a new route.
42+
43+```bash
44+gordon routes add <domain> <image>
45+gordon routes add myapp.example.com myapp:latest
46+```
47+
48+### Arguments
49+
50+| Argument | Description |
51+|----------|-------------|
52+| `<domain>` | The domain name for the route |
53+| `<image>` | The container image to deploy |
54+
55+### Options
56+
57+| Option | Description |
58+|--------|-------------|
59+| `--remote` | Remote Gordon URL |
60+| `--token` | Authentication token for remote |
61+
62+### Examples
63+
64+```bash
65+# Local
66+gordon routes add myapp.example.com myapp:latest
67+gordon routes add api.example.com myapi:v2.1.0
68+
69+# Remote
70+gordon routes add myapp.example.com myapp:latest --remote https://gordon.mydomain.com --token $TOKEN
71+```
72+
73+---
74+
75+## gordon routes remove
76+
77+Remove a route.
78+
79+```bash
80+gordon routes remove <domain>
81+gordon routes remove myapp.example.com
82+```
83+
84+### Arguments
85+
86+| Argument | Description |
87+|----------|-------------|
88+| `<domain>` | The domain name of the route to remove |
89+
90+### Options
91+
92+| Option | Description |
93+|--------|-------------|
94+| `--remote` | Remote Gordon URL |
95+| `--token` | Authentication token for remote |
96+
97+### Examples
98+
99+```bash
100+# Local
101+gordon routes remove myapp.example.com
102+
103+# Remote
104+gordon routes remove myapp.example.com --remote https://gordon.mydomain.com --token $TOKEN
105+```
106+
107+---
108+
109+## gordon routes deploy
110+
111+Deploy or redeploy a specific route.
112+
113+```bash
114+gordon routes deploy <domain>
115+gordon routes deploy myapp.example.com
116+```
117+
118+### Arguments
119+
120+| Argument | Description |
121+|----------|-------------|
122+| `<domain>` | The domain name of the route to deploy |
123+
124+### Options
125+
126+| Option | Description |
127+|--------|-------------|
128+| `--remote` | Remote Gordon URL |
129+| `--token` | Authentication token for remote |
130+
131+### Description
132+
133+Triggers a fresh image pull and container redeployment for the specified route.
134+
135+### Examples
136+
137+```bash
138+# Local
139+gordon routes deploy myapp.example.com
140+
141+# Remote
142+gordon routes deploy myapp.example.com --remote https://gordon.mydomain.com --token $TOKEN
143+```
144+
145+## Related
146+
147+- [CLI Overview](./index.md)
148+- [Routes Configuration](../config/routes.md)
A docs/cli/secrets.md +182 -0
@@ -0,0 +1,182 @@
1+# Secrets Commands
2+
3+Manage secrets on local or remote Gordon instances.
4+
5+## gordon secrets
6+
7+### Subcommands
8+
9+| Subcommand | Description |
10+|------------|-------------|
11+| `list` | List all secrets for a domain |
12+| `set` | Set a secret value |
13+| `remove` | Remove a secret |
14+
15+---
16+
17+## gordon secrets list
18+
19+List all secrets for a specific domain.
20+
21+```bash
22+gordon secrets list <domain>
23+```
24+
25+### Arguments
26+
27+| Argument | Description |
28+|----------|-------------|
29+| `<domain>` | The domain name to list secrets for |
30+
31+### Options
32+
33+| Option | Description |
34+|--------|-------------|
35+| `--remote` | Remote Gordon URL |
36+| `--token` | Authentication token for remote |
37+
38+### Examples
39+
40+```bash
41+# Local
42+gordon secrets list myapp.local
43+
44+# Remote
45+gordon secrets list myapp.local --remote https://gordon.mydomain.com --token $TOKEN
46+```
47+
48+### Output
49+
50+```
51+Secret Value (masked)
52+--------------------------------------------------------------------------------
53+DATABASE_URL postgres://...
54+API_KEY ****
55+JWT_SECRET ****
56+```
57+
58+---
59+
60+## gordon secrets set
61+
62+Set a secret value for a domain.
63+
64+```bash
65+gordon secrets set <domain> <key> <value>
66+gordon secrets set myapp.local DATABASE_URL "postgres://..."
67+```
68+
69+### Arguments
70+
71+| Argument | Description |
72+|----------|-------------|
73+| `<domain>` | The domain name to set the secret for |
74+| `<key>` | The secret key (environment variable name) |
75+| `<value>` | The secret value |
76+
77+### Options
78+
79+| Option | Description |
80+|--------|-------------|
81+| `--remote` | Remote Gordon URL |
82+| `--token` | Authentication token for remote |
83+
84+### Examples
85+
86+```bash
87+# Local
88+gordon secrets set myapp.local DATABASE_URL "postgres://user:pass@postgres:5432/db"
89+gordon secrets set myapp.local API_KEY "your-api-key"
90+
91+# Remote
92+gordon secrets set myapp.local DATABASE_URL "postgres://..." --remote https://gordon.mydomain.com --token $TOKEN
93+```
94+
95+---
96+
97+## gordon secrets remove
98+
99+Remove a secret from a domain.
100+
101+```bash
102+gordon secrets remove <domain> <key>
103+```
104+
105+### Arguments
106+
107+| Argument | Description |
108+|----------|-------------|
109+| `<domain>` | The domain name |
110+| `<key>` | The secret key to remove |
111+
112+### Options
113+
114+| Option | Description |
115+|--------|-------------|
116+| `--remote` | Remote Gordon URL |
117+| `--token` | Authentication token for remote |
118+
119+### Examples
120+
121+```bash
122+# Local
123+gordon secrets remove myapp.local DATABASE_URL
124+
125+# Remote
126+gordon secrets remove myapp.local DATABASE_URL --remote https://gordon.mydomain.com --token $TOKEN
127+```
128+
129+---
130+
131+## Workflow Examples
132+
133+### Setting Up Application Secrets
134+
135+```bash
136+# Database connection
137+gordon secrets set myapp.local DATABASE_URL "postgres://user:pass@postgres:5432/mydb"
138+
139+# API keys
140+gordon secrets set myapp.local STRIPE_KEY "sk_live_..."
141+gordon secrets set myapp.local SENDGRID_KEY "SG..."
142+
143+# JWT secret
144+gordon secrets set myapp.local JWT_SECRET "your-jwt-secret-here"
145+
146+# Verify
147+gordon secrets list myapp.local
148+```
149+
150+### CI/CD Secret Management
151+
152+```bash
153+# In your CI/CD pipeline
154+export GORDON_REMOTE=https://gordon.mydomain.com
155+export GORDON_TOKEN=$GORDON_TOKEN
156+
157+# Update secrets before deploy
158+gordon secrets set myapp.example.com DATABASE_URL "$DATABASE_URL"
159+gordon secrets set myapp.example.com API_KEY "$API_KEY"
160+
161+# Deploy
162+gordon routes deploy myapp.example.com
163+```
164+
165+### Rotating Secrets
166+
167+```bash
168+# Generate new secret
169+NEW_JWT_SECRET=$(openssl rand -base64 32)
170+
171+# Update the secret
172+gordon secrets set myapp.local JWT_SECRET "$NEW_JWT_SECRET"
173+
174+# Redeploy to pick up new secret
175+gordon routes deploy myapp.local
176+```
177+
178+## Related
179+
180+- [CLI Overview](./index.md)
181+- [Secrets Configuration](../config/secrets.md)
182+- [Environment Variables](../config/env.md)
A docs/cli/serve.md +291 -0
@@ -0,0 +1,291 @@
1+# Server Commands
2+
3+Commands for running and managing the Gordon server process.
4+
5+## gordon serve
6+
7+Start the Gordon server with registry and proxy components.
8+
9+### Synopsis
10+
11+```bash
12+gordon serve [options]
13+```
14+
15+### Options
16+
17+| Option | Short | Default | Description |
18+|--------|-------|---------|-------------|
19+| `--config` | `-c` | Auto-detected | Path to configuration file |
20+
21+### Description
22+
23+Starts the Gordon server, which includes:
24+
25+- **Container Registry** - Receives image pushes on the registry port
26+- **HTTP Proxy** - Routes traffic to containers on the proxy port
27+- **Event Bus** - Coordinates deployments and updates
28+- **Config Watcher** - Monitors configuration file for changes
29+
30+### Configuration File Detection
31+
32+Gordon looks for configuration in this order:
33+
34+1. Path specified with `--config`
35+2. `/etc/gordon/gordon.toml`
36+3. `~/.config/gordon/gordon.toml`
37+4. `./gordon.toml`
38+
39+### First Run
40+
41+On first run without a config file, Gordon creates a default configuration at `~/.config/gordon/gordon.toml`.
42+
43+```bash
44+# First run - creates default config
45+gordon serve
46+# Edit the config, then restart
47+```
48+
49+### Examples
50+
51+```bash
52+# Basic start
53+gordon serve
54+
55+# With custom config
56+gordon serve --config /path/to/gordon.toml
57+gordon serve -c ./my-config.toml
58+
59+# With environment override
60+GORDON_SERVER_PORT=8080 gordon serve
61+GORDON_LOGGING_LEVEL=debug gordon serve
62+```
63+
64+### Signals
65+
66+Gordon responds to these signals:
67+
68+| Signal | Action |
69+|--------|--------|
70+| `SIGTERM` | Graceful shutdown |
71+| `SIGINT` | Graceful shutdown (Ctrl+C) |
72+| `SIGUSR1` | Reload configuration |
73+| `SIGUSR2` | Manual deploy (used by `gordon deploy`) |
74+
75+### Running with systemd
76+
77+```bash
78+# Create user service
79+mkdir -p ~/.config/systemd/user
80+cat > ~/.config/systemd/user/gordon.service <<EOF
81+[Unit]
82+Description=Gordon Container Platform
83+
84+[Service]
85+Type=simple
86+Restart=always
87+ExecStart=/usr/local/bin/gordon serve
88+
89+[Install]
90+WantedBy=default.target
91+EOF
92+
93+# Enable and start
94+systemctl --user daemon-reload
95+systemctl --user enable --now gordon
96+sudo loginctl enable-linger $USER
97+```
98+
99+### Startup Sequence
100+
101+1. Load configuration
102+2. Initialize logger
103+3. Create PID file
104+4. Connect to Docker/Podman runtime
105+5. Create storage directories
106+6. Initialize services (registry, proxy, auth)
107+7. Register event handlers
108+8. Start config file watcher
109+9. Sync existing containers
110+10. Start HTTP servers
111+
112+### Shutdown Sequence
113+
114+1. Receive shutdown signal
115+2. Stop accepting new requests
116+3. Complete in-flight requests
117+4. Stop managed containers (if configured)
118+5. Remove PID file
119+6. Exit
120+
121+---
122+
123+## gordon reload
124+
125+Reload configuration and sync containers to match.
126+
127+### Synopsis
128+
129+```bash
130+gordon reload
131+```
132+
133+### Description
134+
135+Sends `SIGUSR1` to the running Gordon process, triggering:
136+
137+- Configuration file reload
138+- Route synchronization
139+- Deployment of containers for routes missing containers
140+- Attachment deployment
141+
142+### Example
143+
144+```bash
145+# After editing gordon.toml, apply changes without restart
146+vim ~/.config/gordon/gordon.toml
147+gordon reload
148+```
149+
150+---
151+
152+## gordon deploy
153+
154+Manually deploy or redeploy a specific route.
155+
156+### Synopsis
157+
158+```bash
159+gordon deploy <domain> [options]
160+```
161+
162+### Arguments
163+
164+| Argument | Description |
165+|----------|-------------|
166+| `<domain>` | The domain name of the route to deploy (required) |
167+
168+### Options
169+
170+| Option | Description |
171+|--------|-------------|
172+| `--remote` | Remote Gordon URL |
173+| `--token` | Authentication token for remote |
174+
175+### Description
176+
177+**Local mode:** Sends `SIGUSR2` to the Gordon process with the specified domain.
178+
179+**Remote mode:** Calls the remote Gordon Admin API to trigger deployment.
180+
181+Both modes trigger:
182+
183+- Fresh image pull (always pulls latest, ignoring cache)
184+- Container redeployment for the specified route
185+
186+### Examples
187+
188+```bash
189+# Local deployment
190+gordon deploy myapp.example.com
191+gordon deploy api.example.com
192+
193+# Remote deployment
194+gordon deploy myapp.example.com --remote https://gordon.mydomain.com --token $TOKEN
195+```
196+
197+### Use Cases
198+
199+- Recover from a failed deployment
200+- Force redeploy without pushing a new image
201+- Manual deployment when automatic deploy didn't trigger
202+- Trigger deployments on remote Gordon instances from CI/CD
203+
204+---
205+
206+## gordon logs
207+
208+Display Gordon process logs or container logs.
209+
210+### Synopsis
211+
212+```bash
213+gordon logs [domain] [options]
214+```
215+
216+### Arguments
217+
218+| Argument | Description |
219+|----------|-------------|
220+| `[domain]` | Optional. Container domain to view logs for. Without this, shows Gordon process logs. |
221+
222+### Options
223+
224+| Option | Short | Default | Description |
225+|--------|-------|---------|-------------|
226+| `--config` | `-c` | Auto | Path to config file |
227+| `--follow` | `-f` | false | Follow log output (like `tail -f`) |
228+| `--lines` | `-n` | 50 | Number of lines to show |
229+| `--remote` | | | Remote Gordon URL |
230+| `--token` | | | Authentication token for remote |
231+
232+### Examples
233+
234+```bash
235+# Gordon process logs
236+gordon logs # Last 50 lines
237+gordon logs -f # Follow logs
238+gordon logs -n 100 # Last 100 lines
239+gordon logs -f -n 200 # Follow, starting from last 200 lines
240+
241+# Container logs
242+gordon logs myapp.local # Last 50 lines from container
243+gordon logs myapp.local -f # Follow container logs
244+gordon logs myapp.local -n 100 # Last 100 lines from container
245+
246+# Remote mode
247+gordon logs --remote https://gordon.mydomain.com --token $TOKEN
248+gordon logs myapp.local --remote https://gordon.mydomain.com --token $TOKEN
249+```
250+
251+### Log Locations
252+
253+```bash
254+# Using gordon logs
255+gordon logs -f
256+
257+# Direct file access
258+tail -f ~/.gordon/logs/gordon.log
259+
260+# With systemd
261+journalctl --user -u gordon -f
262+
263+# Container logs via docker (local alternative)
264+docker logs --tail 50 myapp.local
265+docker logs -f myapp.local
266+```
267+
268+---
269+
270+## gordon version
271+
272+Print version information.
273+
274+### Synopsis
275+
276+```bash
277+gordon version
278+```
279+
280+### Output
281+
282+```
283+Gordon v2.0.0
284+Commit: abc1234
285+Build Date: 2024-01-15
286+```
287+
288+## Related
289+
290+- [CLI Overview](./index.md)
291+- [Configuration Reference](../config/index.md)
M docs/concepts.md +1 -1
@@ -142,7 +142,7 @@ Both `app.mydomain.com` and `api.mydomain.com` can access the shared services.
142142 Gordon automatically creates persistent storage from Dockerfile `VOLUME` directives:
143143
144144 ```dockerfile
145-FROM postgres:15
145+FROM postgres:18
146146 VOLUME ["/var/lib/postgresql/data"]
147147 ```
148148
M docs/config/attachments.md +69 -3
@@ -2,6 +2,17 @@
22
33 Attach service dependencies (databases, caches, queues) to your applications.
44
5+## Requirements
6+
7+**Network isolation must be enabled** for attachments to work properly:
8+
9+```toml
10+[network_isolation]
11+enabled = true
12+```
13+
14+Without network isolation, containers run on Docker's default bridge network which **does not provide DNS resolution**. Your application won't be able to reach attachments by hostname (e.g., `postgres:5432`).
15+
516 ## Configuration
617
718 ```toml
@@ -65,7 +76,7 @@ Use Dockerfile VOLUME directives for persistent data:
6576
6677 ```dockerfile
6778 # postgres.Dockerfile
68-FROM postgres:15
79+FROM postgres:18
6980 VOLUME ["/var/lib/postgresql/data"]
7081 ENV POSTGRES_DB=myapp
7182 ENV POSTGRES_USER=app
@@ -143,7 +154,7 @@ Multiple apps share the same service instances:
143154 "app.mydomain.com" = "myapp:latest"
144155
145156 [attachments]
146-"app.mydomain.com" = ["postgres:15", "redis:7-alpine"]
157+"app.mydomain.com" = ["postgres:18", "redis:7-alpine"]
147158 ```
148159
149160 ### Microservices with Shared Queue
@@ -169,7 +180,7 @@ Build custom service images with your configuration:
169180
170181 ```dockerfile
171182 # my-postgres.Dockerfile
172-FROM postgres:15
183+FROM postgres:18
173184 VOLUME ["/var/lib/postgresql/data"]
174185 ENV POSTGRES_DB=production
175186 ENV POSTGRES_USER=appuser
@@ -204,8 +215,63 @@ Gordon adds labels to attachment containers:
204215 | `gordon.attachment` | `true` |
205216 | `gordon.attached-to` | Domain or group name |
206217
218+## CLI Management
219+
220+Manage attachments via the CLI without editing configuration files.
221+
222+### List Attachments
223+
224+```bash
225+# List all attachments
226+gordon attachments list
227+
228+# List attachments for a specific domain or network group
229+gordon attachments list app.mydomain.com
230+gordon attachments list backend
231+
232+# Remote mode
233+gordon attachments list --remote https://gordon.mydomain.com --token $TOKEN
234+```
235+
236+### Add Attachments
237+
238+```bash
239+# Add attachment to a domain
240+gordon attachments add app.mydomain.com postgres:18
241+
242+# Add attachment to a network group
243+gordon attachments add backend redis:7-alpine
244+
245+# Remote mode
246+gordon attachments add app.mydomain.com postgres:18 --remote https://gordon.mydomain.com --token $TOKEN
247+```
248+
249+### Remove Attachments
250+
251+```bash
252+# Remove attachment from a domain
253+gordon attachments remove app.mydomain.com postgres:18
254+
255+# Remove from network group
256+gordon attachments remove backend redis:7-alpine
257+
258+# Remote mode
259+gordon attachments remove app.mydomain.com postgres:18 --remote https://gordon.mydomain.com --token $TOKEN
260+```
261+
262+### Alias
263+
264+The `gordon attach` command is an alias for `gordon attachments`:
265+
266+```bash
267+gordon attach list
268+gordon attach add app.mydomain.com postgres:18
269+gordon attach remove app.mydomain.com postgres:18
270+```
271+
207272 ## Related
208273
209274 - [Network Isolation](./network-isolation.md)
210275 - [Network Groups](./network-groups.md)
211276 - [Routes](./routes.md)
277+- [CLI Commands](/docs/cli/index.md)
R docs/config/registry-auth.md → docs/config/auth.md +121 -56 renamed, 52% similar
@@ -1,33 +1,68 @@
1-# Registry Authentication
1+# Authentication
22
3-Secure your container registry with password or token-based authentication.
3+Configure authentication for the Gordon registry and Admin API.
44
5-## Configuration
5+## Quick Start
6+
7+Authentication is **enabled by default** for security. Gordon will not start without authentication configured.
8+
9+**Option 1: Password Authentication (simplest)**
10+```bash
11+# 1. Generate password hash
12+gordon auth password hash
13+# Enter your password, copy the bcrypt hash
14+
15+# 2. Save hash to secrets file
16+mkdir -p ~/.gordon/secrets
17+echo '$2a$10$...' > ~/.gordon/secrets/password_hash
618
19+# 3. Add to config.toml
20+cat >> ~/.gordon/config.toml << 'EOF'
21+[auth]
22+password_hash = "password_hash"
23+EOF
24+```
25+
26+**Option 2: Disable Authentication (development only)**
727 ```toml
8-[registry_auth]
9-enabled = true
10-type = "token" # "password" or "token"
28+[auth]
29+enabled = false
30+```
1131
12-# For token auth:
13-token_secret = "gordon/registry/token_secret" # Path in secrets backend
14-token_expiry = "720h" # Duration or 0 for never
32+## Configuration
1533
16-# For password auth:
17-# username = "deploy"
18-# password_hash = "gordon/registry/password_hash" # Path in secrets backend
34+```toml
35+[auth]
36+enabled = true
37+type = "token"
38+secrets_backend = "pass"
39+token_secret = "gordon/auth/token_secret"
40+token_expiry = "30d"
1941 ```
2042
2143 ## Options
2244
2345 | Option | Type | Default | Description |
2446 |--------|------|---------|-------------|
25-| `enabled` | bool | `false` | Enable registry authentication |
47+| `enabled` | bool | `true` | Enable authentication |
2648 | `type` | string | `"password"` | Auth type: `"password"` or `"token"` |
49+| `secrets_backend` | string | `"unsafe"` | Secrets backend: `"pass"`, `"sops"`, or `"unsafe"` |
2750 | `username` | string | - | Username for password auth |
2851 | `password_hash` | string | - | Path to bcrypt hash in secrets backend |
2952 | `token_secret` | string | - | Path to JWT signing secret in secrets backend |
30-| `token_expiry` | string | `"30d"` | Token validity duration (0 = never expires). Supports: d (days), w (weeks), M (months), y (years) |
53+| `token_expiry` | string | `"30d"` | Token validity duration (0 = never expires) |
54+
55+## Secrets Backend
56+
57+The `secrets_backend` option determines how Gordon retrieves sensitive values like `token_secret` and `password_hash`:
58+
59+| Backend | Description |
60+|---------|-------------|
61+| `pass` | Unix password manager (GPG-encrypted) |
62+| `sops` | Mozilla SOPS encrypted files |
63+| `unsafe` | Plain text files (development only) |
64+
65+See [Secret Providers](./secrets.md) for detailed configuration of each backend.
3166
3267 ## Authentication Types
3368
@@ -36,20 +71,18 @@ token_expiry = "720h" # Duration or 0 for never
3671 JWT-based authentication, ideal for CI/CD pipelines:
3772
3873 ```toml
39-[secrets]
40-backend = "pass"
41-
42-[registry_auth]
74+[auth]
4375 enabled = true
4476 type = "token"
45-token_secret = "gordon/registry/token_secret"
46-token_expiry = "30d" # 30 days, or "0" for never. Also: 1y, 2w, 6M
77+secrets_backend = "pass"
78+token_secret = "gordon/auth/token_secret"
79+token_expiry = "30d"
4780 ```
4881
4982 **Setup:**
5083 ```bash
5184 # Create token secret (random 32+ characters)
52-pass insert gordon/registry/token_secret
85+pass insert gordon/auth/token_secret
5386
5487 # Generate a token
5588 gordon auth token generate --subject ci-bot --scopes push,pull --expiry 0
@@ -66,8 +99,11 @@ docker login -u ci-bot -p <token> registry.mydomain.com
6699 # List all tokens
67100 gordon auth token list
68101
69-# Revoke a token
102+# Revoke a specific token
70103 gordon auth token revoke <token-id>
104+
105+# Revoke all tokens
106+gordon auth token revoke --all
71107 ```
72108
73109 ### Password Authentication
@@ -75,14 +111,12 @@ gordon auth token revoke <token-id>
75111 Simple username/password authentication:
76112
77113 ```toml
78-[secrets]
79-backend = "pass"
80-
81-[registry_auth]
114+[auth]
82115 enabled = true
83116 type = "password"
117+secrets_backend = "pass"
84118 username = "deploy"
85-password_hash = "gordon/registry/password_hash"
119+password_hash = "gordon/auth/password_hash"
86120 ```
87121
88122 **Setup:**
@@ -92,7 +126,7 @@ gordon auth password hash
92126 # Enter password when prompted
93127
94128 # Store hash in secrets backend
95-pass insert gordon/registry/password_hash
129+pass insert gordon/auth/password_hash
96130 # Paste the bcrypt hash
97131 ```
98132
@@ -105,27 +139,51 @@ docker login -u deploy -p <password> registry.mydomain.com
105139
106140 Tokens can have different permission levels:
107141
142+### Registry Scopes
143+
108144 | Scope | Permission |
109145 |-------|------------|
110146 | `push` | Push images to registry |
111147 | `pull` | Pull images from registry |
112148 | `push,pull` | Both push and pull (default) |
113149
150+### Admin Scopes
151+
152+For remote CLI access via the Admin API:
153+
154+| Scope | Permission |
155+|-------|------------|
156+| `admin:*:*` | Full admin access |
157+| `admin:routes:read` | Read-only routes access |
158+| `admin:routes:write` | Routes write access |
159+| `admin:config:read` | Read-only config access |
160+| `admin:config:write` | Config write access |
161+| `admin:status:read` | Read-only status/health |
162+| `admin:secrets:read` | List secret keys |
163+| `admin:secrets:write` | Set/delete secrets |
164+
165+**Examples:**
114166 ```bash
115167 # Pull-only token (for read-only access)
116168 gordon auth token generate --subject reader --scopes pull --expiry 30d
117169
118170 # Push-only token (for CI builds)
119171 gordon auth token generate --subject builder --scopes push --expiry 0
172+
173+# Full admin access (for remote CLI)
174+gordon auth token generate --subject admin --scopes "push,pull,admin:*:*" --expiry 0
120175 ```
121176
122177 ## Token Expiry
123178
124179 Control how long tokens remain valid. Supports human-friendly units:
125-- `d` - days (24 hours)
126-- `w` - weeks (7 days)
127-- `M` - months (30 days)
128-- `y` - years (365 days)
180+
181+| Unit | Description |
182+|------|-------------|
183+| `d` | days (24 hours) |
184+| `w` | weeks (7 days) |
185+| `M` | months (30 days) |
186+| `y` | years (365 days) |
129187
130188 Compound durations work too: `1y6M`, `2w3d`
131189
@@ -138,39 +196,40 @@ gordon auth token generate --subject deploy --expiry 1y
138196
139197 # Expires in 30 days
140198 gordon auth token generate --subject temp --expiry 30d
141-
142-# Expires in 2 weeks
143-gordon auth token generate --subject short --expiry 2w
144199 ```
145200
146201 ## Instance-Specific Tokens
147202
148-Tokens are signed with the `token_secret`, which is unique to each Gordon instance. This means:
203+Tokens are signed with the `token_secret`, which is unique to each Gordon instance:
149204
150205 - Tokens from one Gordon instance won't work on another
151206 - Changing the `token_secret` invalidates all existing tokens
152207 - Each environment (dev, staging, prod) should have different secrets
153208
209+## Development Setup
210+
211+For local development without authentication:
212+
213+```toml
214+[auth]
215+enabled = false
216+```
217+
154218 ## Examples
155219
156220 ### CI/CD Setup
157221
158222 ```toml
159-[secrets]
160-backend = "pass"
161-
162-[registry_auth]
223+[auth]
163224 enabled = true
164225 type = "token"
165-token_secret = "gordon/registry/token_secret"
226+secrets_backend = "pass"
227+token_secret = "gordon/auth/token_secret"
166228 ```
167229
168230 ```bash
169231 # Generate CI token
170232 gordon auth token generate --subject github-actions --scopes push,pull --expiry 0
171-
172-# Token output:
173-# eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
174233 ```
175234
176235 In GitHub Actions:
@@ -179,32 +238,38 @@ In GitHub Actions:
179238 run: docker login -u github-actions -p ${{ secrets.GORDON_TOKEN }} ${{ secrets.GORDON_REGISTRY }}
180239 ```
181240
182-### Development Setup
241+### Production with Password
183242
184243 ```toml
185-[registry_auth]
186-enabled = false # Disable for local development
244+[auth]
245+enabled = true
246+type = "password"
247+secrets_backend = "pass"
248+username = "deploy"
249+password_hash = "gordon/auth/password_hash"
187250 ```
188251
189-### Production with Password
252+### Enterprise with SOPS
190253
191254 ```toml
192-[secrets]
193-backend = "pass"
194-
195-[registry_auth]
255+[auth]
196256 enabled = true
197-type = "password"
198-username = "deploy"
199-password_hash = "gordon/registry/password_hash"
257+type = "token"
258+secrets_backend = "sops"
259+token_secret = "gordon/auth/token_secret"
200260 ```
201261
202262 ## Internal Registry Auth
203263
204264 Gordon generates internal credentials automatically when auth is enabled. These are used for loopback pulls when deploying containers and are regenerated on each restart.
205265
266+To view internal credentials (for troubleshooting):
267+```bash
268+gordon auth internal
269+```
270+
206271 ## Related
207272
208-- [Secrets Configuration](./secrets.md)
273+- [Secret Providers](./secrets.md)
209274 - [CLI Commands](../cli/commands.md)
210275 - [GitHub Actions Deployment](../deployment/github-actions.md)
M docs/config/index.md +15 -18
@@ -23,6 +23,8 @@ gordon_domain = "gordon.mydomain.com"
2323
2424 ## Full Configuration Reference
2525
26+For a complete list of all configuration options with their default values, see the [Configuration Reference](./reference.md).
27+
2628 > **Note:** This example shows production-style paths. Default paths use `~/.gordon/` for user installations.
2729
2830 ```toml
@@ -33,19 +35,16 @@ registry_port = 5000 # Registry port (default: 5000)
3335 gordon_domain = "gordon.mydomain.com" # Required: Gordon domain (registry + API)
3436 # data_dir = "~/.gordon" # Default for user installations
3537
36-# Secrets backend
37-[secrets]
38-backend = "pass" # "pass", "sops", or "unsafe"
39-
40-# Registry authentication
41-[registry_auth]
42-enabled = true
38+# Authentication (includes secrets backend)
39+[auth]
40+enabled = true # Enable registry authentication (default: true)
4341 type = "token" # "password" or "token"
42+secrets_backend = "pass" # "pass", "sops", or "unsafe"
4443 # Password auth:
4544 # username = "deploy"
46-# password_hash = "gordon/registry/password_hash"
45+# password_hash = "gordon/auth/password_hash"
4746 # Token auth:
48-token_secret = "gordon/registry/token_secret"
47+token_secret = "gordon/auth/token_secret"
4948 token_expiry = "30d" # Duration (1y, 30d, 2w) or 0 for never
5049
5150 # Deploy behavior
@@ -111,8 +110,7 @@ enabled = false # Auto-create routes from image names
111110 | Section | Description | Documentation |
112111 |---------|-------------|---------------|
113112 | `[server]` | Core server settings | [Server](./server.md) |
114-| `[secrets]` | Secrets backend configuration | [Secrets](./secrets.md) |
115-| `[registry_auth]` | Registry authentication | [Registry Auth](./registry-auth.md) |
113+| `[auth]` | Authentication and secrets backend | [Auth](./auth.md) |
116114 | `[deploy]` | Deployment behavior | [Deploy](./deploy.md) |
117115 | `[logging]` | Logging configuration | [Logging](./logging.md) |
118116 | `[env]` | Environment variable settings | [Environment](./env.md) |
@@ -131,10 +129,10 @@ enabled = false # Auto-create routes from image names
131129 | `server.port` | `80` |
132130 | `server.registry_port` | `5000` |
133131 | `server.data_dir` | `~/.gordon` |
134-| `secrets.backend` | `"unsafe"` |
135-| `registry_auth.enabled` | `false` |
136-| `registry_auth.type` | `"password"` |
137-| `registry_auth.token_expiry` | `"30d"` |
132+| `auth.enabled` | `true` |
133+| `auth.type` | `"password"` |
134+| `auth.secrets_backend` | `"unsafe"` |
135+| `auth.token_expiry` | `"30d"` |
138136 | `deploy.pull_policy` | `"if-tag-changed"` |
139137 | `logging.level` | `"info"` |
140138 | `logging.format` | `"console"` |
@@ -162,8 +160,7 @@ The following settings require a restart to take effect:
162160 - `server.port`
163161 - `server.registry_port`
164162 - `server.data_dir`
165-- `secrets.backend`
166-- `registry_auth` settings
163+- `auth` settings
167164 - `deploy.pull_policy`
168165
169166 ## Environment Variable Override
@@ -182,4 +179,4 @@ Pattern: `GORDON_SECTION_KEY` (uppercase, underscores instead of dots)
182179 - [Server Configuration](./server.md)
183180 - [Routes Configuration](./routes.md)
184181 - [External Routes](./external-routes.md)
185-- [Registry Authentication](./registry-auth.md)
182+- [Authentication](./auth.md)
A docs/config/reference.md +203 -0
@@ -0,0 +1,203 @@
1+# Configuration Reference
2+
3+Complete configuration reference with all options and their default values.
4+
5+## Full Configuration Example
6+
7+```toml
8+# =============================================================================
9+# SERVER
10+# =============================================================================
11+[server]
12+port = 80 # HTTP proxy port
13+registry_port = 5000 # Container registry port
14+gordon_domain = "" # Required: Gordon domain (registry + API)
15+data_dir = "~/.gordon" # Data directory (varies by install type)
16+
17+# =============================================================================
18+# AUTHENTICATION (required - Gordon won't start without credentials configured)
19+# =============================================================================
20+[auth]
21+enabled = true # Enable registry authentication (default: true)
22+type = "password" # "password" or "token"
23+secrets_backend = "unsafe" # "pass", "sops", or "unsafe"
24+username = "" # Username for password auth
25+password_hash = "" # Path in secrets backend to bcrypt hash (REQUIRED for password type)
26+token_secret = "" # Path in secrets backend to JWT signing key (REQUIRED for token type)
27+token_expiry = "720h" # Token expiry duration (720h = 30 days)
28+
29+# =============================================================================
30+# LOGGING
31+# =============================================================================
32+[logging]
33+level = "info" # "debug", "info", "warn", "error"
34+format = "console" # "console" or "json"
35+
36+[logging.file]
37+enabled = false # Enable file logging
38+path = "" # Log file path (default: {data_dir}/logs/gordon.log)
39+max_size = 100 # Max size in MB before rotation
40+max_backups = 3 # Number of old files to keep
41+max_age = 28 # Days to keep old files
42+
43+[logging.container_logs]
44+enabled = true # Enable container log collection
45+dir = "" # Log directory (default: {data_dir}/container-logs)
46+max_size = 100 # Max size in MB before rotation
47+max_backups = 3 # Number of old files to keep
48+max_age = 28 # Days to keep old files
49+
50+# =============================================================================
51+# ENVIRONMENT
52+# =============================================================================
53+[env]
54+dir = "" # Env files directory (default: {data_dir}/env)
55+
56+# =============================================================================
57+# DEPLOYMENT
58+# =============================================================================
59+[deploy]
60+pull_policy = "if-tag-changed" # "always", "if-tag-changed", "never"
61+
62+# =============================================================================
63+# AUTO-ROUTE
64+# =============================================================================
65+[auto_route]
66+enabled = false # Create routes from image labels automatically
67+
68+# =============================================================================
69+# NETWORK ISOLATION
70+# =============================================================================
71+[network_isolation]
72+enabled = false # Enable per-app Docker networks
73+network_prefix = "gordon" # Prefix for created networks
74+dns_suffix = ".internal" # DNS suffix for internal resolution
75+
76+# =============================================================================
77+# VOLUMES
78+# =============================================================================
79+[volumes]
80+auto_create = true # Auto-create volumes from Dockerfile VOLUME
81+prefix = "gordon" # Volume name prefix
82+preserve = true # Keep volumes when containers are removed
83+
84+# =============================================================================
85+# ROUTES
86+# =============================================================================
87+[routes]
88+# "domain.com" = "image:tag"
89+# "http://insecure.domain.com" = "image:tag" # HTTP-only (no HTTPS redirect)
90+
91+# =============================================================================
92+# EXTERNAL ROUTES
93+# =============================================================================
94+[external_routes]
95+# "domain.com" = "host:port" # Proxy to non-container services
96+
97+# =============================================================================
98+# NETWORK GROUPS
99+# =============================================================================
100+[network_groups]
101+# "group-name" = ["domain1.com", "domain2.com"]
102+
103+# =============================================================================
104+# ATTACHMENTS
105+# =============================================================================
106+[attachments]
107+# "domain-or-group" = ["image1:tag", "image2:tag"]
108+```
109+
110+## Default Values Summary
111+
112+| Setting | Default | Description |
113+|---------|---------|-------------|
114+| `server.port` | `80` | HTTP proxy port |
115+| `server.registry_port` | `5000` | Container registry port |
116+| `server.gordon_domain` | `""` | **Required** - Gordon domain |
117+| `server.data_dir` | `~/.gordon` | Data directory |
118+| `auth.enabled` | `true` | Enable authentication |
119+| `auth.type` | `"password"` | Auth type |
120+| `auth.secrets_backend` | `"unsafe"` | Secrets storage |
121+| `auth.token_expiry` | `"720h"` | 30 days |
122+| `logging.level` | `"info"` | Log level |
123+| `logging.format` | `"console"` | Log format |
124+| `logging.file.enabled` | `false` | File logging disabled |
125+| `logging.file.max_size` | `100` | 100 MB |
126+| `logging.file.max_backups` | `3` | Keep 3 old files |
127+| `logging.file.max_age` | `28` | 28 days |
128+| `logging.container_logs.enabled` | `true` | Container logs enabled |
129+| `logging.container_logs.max_size` | `100` | 100 MB |
130+| `logging.container_logs.max_backups` | `3` | Keep 3 old files |
131+| `logging.container_logs.max_age` | `28` | 28 days |
132+| `deploy.pull_policy` | `"if-tag-changed"` | Pull on tag change |
133+| `auto_route.enabled` | `false` | Auto-route disabled |
134+| `network_isolation.enabled` | `false` | Isolation disabled |
135+| `network_isolation.network_prefix` | `"gordon"` | Network prefix |
136+| `network_isolation.dns_suffix` | `".internal"` | DNS suffix |
137+| `volumes.auto_create` | `true` | Auto-create volumes |
138+| `volumes.prefix` | `"gordon"` | Volume prefix |
139+| `volumes.preserve` | `true` | Keep volumes |
140+
141+## Environment Variables
142+
143+All configuration options can be set via environment variables using the pattern:
144+
145+```
146+GORDON_<SECTION>_<KEY>=value
147+```
148+
149+Examples:
150+```bash
151+GORDON_SERVER_PORT=8080
152+GORDON_SERVER_GORDON_DOMAIN=gordon.example.com
153+GORDON_AUTH_ENABLED=true
154+GORDON_AUTH_TYPE=token
155+GORDON_LOGGING_LEVEL=debug
156+GORDON_NETWORK_ISOLATION_ENABLED=true
157+```
158+
159+Nested keys use underscores:
160+```bash
161+GORDON_LOGGING_FILE_ENABLED=true
162+GORDON_LOGGING_FILE_MAX_SIZE=200
163+GORDON_LOGGING_CONTAINER_LOGS_ENABLED=false
164+```
165+
166+## Pull Policy Options
167+
168+| Value | Behavior |
169+|-------|----------|
170+| `"always"` | Always pull image before deploying |
171+| `"if-tag-changed"` | Pull only if image tag differs from running container |
172+| `"never"` | Never pull, use local image only |
173+
174+## Auth Type Options
175+
176+| Value | Description |
177+|-------|-------------|
178+| `"password"` | HTTP Basic authentication with username/password |
179+| `"token"` | JWT token authentication |
180+
181+## Secrets Backend Options
182+
183+| Value | Description |
184+|-------|-------------|
185+| `"pass"` | Unix password manager (recommended for production) |
186+| `"sops"` | Mozilla SOPS encrypted files |
187+| `"unsafe"` | Plain text files (development only) |
188+
189+## Log Level Options
190+
191+| Value | Description |
192+|-------|-------------|
193+| `"debug"` | Verbose debugging information |
194+| `"info"` | General operational information |
195+| `"warn"` | Warning conditions |
196+| `"error"` | Error conditions only |
197+
198+## Related
199+
200+- [Configuration Overview](./index.md)
201+- [Authentication](./auth.md)
202+- [Network Isolation](./network-isolation.md)
203+- [Volumes](./volumes.md)
M docs/config/secrets.md +29 -33
@@ -4,16 +4,18 @@ Configure how Gordon stores and retrieves sensitive data.
44
55 ## Configuration
66
7+The secrets backend is configured within the `[auth]` section:
8+
79 ```toml
8-[secrets]
9-backend = "pass" # "pass", "sops", or "unsafe"
10+[auth]
11+secrets_backend = "pass" # "pass", "sops", or "unsafe"
1012 ```
1113
1214 ## Options
1315
1416 | Option | Type | Default | Description |
1517 |--------|------|---------|-------------|
16-| `backend` | string | `"unsafe"` | Secrets storage backend |
18+| `secrets_backend` | string | `"unsafe"` | Secrets storage backend |
1719
1820 ## Backends
1921
@@ -22,8 +24,8 @@ backend = "pass" # "pass", "sops", or "unsafe"
2224 Uses the Unix password manager (`pass`) for secure secret storage:
2325
2426 ```toml
25-[secrets]
26-backend = "pass"
27+[auth]
28+secrets_backend = "pass"
2729 ```
2830
2931 **Setup:**
@@ -35,15 +37,15 @@ sudo apt install pass
3537 pass init your-gpg-key-id
3638
3739 # Store secrets
38-pass insert gordon/registry/password_hash
39-pass insert gordon/registry/token_secret
40+pass insert gordon/auth/password_hash
41+pass insert gordon/auth/token_secret
4042 ```
4143
4244 **Usage in config:**
4345 ```toml
44-[registry_auth]
45-password_hash = "gordon/registry/password_hash" # Path in pass store
46-token_secret = "gordon/registry/token_secret"
46+[auth]
47+password_hash = "gordon/auth/password_hash" # Path in pass store
48+token_secret = "gordon/auth/token_secret"
4749 ```
4850
4951 **Benefits:**
@@ -57,8 +59,8 @@ token_secret = "gordon/registry/token_secret"
5759 Uses Mozilla SOPS for encrypted file-based secrets:
5860
5961 ```toml
60-[secrets]
61-backend = "sops"
62+[auth]
63+secrets_backend = "sops"
6264 ```
6365
6466 **Setup:**
@@ -88,15 +90,15 @@ DB_PASSWORD=${sops:secrets.yaml:database.password}
8890 Stores secrets as plain text files:
8991
9092 ```toml
91-[secrets]
92-backend = "unsafe"
93+[auth]
94+secrets_backend = "unsafe"
9395 ```
9496
9597 **Storage location:**
9698 ```
9799 {data_dir}/secrets/
98100 ├── gordon/
99-│ └── registry/
101+│ └── auth/
100102 │ ├── password_hash
101103 │ └── token_secret
102104 ```
@@ -104,8 +106,8 @@ backend = "unsafe"
104106 **Usage:**
105107 ```bash
106108 # Create secret
107-mkdir -p ~/.gordon/secrets/gordon/registry
108-echo "your-bcrypt-hash" > ~/.gordon/secrets/gordon/registry/password_hash
109+mkdir -p ~/.gordon/secrets/gordon/auth
110+echo "your-bcrypt-hash" > ~/.gordon/secrets/gordon/auth/password_hash
109111 ```
110112
111113 > **Warning:** Only use for local development. Secrets are stored in plain text.
@@ -135,18 +137,16 @@ API_SECRET=${sops:production.yaml:api.secret.key}
135137 ### Production with Pass
136138
137139 ```toml
138-[secrets]
139-backend = "pass"
140-
141-[registry_auth]
140+[auth]
142141 enabled = true
143142 type = "token"
144-token_secret = "gordon/registry/token_secret"
143+secrets_backend = "pass"
144+token_secret = "gordon/auth/token_secret"
145145 ```
146146
147147 ```bash
148148 # Setup
149-pass insert gordon/registry/token_secret
149+pass insert gordon/auth/token_secret
150150 # Enter a random 32+ character string
151151
152152 # Generate tokens
@@ -156,23 +156,19 @@ gordon auth token generate --subject deploy --expiry 0
156156 ### Development with Unsafe
157157
158158 ```toml
159-[secrets]
160-backend = "unsafe"
161-
162-[registry_auth]
159+[auth]
163160 enabled = false
161+secrets_backend = "unsafe"
164162 ```
165163
166164 ### Enterprise with SOPS
167165
168166 ```toml
169-[secrets]
170-backend = "sops"
171-
172-[registry_auth]
167+[auth]
173168 enabled = true
174169 type = "token"
175-token_secret = "gordon/registry/token_secret"
170+secrets_backend = "sops"
171+token_secret = "gordon/auth/token_secret"
176172 ```
177173
178174 Environment file:
@@ -194,6 +190,6 @@ JWT_SECRET=${sops:secrets.yaml:jwt.secret}
194190
195191 ## Related
196192
197-- [Registry Authentication](./registry-auth.md)
193+- [Authentication](./auth.md)
198194 - [Environment Variables](./env.md)
199195 - [Configuration Overview](./index.md)
M docs/config/volumes.md +2 -2
@@ -24,7 +24,7 @@ preserve = true
2424 Gordon automatically creates Docker volumes from Dockerfile `VOLUME` directives:
2525
2626 ```dockerfile
27-FROM postgres:15
27+FROM postgres:18
2828 VOLUME ["/var/lib/postgresql/data"]
2929 ```
3030
@@ -76,7 +76,7 @@ With `preserve = false`:
7676
7777 ```dockerfile
7878 # my-postgres.Dockerfile
79-FROM postgres:15
79+FROM postgres:18
8080 VOLUME ["/var/lib/postgresql/data"]
8181 ENV POSTGRES_DB=myapp
8282 ENV POSTGRES_USER=app
A internal/adapters/dto/admin_attachments.go +23 -0
@@ -0,0 +1,23 @@
1+// Package dto provides shared data transfer objects for API responses.
2+package dto
3+
4+// AttachmentsConfigResponse represents all configured attachments.
5+type AttachmentsConfigResponse struct {
6+ Attachments map[string][]string `json:"attachments"`
7+}
8+
9+// AttachmentConfigResponse represents attachments for a specific target.
10+type AttachmentConfigResponse struct {
11+ Target string `json:"target"`
12+ Images []string `json:"images"`
13+}
14+
15+// AttachmentAddRequest represents a request to add an attachment.
16+type AttachmentAddRequest struct {
17+ Image string `json:"image"`
18+}
19+
20+// AttachmentStatusResponse represents attachment operation result.
21+type AttachmentStatusResponse struct {
22+ Status string `json:"status"`
23+}
A internal/adapters/in/cli/attachments.go +287 -0
@@ -0,0 +1,287 @@
1+package cli
2+
3+import (
4+ "context"
5+ "fmt"
6+ "sort"
7+
8+ "gordon/internal/adapters/in/cli/remote"
9+ "gordon/internal/adapters/in/cli/ui/components"
10+ "gordon/internal/adapters/in/cli/ui/styles"
11+
12+ "github.com/spf13/cobra"
13+)
14+
15+// newAttachmentsCmd creates the attachments command group.
16+func newAttachmentsCmd() *cobra.Command {
17+ cmd := &cobra.Command{
18+ Use: "attachments",
19+ Aliases: []string{"attach"},
20+ Short: "Manage container attachments",
21+ Long: `Manage container attachments (databases, caches, queues) in the configuration.
22+
23+Attachments are service dependencies that run alongside your application containers.
24+They are defined per-domain or per-network-group in the configuration.
25+
26+When targeting a remote Gordon instance (via --remote flag or GORDON_REMOTE env var),
27+these commands operate on the remote server. Otherwise, they require access to
28+the local Gordon configuration.`,
29+ }
30+
31+ cmd.AddCommand(newAttachmentsListCmd())
32+ cmd.AddCommand(newAttachmentsAddCmd())
33+ cmd.AddCommand(newAttachmentsRemoveCmd())
34+
35+ return cmd
36+}
37+
38+// newAttachmentsListCmd creates the attachments list command.
39+func newAttachmentsListCmd() *cobra.Command {
40+ return &cobra.Command{
41+ Use: "list [domain-or-group]",
42+ Short: "List configured attachments",
43+ Long: `List all configured attachments, or attachments for a specific domain/group.
44+
45+Examples:
46+ gordon attachments list # List all attachments
47+ gordon attachments list app.example.com # List attachments for domain
48+ gordon attachments list backend # List attachments for network group`,
49+ Args: cobra.MaximumNArgs(1),
50+ RunE: func(cmd *cobra.Command, args []string) error {
51+ ctx := context.Background()
52+ target := ""
53+ if len(args) > 0 {
54+ target = args[0]
55+ }
56+
57+ client, isRemote := GetRemoteClient()
58+ if isRemote {
59+ return runAttachmentsListRemote(ctx, client, target)
60+ }
61+ return runAttachmentsListLocal(ctx, configPath, target)
62+ },
63+ }
64+}
65+
66+// runAttachmentsListRemote lists attachments from a remote Gordon instance.
67+func runAttachmentsListRemote(ctx context.Context, client *remote.Client, target string) error {
68+ if target != "" {
69+ // List for specific target
70+ images, err := client.GetAttachmentsConfig(ctx, target)
71+ if err != nil {
72+ return fmt.Errorf("failed to get attachments: %w", err)
73+ }
74+
75+ if len(images) == 0 {
76+ fmt.Printf("No attachments configured for '%s'\n", target)
77+ return nil
78+ }
79+
80+ fmt.Println(styles.Theme.Title.Render(fmt.Sprintf("Attachments for %s", target)))
81+ fmt.Println()
82+ for _, img := range images {
83+ fmt.Printf(" %s\n", img)
84+ }
85+ return nil
86+ }
87+
88+ // List all attachments
89+ attachments, err := client.GetAllAttachmentsConfig(ctx)
90+ if err != nil {
91+ return fmt.Errorf("failed to list attachments: %w", err)
92+ }
93+
94+ if len(attachments) == 0 {
95+ fmt.Println(styles.Theme.Muted.Render("No attachments configured"))
96+ return nil
97+ }
98+
99+ // Sort targets for consistent output
100+ targets := make([]string, 0, len(attachments))
101+ for t := range attachments {
102+ targets = append(targets, t)
103+ }
104+ sort.Strings(targets)
105+
106+ fmt.Println(styles.Theme.Title.Render("Attachments"))
107+ fmt.Println()
108+
109+ for _, t := range targets {
110+ images := attachments[t]
111+ fmt.Printf("%s\n", styles.Theme.Bold.Render(t))
112+ for _, img := range images {
113+ fmt.Printf(" %s\n", img)
114+ }
115+ }
116+
117+ return nil
118+}
119+
120+// runAttachmentsListLocal lists attachments from local configuration.
121+func runAttachmentsListLocal(ctx context.Context, cfgPath string, target string) error {
122+ local, err := GetLocalServices(cfgPath)
123+ if err != nil {
124+ return fmt.Errorf("failed to initialize local services: %w", err)
125+ }
126+
127+ if target != "" {
128+ // List for specific target
129+ images, err := local.GetConfigService().GetAttachmentsFor(ctx, target)
130+ if err != nil {
131+ return fmt.Errorf("failed to get attachments: %w", err)
132+ }
133+
134+ if len(images) == 0 {
135+ fmt.Printf("No attachments configured for '%s'\n", target)
136+ return nil
137+ }
138+
139+ fmt.Println(styles.Theme.Title.Render(fmt.Sprintf("Attachments for %s (local)", target)))
140+ fmt.Println()
141+ for _, img := range images {
142+ fmt.Printf(" %s\n", img)
143+ }
144+ return nil
145+ }
146+
147+ // List all attachments
148+ attachments := local.GetConfigService().GetAllAttachments(ctx)
149+
150+ if len(attachments) == 0 {
151+ fmt.Println(styles.Theme.Muted.Render("No attachments configured"))
152+ return nil
153+ }
154+
155+ // Sort targets for consistent output
156+ targets := make([]string, 0, len(attachments))
157+ for t := range attachments {
158+ targets = append(targets, t)
159+ }
160+ sort.Strings(targets)
161+
162+ fmt.Println(styles.Theme.Title.Render("Attachments (local)"))
163+ fmt.Println()
164+
165+ for _, t := range targets {
166+ images := attachments[t]
167+ fmt.Printf("%s\n", styles.Theme.Bold.Render(t))
168+ for _, img := range images {
169+ fmt.Printf(" %s\n", img)
170+ }
171+ }
172+
173+ return nil
174+}
175+
176+// newAttachmentsAddCmd creates the attachments add command.
177+func newAttachmentsAddCmd() *cobra.Command {
178+ cmd := &cobra.Command{
179+ Use: "add <domain-or-group> <image>",
180+ Short: "Add an attachment",
181+ Long: `Add a container attachment to a domain or network group.
182+
183+Note: Attachments require network_isolation.enabled = true in your configuration.
184+Without network isolation, containers use Docker's default bridge network which
185+does not provide DNS resolution - your app won't be able to reach attachments
186+by hostname (e.g., 'postgres:5432').
187+
188+Examples:
189+ gordon attachments add app.example.com postgres:18
190+ gordon attachments add backend redis:7-alpine
191+ gordon --remote https://gordon.mydomain.com attachments add api.mydomain.com memcached:latest`,
192+ Args: cobra.ExactArgs(2),
193+ RunE: func(cmd *cobra.Command, args []string) error {
194+ ctx := context.Background()
195+ target := args[0]
196+ image := args[1]
197+
198+ client, isRemote := GetRemoteClient()
199+ if isRemote {
200+ if err := client.AddAttachment(ctx, target, image); err != nil {
201+ return fmt.Errorf("failed to add attachment: %w", err)
202+ }
203+ } else {
204+ local, err := GetLocalServices(configPath)
205+ if err != nil {
206+ return fmt.Errorf("failed to initialize local services: %w", err)
207+ }
208+
209+ // Warn if network isolation is disabled
210+ if !local.GetConfigService().IsNetworkIsolationEnabled() {
211+ fmt.Println(styles.RenderWarning("network_isolation.enabled = false"))
212+ fmt.Println(styles.Theme.Muted.Render(" Attachments require network isolation for DNS resolution."))
213+ fmt.Println(styles.Theme.Muted.Render(" Without it, your app cannot reach attachments by hostname."))
214+ fmt.Println(styles.Theme.Muted.Render(" Enable with: [network_isolation] enabled = true"))
215+ fmt.Println()
216+ }
217+
218+ if err := local.GetConfigService().AddAttachment(ctx, target, image); err != nil {
219+ return fmt.Errorf("failed to add attachment: %w", err)
220+ }
221+ }
222+
223+ fmt.Println(styles.RenderSuccess(fmt.Sprintf("Attachment added: %s -> %s", target, image)))
224+ return nil
225+ },
226+ }
227+
228+ return cmd
229+}
230+
231+// newAttachmentsRemoveCmd creates the attachments remove command.
232+func newAttachmentsRemoveCmd() *cobra.Command {
233+ var force bool
234+
235+ cmd := &cobra.Command{
236+ Use: "remove <domain-or-group> <image>",
237+ Short: "Remove an attachment",
238+ Long: `Remove a container attachment from a domain or network group.
239+
240+Examples:
241+ gordon attachments remove app.example.com postgres:15
242+ gordon attachments remove backend redis:7-alpine --force`,
243+ Args: cobra.ExactArgs(2),
244+ RunE: func(cmd *cobra.Command, args []string) error {
245+ ctx := context.Background()
246+ target := args[0]
247+ image := args[1]
248+
249+ // Confirm unless --force
250+ if !force {
251+ confirmed, err := components.RunConfirm(
252+ fmt.Sprintf("Remove attachment '%s' from '%s'?", image, target),
253+ components.WithDescription("This will remove the attachment from the configuration. The container will be stopped on next reload."),
254+ )
255+ if err != nil {
256+ return err
257+ }
258+ if !confirmed {
259+ fmt.Println(styles.Theme.Muted.Render("Cancelled"))
260+ return nil
261+ }
262+ }
263+
264+ client, isRemote := GetRemoteClient()
265+ if isRemote {
266+ if err := client.RemoveAttachment(ctx, target, image); err != nil {
267+ return fmt.Errorf("failed to remove attachment: %w", err)
268+ }
269+ } else {
270+ local, err := GetLocalServices(configPath)
271+ if err != nil {
272+ return fmt.Errorf("failed to initialize local services: %w", err)
273+ }
274+ if err := local.GetConfigService().RemoveAttachment(ctx, target, image); err != nil {
275+ return fmt.Errorf("failed to remove attachment: %w", err)
276+ }
277+ }
278+
279+ fmt.Println(styles.RenderSuccess(fmt.Sprintf("Attachment removed: %s -> %s", target, image)))
280+ return nil
281+ },
282+ }
283+
284+ cmd.Flags().BoolVarP(&force, "force", "f", false, "Skip confirmation")
285+
286+ return cmd
287+}
M internal/adapters/in/cli/auth.go +9 -9
@@ -25,8 +25,8 @@ import (
2525 func newAuthCmd() *cobra.Command {
2626 cmd := &cobra.Command{
2727 Use: "auth",
28- Short: "Manage registry authentication",
29- Long: `Commands for managing registry authentication tokens and passwords.`,
28+ Short: "Manage Gordon server authentication",
29+ Long: `Commands for managing Gordon server authentication tokens and passwords.`,
3030 }
3131
3232 cmd.AddCommand(newAuthTokenCmd())
@@ -224,9 +224,9 @@ func newPasswordHashCmd() *cobra.Command {
224224
225225 The hash can be stored in your secrets backend and referenced in the config:
226226
227- [registry_auth]
227+ [auth]
228228 type = "password"
229- password_hash = "gordon/registry/password_hash"`,
229+ password_hash = "gordon/auth/password_hash"`,
230230 RunE: func(cmd *cobra.Command, args []string) error {
231231 return runPasswordHash()
232232 },
@@ -410,9 +410,9 @@ func runPasswordHash() error {
410410 fmt.Println(hash)
411411 fmt.Println()
412412 fmt.Println("Then reference the path in your config:")
413- fmt.Println(" [registry_auth]")
413+ fmt.Println(" [auth]")
414414 fmt.Println(" type = \"password\"")
415- fmt.Println(" password_hash = \"gordon/registry/password_hash\"")
415+ fmt.Println(" password_hash = \"gordon/auth/password_hash\"")
416416
417417 return nil
418418 }
@@ -430,7 +430,7 @@ func loadAuthConfig(configPath string) (*cliConfig, error) {
430430
431431 // Set defaults
432432 v.SetDefault("server.data_dir", app.DefaultDataDir())
433- v.SetDefault("secrets.backend", "unsafe")
433+ v.SetDefault("auth.secrets_backend", "unsafe")
434434
435435 app.ConfigureViper(v, configPath)
436436
@@ -446,7 +446,7 @@ func loadAuthConfig(configPath string) (*cliConfig, error) {
446446 }
447447
448448 // Determine backend
449- switch v.GetString("secrets.backend") {
449+ switch v.GetString("auth.secrets_backend") {
450450 case "pass":
451451 cfg.Backend = domain.SecretsBackendPass
452452 case "sops":
@@ -456,7 +456,7 @@ func loadAuthConfig(configPath string) (*cliConfig, error) {
456456 }
457457
458458 // Load token secret from secrets backend
459- tokenSecretPath := v.GetString("registry_auth.token_secret")
459+ tokenSecretPath := v.GetString("auth.token_secret")
460460 if tokenSecretPath != "" {
461461 // Load actual secret from the configured backend
462462 ctx := context.Background()
M internal/adapters/in/cli/remote/client.go +75 -0
@@ -458,6 +458,81 @@ func (c *Client) StreamContainerLogs(ctx context.Context, logDomain string, line
458458 return c.streamLogs(ctx, path)
459459 }
460460
461+// Attachments Config API
462+
463+// GetAllAttachmentsConfig returns all configured attachments.
464+func (c *Client) GetAllAttachmentsConfig(ctx context.Context) (map[string][]string, error) {
465+ resp, err := c.request(ctx, http.MethodGet, "/attachments", nil)
466+ if err != nil {
467+ return nil, err
468+ }
469+
470+ var result struct {
471+ Attachments map[string][]string `json:"attachments"`
472+ }
473+ if err := parseResponse(resp, &result); err != nil {
474+ return nil, err
475+ }
476+
477+ return result.Attachments, nil
478+}
479+
480+// GetAttachmentsConfig returns attachments for a specific domain/group from config.
481+func (c *Client) GetAttachmentsConfig(ctx context.Context, domainOrGroup string) ([]string, error) {
482+ if domainOrGroup == "" {
483+ return nil, fmt.Errorf("domain or group is required")
484+ }
485+ path := "/attachments/" + url.PathEscape(domainOrGroup)
486+ resp, err := c.request(ctx, http.MethodGet, path, nil)
487+ if err != nil {
488+ return nil, err
489+ }
490+
491+ var result struct {
492+ Target string `json:"target"`
493+ Images []string `json:"images"`
494+ }
495+ if err := parseResponse(resp, &result); err != nil {
496+ return nil, err
497+ }
498+
499+ return result.Images, nil
500+}
501+
502+// AddAttachment adds an attachment to a domain/group.
503+func (c *Client) AddAttachment(ctx context.Context, domainOrGroup, image string) error {
504+ if domainOrGroup == "" {
505+ return fmt.Errorf("domain or group is required")
506+ }
507+ if image == "" {
508+ return fmt.Errorf("image is required")
509+ }
510+ path := "/attachments/" + url.PathEscape(domainOrGroup)
511+ resp, err := c.request(ctx, http.MethodPost, path, struct {
512+ Image string `json:"image"`
513+ }{Image: image})
514+ if err != nil {
515+ return err
516+ }
517+ return parseResponse(resp, nil)
518+}
519+
520+// RemoveAttachment removes an attachment from a domain/group.
521+func (c *Client) RemoveAttachment(ctx context.Context, domainOrGroup, image string) error {
522+ if domainOrGroup == "" {
523+ return fmt.Errorf("domain or group is required")
524+ }
525+ if image == "" {
526+ return fmt.Errorf("image is required")
527+ }
528+ path := "/attachments/" + url.PathEscape(domainOrGroup) + "/" + url.PathEscape(image)
529+ resp, err := c.request(ctx, http.MethodDelete, path, nil)
530+ if err != nil {
531+ return err
532+ }
533+ return parseResponse(resp, nil)
534+}
535+
461536 // streamLogs handles SSE streaming for log endpoints.
462537 func (c *Client) streamLogs(ctx context.Context, path string) (<-chan string, error) {
463538 url := c.baseURL + "/admin" + path
M internal/adapters/in/cli/root.go +72 -16
@@ -23,6 +23,13 @@ var (
2323 tokenFlag string
2424 )
2525
26+// Command group IDs
27+const (
28+ groupServer = "server"
29+ groupManage = "manage"
30+ groupClient = "client"
31+)
32+
2633 // NewRootCmd creates the root command for Gordon CLI.
2734 func NewRootCmd() *cobra.Command {
2835 rootCmd := &cobra.Command{
@@ -34,28 +41,77 @@ a Docker registry with automatic container deployment capabilities.
3441 It listens for image pushes and automatically deploys containers based on
3542 configuration rules, making it ideal for single-server deployments.
3643
37-The CLI can target remote Gordon instances using the --remote flag or
38-GORDON_REMOTE environment variable.`,
44+Commands are organized by where they run:
45+ Server-only: Run on the machine hosting Gordon (serve, auth, reload)
46+ Management: Work locally or remotely via --remote flag (routes, secrets, etc.)
47+ Client-only: CLI utilities that don't require a running Gordon server`,
3948 }
4049
50+ // Define command groups (order matters for help output)
51+ rootCmd.AddGroup(
52+ &cobra.Group{ID: groupServer, Title: "Server Commands (local only):"},
53+ &cobra.Group{ID: groupManage, Title: "Management Commands (local or --remote):"},
54+ &cobra.Group{ID: groupClient, Title: "Client Commands:"},
55+ )
56+
4157 // Add persistent flags for remote targeting
4258 rootCmd.PersistentFlags().StringVar(&remoteFlag, "remote", "", "Remote Gordon URL (e.g., https://gordon.mydomain.com)")
4359 rootCmd.PersistentFlags().StringVar(&tokenFlag, "token", "", "Authentication token for remote")
4460
45- // Server commands
46- rootCmd.AddCommand(newServeCmd())
47- rootCmd.AddCommand(newStartCmd()) // Deprecated alias for serve
48- rootCmd.AddCommand(newReloadCmd())
49- rootCmd.AddCommand(newDeployCmd())
50- rootCmd.AddCommand(newLogsCmd())
51- rootCmd.AddCommand(newVersionCmd())
52- rootCmd.AddCommand(newAuthCmd())
53-
54- // Remote management commands
55- rootCmd.AddCommand(newRoutesCmd())
56- rootCmd.AddCommand(newSecretsCmd())
57- rootCmd.AddCommand(newRemotesCmd())
58- rootCmd.AddCommand(newStatusCmd())
61+ // Server-only commands (must run on the Gordon host)
62+ serveCmd := newServeCmd()
63+ serveCmd.GroupID = groupServer
64+ rootCmd.AddCommand(serveCmd)
65+
66+ startCmd := newStartCmd() // Deprecated alias for serve
67+ startCmd.GroupID = groupServer
68+ rootCmd.AddCommand(startCmd)
69+
70+ authCmd := newAuthCmd()
71+ authCmd.GroupID = groupServer
72+ rootCmd.AddCommand(authCmd)
73+
74+ // Management commands (work locally or via --remote)
75+ routesCmd := newRoutesCmd()
76+ routesCmd.GroupID = groupManage
77+ rootCmd.AddCommand(routesCmd)
78+
79+ attachmentsCmd := newAttachmentsCmd()
80+ attachmentsCmd.GroupID = groupManage
81+ rootCmd.AddCommand(attachmentsCmd)
82+
83+ secretsCmd := newSecretsCmd()
84+ secretsCmd.GroupID = groupManage
85+ rootCmd.AddCommand(secretsCmd)
86+
87+ deployCmd := newDeployCmd()
88+ deployCmd.GroupID = groupManage
89+ rootCmd.AddCommand(deployCmd)
90+
91+ reloadCmd := newReloadCmd()
92+ reloadCmd.GroupID = groupManage
93+ rootCmd.AddCommand(reloadCmd)
94+
95+ logsCmd := newLogsCmd()
96+ logsCmd.GroupID = groupManage
97+ rootCmd.AddCommand(logsCmd)
98+
99+ statusCmd := newStatusCmd()
100+ statusCmd.GroupID = groupManage
101+ rootCmd.AddCommand(statusCmd)
102+
103+ // Client-only commands (no server needed)
104+ remotesCmd := newRemotesCmd()
105+ remotesCmd.GroupID = groupClient
106+ rootCmd.AddCommand(remotesCmd)
107+
108+ versionCmd := newVersionCmd()
109+ versionCmd.GroupID = groupClient
110+ rootCmd.AddCommand(versionCmd)
111+
112+ // Put help and completion in the client group
113+ rootCmd.SetHelpCommandGroupID(groupClient)
114+ rootCmd.SetCompletionCommandGroupID(groupClient)
59115
60116 return rootCmd
61117 }
M internal/adapters/in/http/admin/handler.go +180 -21
@@ -122,28 +122,48 @@ func (h *Handler) handleAdminRoutes(w http.ResponseWriter, r *http.Request) {
122122 path := strings.TrimPrefix(r.URL.Path, "/admin")
123123
124124 // Route to appropriate handler
125- switch {
126- case path == "/routes" || strings.HasPrefix(path, "/routes/"):
127- h.handleRoutes(w, r, path)
128- case path == "/networks":
129- h.handleNetworks(w, r)
130- case path == "/secrets" || strings.HasPrefix(path, "/secrets/"):
131- h.handleSecrets(w, r, path)
132- case path == "/deploy" || strings.HasPrefix(path, "/deploy/"):
133- h.handleDeploy(w, r, path)
134- case path == "/logs" || strings.HasPrefix(path, "/logs/"):
135- h.handleLogs(w, r, path)
136- case path == "/status":
137- h.handleStatus(w, r)
138- case path == "/health":
139- h.handleHealth(w, r)
140- case path == "/reload":
141- h.handleReload(w, r)
142- case path == "/config":
143- h.handleConfig(w, r)
144- default:
145- h.sendError(w, http.StatusNotFound, "route not found")
125+ if handler, ok := h.matchRoute(path); ok {
126+ handler(w, r, path)
127+ return
128+ }
129+ h.sendError(w, http.StatusNotFound, "route not found")
130+}
131+
132+// routeHandler is the signature for path-based route handlers.
133+type routeHandler func(w http.ResponseWriter, r *http.Request, path string)
134+
135+// matchRoute returns the handler for a given path, or false if not found.
136+func (h *Handler) matchRoute(path string) (routeHandler, bool) {
137+ // Exact match routes
138+ exactRoutes := map[string]routeHandler{
139+ "/networks": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleNetworks(w, r) },
140+ "/status": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleStatus(w, r) },
141+ "/health": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleHealth(w, r) },
142+ "/reload": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleReload(w, r) },
143+ "/config": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleConfig(w, r) },
144+ }
145+ if handler, ok := exactRoutes[path]; ok {
146+ return handler, true
147+ }
148+
149+ // Prefix match routes
150+ prefixRoutes := []struct {
151+ prefix string
152+ handler routeHandler
153+ }{
154+ {"/attachments", h.handleAttachmentsConfig},
155+ {"/routes", h.handleRoutes},
156+ {"/secrets", h.handleSecrets},
157+ {"/deploy", h.handleDeploy},
158+ {"/logs", h.handleLogs},
159+ }
160+ for _, route := range prefixRoutes {
161+ if path == route.prefix || strings.HasPrefix(path, route.prefix+"/") {
162+ return route.handler, true
163+ }
146164 }
165+
166+ return nil, false
147167 }
148168
149169 // sendJSON sends a JSON response.
@@ -857,3 +877,142 @@ func (h *Handler) streamContainerLogs(w http.ResponseWriter, r *http.Request, lo
857877 }
858878 }
859879 }
880+
881+// handleAttachmentsConfig handles /admin/attachments endpoints for config-level attachments.
882+func (h *Handler) handleAttachmentsConfig(w http.ResponseWriter, r *http.Request, path string) {
883+ // Parse target (domain or group) from path
884+ target := strings.TrimPrefix(path, "/attachments/")
885+ if target == "/attachments" {
886+ target = ""
887+ }
888+
889+ switch r.Method {
890+ case http.MethodGet:
891+ h.handleAttachmentsConfigGet(w, r, target)
892+ case http.MethodPost:
893+ h.handleAttachmentsConfigPost(w, r, target)
894+ case http.MethodDelete:
895+ h.handleAttachmentsConfigDelete(w, r, target)
896+ default:
897+ h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
898+ }
899+}
900+
901+func (h *Handler) handleAttachmentsConfigGet(w http.ResponseWriter, r *http.Request, target string) {
902+ ctx := r.Context()
903+
904+ // Check read permission
905+ if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionRead) {
906+ h.sendError(w, http.StatusForbidden, "insufficient permissions for config:read")
907+ return
908+ }
909+
910+ if target == "" {
911+ // List all attachments
912+ attachments := h.configSvc.GetAllAttachments(ctx)
913+ h.sendJSON(w, http.StatusOK, dto.AttachmentsConfigResponse{Attachments: attachments})
914+ return
915+ }
916+
917+ // List attachments for specific target
918+ images, err := h.configSvc.GetAttachmentsFor(ctx, target)
919+ if err != nil {
920+ if errors.Is(err, domain.ErrAttachmentNotFound) {
921+ h.sendError(w, http.StatusNotFound, "no attachments found for target")
922+ return
923+ }
924+ h.sendError(w, http.StatusInternalServerError, "failed to get attachments")
925+ return
926+ }
927+
928+ h.sendJSON(w, http.StatusOK, dto.AttachmentConfigResponse{Target: target, Images: images})
929+}
930+
931+func (h *Handler) handleAttachmentsConfigPost(w http.ResponseWriter, r *http.Request, target string) {
932+ ctx := r.Context()
933+ log := zerowrap.FromCtx(ctx)
934+
935+ // Check write permission
936+ if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionWrite) {
937+ h.sendError(w, http.StatusForbidden, "insufficient permissions for config:write")
938+ return
939+ }
940+
941+ if target == "" {
942+ h.sendError(w, http.StatusBadRequest, "target (domain or group) required in path")
943+ return
944+ }
945+
946+ // Limit request body size
947+ r.Body = http.MaxBytesReader(w, r.Body, maxAdminRequestSize)
948+
949+ var req dto.AttachmentAddRequest
950+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
951+ log.Warn().Err(err).Msg("invalid attachment JSON")
952+ h.sendError(w, http.StatusBadRequest, "invalid JSON")
953+ return
954+ }
955+
956+ if err := h.configSvc.AddAttachment(ctx, target, req.Image); err != nil {
957+ log.Error().Err(err).Str("target", target).Str("image", req.Image).Msg("failed to add attachment")
958+ switch {
959+ case errors.Is(err, domain.ErrAttachmentExists):
960+ h.sendError(w, http.StatusConflict, "attachment already exists")
961+ case errors.Is(err, domain.ErrAttachmentImageEmpty):
962+ h.sendError(w, http.StatusBadRequest, err.Error())
963+ case errors.Is(err, domain.ErrAttachmentTargetEmpty):
964+ h.sendError(w, http.StatusBadRequest, err.Error())
965+ default:
966+ h.sendError(w, http.StatusInternalServerError, "failed to add attachment")
967+ }
968+ return
969+ }
970+
971+ log.Info().Str("target", target).Str("image", req.Image).Msg("attachment added")
972+ h.sendJSON(w, http.StatusCreated, dto.AttachmentStatusResponse{Status: "added"})
973+}
974+
975+func (h *Handler) handleAttachmentsConfigDelete(w http.ResponseWriter, r *http.Request, target string) {
976+ ctx := r.Context()
977+ log := zerowrap.FromCtx(ctx)
978+
979+ // Check write permission
980+ if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionWrite) {
981+ h.sendError(w, http.StatusForbidden, "insufficient permissions for config:write")
982+ return
983+ }
984+
985+ if target == "" {
986+ h.sendError(w, http.StatusBadRequest, "target (domain or group) required in path")
987+ return
988+ }
989+
990+ // Parse image from path: /attachments/{target}/{image}
991+ // target at this point contains "{domain}/{image}" or just "{domain}"
992+ parts := strings.SplitN(target, "/", 2)
993+ if len(parts) != 2 {
994+ h.sendError(w, http.StatusBadRequest, "image required in path: /attachments/{target}/{image}")
995+ return
996+ }
997+
998+ domainOrGroup := parts[0]
999+ image := parts[1]
1000+
1001+ if err := h.configSvc.RemoveAttachment(ctx, domainOrGroup, image); err != nil {
1002+ log.Error().Err(err).Str("target", domainOrGroup).Str("image", image).Msg("failed to remove attachment")
1003+ switch {
1004+ case errors.Is(err, domain.ErrAttachmentNotFound):
1005+ h.sendError(w, http.StatusNotFound, "attachment not found")
1006+ case errors.Is(err, domain.ErrAttachmentImageEmpty):
1007+ h.sendError(w, http.StatusBadRequest, err.Error())
1008+ case errors.Is(err, domain.ErrAttachmentTargetEmpty):
1009+ h.sendError(w, http.StatusBadRequest, err.Error())
1010+ default:
1011+ h.sendError(w, http.StatusInternalServerError, "failed to remove attachment")
1012+ }
1013+ return
1014+ }
1015+
1016+ log.Info().Str("target", domainOrGroup).Str("image", image).Msg("attachment removed")
1017+ h.sendJSON(w, http.StatusOK, dto.AttachmentStatusResponse{Status: "removed"})
1018+}
M internal/app/run.go +39 -34
@@ -90,19 +90,16 @@ type Config struct {
9090 Dir string `mapstructure:"dir"`
9191 } `mapstructure:"env"`
9292
93- Secrets struct {
94- Backend string `mapstructure:"backend"` // "pass", "sops", or "unsafe"
95- } `mapstructure:"secrets"`
96-
97- RegistryAuth struct {
98- Enabled bool `mapstructure:"enabled"`
99- Type string `mapstructure:"type"` // "password" or "token"
100- Username string `mapstructure:"username"`
101- Password string `mapstructure:"password"` // deprecated: use password_hash
102- PasswordHash string `mapstructure:"password_hash"` // path in secrets backend
103- TokenSecret string `mapstructure:"token_secret"` // path in secrets backend
104- TokenExpiry string `mapstructure:"token_expiry"` // e.g., "720h"
105- } `mapstructure:"registry_auth"`
93+ Auth struct {
94+ Enabled bool `mapstructure:"enabled"`
95+ Type string `mapstructure:"type"` // "password" or "token"
96+ SecretsBackend string `mapstructure:"secrets_backend"` // "pass", "sops", or "unsafe"
97+ Username string `mapstructure:"username"`
98+ Password string `mapstructure:"password"` // deprecated: use password_hash
99+ PasswordHash string `mapstructure:"password_hash"` // path in secrets backend
100+ TokenSecret string `mapstructure:"token_secret"` // path in secrets backend
101+ TokenExpiry string `mapstructure:"token_expiry"` // e.g., "720h", "30d"
102+ } `mapstructure:"auth"`
106103 }
107104
108105 // services holds all the services used by the application.
@@ -264,7 +261,7 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra
264261 }
265262
266263 // Generate internal registry credentials for loopback-only pulls
267- if cfg.RegistryAuth.Enabled {
264+ if cfg.Auth.Enabled {
268265 svc.internalRegUser, svc.internalRegPass, err = generateInternalRegistryAuth()
269266 if err != nil {
270267 return nil, log.WrapErr(err, "failed to generate internal registry credentials")
@@ -517,12 +514,12 @@ func GetInternalCredentials() (*InternalCredentials, error) {
517514
518515 // createAuthService creates the authentication service and token store.
519516 func createAuthService(ctx context.Context, cfg Config, log zerowrap.Logger) (out.TokenStore, *auth.Service, error) {
520- if !cfg.RegistryAuth.Enabled {
517+ if !cfg.Auth.Enabled {
521518 return nil, nil, nil
522519 }
523520
524- authType := resolveAuthType(cfg.RegistryAuth.Type)
525- backend := resolveSecretsBackend(cfg.Secrets.Backend)
521+ authType := resolveAuthType(cfg.Auth.Type)
522+ backend := resolveSecretsBackend(cfg.Auth.SecretsBackend)
526523 dataDir := resolveDataDir(cfg.Server.DataDir)
527524
528525 store, err := createTokenStore(authType, backend, dataDir, log)
@@ -586,9 +583,9 @@ func createTokenStore(authType domain.AuthType, backend domain.SecretsBackend, d
586583
587584 func buildAuthConfig(ctx context.Context, cfg Config, authType domain.AuthType, backend domain.SecretsBackend, dataDir string, log zerowrap.Logger) (auth.Config, error) {
588585 authConfig := auth.Config{
589- Enabled: cfg.RegistryAuth.Enabled,
586+ Enabled: cfg.Auth.Enabled,
590587 AuthType: authType,
591- Username: cfg.RegistryAuth.Username,
588+ Username: cfg.Auth.Username,
592589 }
593590
594591 switch authType {
@@ -597,6 +594,9 @@ func buildAuthConfig(ctx context.Context, cfg Config, authType domain.AuthType,
597594 if err != nil {
598595 return auth.Config{}, err
599596 }
597+ if hash == "" {
598+ return auth.Config{}, errAuthNotConfigured()
599+ }
600600 authConfig.PasswordHash = hash
601601 case domain.AuthTypeToken:
602602 secret, expiry, err := loadTokenConfig(ctx, cfg, backend, dataDir, log)
@@ -610,18 +610,23 @@ func buildAuthConfig(ctx context.Context, cfg Config, authType domain.AuthType,
610610 return authConfig, nil
611611 }
612612
613+// errAuthNotConfigured returns an error when auth is enabled but credentials are not configured.
614+func errAuthNotConfigured() error {
615+ return fmt.Errorf("auth is enabled by default, configure auth type and secrets backend. See: https://gordon.bnema.dev/docs/config/auth")
616+}
617+
613618 func loadPasswordHash(ctx context.Context, cfg Config, backend domain.SecretsBackend, dataDir string, log zerowrap.Logger) (string, error) {
614- if cfg.RegistryAuth.PasswordHash != "" {
615- hash, err := loadSecret(ctx, backend, cfg.RegistryAuth.PasswordHash, dataDir, log)
619+ if cfg.Auth.PasswordHash != "" {
620+ hash, err := loadSecret(ctx, backend, cfg.Auth.PasswordHash, dataDir, log)
616621 if err != nil {
617622 return "", log.WrapErr(err, "failed to load password hash")
618623 }
619624 return hash, nil
620625 }
621626
622- if cfg.RegistryAuth.Password != "" {
627+ if cfg.Auth.Password != "" {
623628 log.Warn().Msg("using plain password in config is deprecated, use password_hash with a secrets backend")
624- return cfg.RegistryAuth.Password, nil
629+ return cfg.Auth.Password, nil
625630 }
626631
627632 return "", nil
@@ -633,7 +638,7 @@ func loadTokenConfig(ctx context.Context, cfg Config, backend domain.SecretsBack
633638 return nil, 0, err
634639 }
635640
636- expiry, err := parseTokenExpiry(cfg.RegistryAuth.TokenExpiry)
641+ expiry, err := parseTokenExpiry(cfg.Auth.TokenExpiry)
637642 if err != nil {
638643 return nil, 0, err
639644 }
@@ -642,11 +647,11 @@ func loadTokenConfig(ctx context.Context, cfg Config, backend domain.SecretsBack
642647 }
643648
644649 func loadTokenSecret(ctx context.Context, cfg Config, backend domain.SecretsBackend, dataDir string, log zerowrap.Logger) ([]byte, error) {
645- if cfg.RegistryAuth.TokenSecret == "" {
650+ if cfg.Auth.TokenSecret == "" {
646651 return nil, fmt.Errorf("token_secret is required for token authentication")
647652 }
648653
649- secret, err := loadSecret(ctx, backend, cfg.RegistryAuth.TokenSecret, dataDir, log)
654+ secret, err := loadSecret(ctx, backend, cfg.Auth.TokenSecret, dataDir, log)
650655 if err != nil {
651656 return nil, log.WrapErr(err, "failed to load token secret")
652657 }
@@ -692,11 +697,11 @@ func loadSecret(ctx context.Context, backend domain.SecretsBackend, path, dataDi
692697 // createContainerService creates the container service with configuration.
693698 func createContainerService(v *viper.Viper, cfg Config, svc *services) *container.Service {
694699 containerConfig := container.Config{
695- RegistryAuthEnabled: cfg.RegistryAuth.Enabled,
700+ RegistryAuthEnabled: cfg.Auth.Enabled,
696701 RegistryDomain: cfg.Server.RegistryDomain,
697702 RegistryPort: cfg.Server.RegistryPort,
698- RegistryUsername: cfg.RegistryAuth.Username,
699- RegistryPassword: cfg.RegistryAuth.Password,
703+ RegistryUsername: cfg.Auth.Username,
704+ RegistryPassword: cfg.Auth.Password,
700705 InternalRegistryUsername: svc.internalRegUser,
701706 InternalRegistryPassword: svc.internalRegPass,
702707 PullPolicy: v.GetString("deploy.pull_policy"),
@@ -803,7 +808,7 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Ha
803808 middleware.RequestLogger(log),
804809 }
805810
806- if cfg.RegistryAuth.Enabled && svc.authSvc != nil {
811+ if cfg.Auth.Enabled && svc.authSvc != nil {
807812 internalAuth := middleware.InternalRegistryAuth{
808813 Username: svc.internalRegUser,
809814 Password: svc.internalRegPass,
@@ -1143,10 +1148,10 @@ func loadConfig(v *viper.Viper, configPath string) error {
11431148 v.SetDefault("logging.container_logs.max_backups", 3)
11441149 v.SetDefault("logging.container_logs.max_age", 28)
11451150 v.SetDefault("env.dir", "") // defaults to {data_dir}/env when empty
1146- v.SetDefault("secrets.backend", "unsafe")
1147- v.SetDefault("registry_auth.enabled", false)
1148- v.SetDefault("registry_auth.type", "password")
1149- v.SetDefault("registry_auth.token_expiry", "720h")
1151+ v.SetDefault("auth.enabled", true)
1152+ v.SetDefault("auth.type", "password")
1153+ v.SetDefault("auth.secrets_backend", "unsafe")
1154+ v.SetDefault("auth.token_expiry", "720h")
11501155 v.SetDefault("auto_route.enabled", false)
11511156 v.SetDefault("network_isolation.enabled", false)
11521157 v.SetDefault("network_isolation.network_prefix", "gordon")
M internal/boundaries/in/config.go +12 -0
@@ -56,4 +56,16 @@ type ConfigService interface {
5656
5757 // GetExternalRoutes returns all configured external routes.
5858 GetExternalRoutes() map[string]string
59+
60+ // GetAllAttachments returns all configured attachments.
61+ GetAllAttachments(ctx context.Context) map[string][]string
62+
63+ // GetAttachmentsFor returns attachments for a specific domain or network group.
64+ GetAttachmentsFor(ctx context.Context, domainOrGroup string) ([]string, error)
65+
66+ // AddAttachment adds an image to a domain/group's attachments.
67+ AddAttachment(ctx context.Context, domainOrGroup, image string) error
68+
69+ // RemoveAttachment removes an image from a domain/group's attachments.
70+ RemoveAttachment(ctx context.Context, domainOrGroup, image string) error
5971 }
M internal/boundaries/in/mocks/mock_config_service.go +247 -0
@@ -38,6 +38,69 @@ func (_m *MockConfigService) EXPECT() *MockConfigService_Expecter {
3838 return &MockConfigService_Expecter{mock: &_m.Mock}
3939 }
4040
41+// AddAttachment provides a mock function for the type MockConfigService
42+func (_mock *MockConfigService) AddAttachment(ctx context.Context, domainOrGroup string, image string) error {
43+ ret := _mock.Called(ctx, domainOrGroup, image)
44+
45+ if len(ret) == 0 {
46+ panic("no return value specified for AddAttachment")
47+ }
48+
49+ var r0 error
50+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) error); ok {
51+ r0 = returnFunc(ctx, domainOrGroup, image)
52+ } else {
53+ r0 = ret.Error(0)
54+ }
55+ return r0
56+}
57+
58+// MockConfigService_AddAttachment_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AddAttachment'
59+type MockConfigService_AddAttachment_Call struct {
60+ *mock.Call
61+}
62+
63+// AddAttachment is a helper method to define mock.On call
64+// - ctx context.Context
65+// - domainOrGroup string
66+// - image string
67+func (_e *MockConfigService_Expecter) AddAttachment(ctx interface{}, domainOrGroup interface{}, image interface{}) *MockConfigService_AddAttachment_Call {
68+ return &MockConfigService_AddAttachment_Call{Call: _e.mock.On("AddAttachment", ctx, domainOrGroup, image)}
69+}
70+
71+func (_c *MockConfigService_AddAttachment_Call) Run(run func(ctx context.Context, domainOrGroup string, image string)) *MockConfigService_AddAttachment_Call {
72+ _c.Call.Run(func(args mock.Arguments) {
73+ var arg0 context.Context
74+ if args[0] != nil {
75+ arg0 = args[0].(context.Context)
76+ }
77+ var arg1 string
78+ if args[1] != nil {
79+ arg1 = args[1].(string)
80+ }
81+ var arg2 string
82+ if args[2] != nil {
83+ arg2 = args[2].(string)
84+ }
85+ run(
86+ arg0,
87+ arg1,
88+ arg2,
89+ )
90+ })
91+ return _c
92+}
93+
94+func (_c *MockConfigService_AddAttachment_Call) Return(err error) *MockConfigService_AddAttachment_Call {
95+ _c.Call.Return(err)
96+ return _c
97+}
98+
99+func (_c *MockConfigService_AddAttachment_Call) RunAndReturn(run func(ctx context.Context, domainOrGroup string, image string) error) *MockConfigService_AddAttachment_Call {
100+ _c.Call.Return(run)
101+ return _c
102+}
103+
41104 // AddRoute provides a mock function for the type MockConfigService
42105 func (_mock *MockConfigService) AddRoute(ctx context.Context, route domain.Route) error {
43106 ret := _mock.Called(ctx, route)
@@ -95,6 +158,127 @@ func (_c *MockConfigService_AddRoute_Call) RunAndReturn(run func(ctx context.Con
95158 return _c
96159 }
97160
161+// GetAllAttachments provides a mock function for the type MockConfigService
162+func (_mock *MockConfigService) GetAllAttachments(ctx context.Context) map[string][]string {
163+ ret := _mock.Called(ctx)
164+
165+ if len(ret) == 0 {
166+ panic("no return value specified for GetAllAttachments")
167+ }
168+
169+ var r0 map[string][]string
170+ if returnFunc, ok := ret.Get(0).(func(context.Context) map[string][]string); ok {
171+ r0 = returnFunc(ctx)
172+ } else {
173+ if ret.Get(0) != nil {
174+ r0 = ret.Get(0).(map[string][]string)
175+ }
176+ }
177+ return r0
178+}
179+
180+// MockConfigService_GetAllAttachments_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetAllAttachments'
181+type MockConfigService_GetAllAttachments_Call struct {
182+ *mock.Call
183+}
184+
185+// GetAllAttachments is a helper method to define mock.On call
186+// - ctx context.Context
187+func (_e *MockConfigService_Expecter) GetAllAttachments(ctx interface{}) *MockConfigService_GetAllAttachments_Call {
188+ return &MockConfigService_GetAllAttachments_Call{Call: _e.mock.On("GetAllAttachments", ctx)}
189+}
190+
191+func (_c *MockConfigService_GetAllAttachments_Call) Run(run func(ctx context.Context)) *MockConfigService_GetAllAttachments_Call {
192+ _c.Call.Run(func(args mock.Arguments) {
193+ var arg0 context.Context
194+ if args[0] != nil {
195+ arg0 = args[0].(context.Context)
196+ }
197+ run(
198+ arg0,
199+ )
200+ })
201+ return _c
202+}
203+
204+func (_c *MockConfigService_GetAllAttachments_Call) Return(stringToStrings map[string][]string) *MockConfigService_GetAllAttachments_Call {
205+ _c.Call.Return(stringToStrings)
206+ return _c
207+}
208+
209+func (_c *MockConfigService_GetAllAttachments_Call) RunAndReturn(run func(ctx context.Context) map[string][]string) *MockConfigService_GetAllAttachments_Call {
210+ _c.Call.Return(run)
211+ return _c
212+}
213+
214+// GetAttachmentsFor provides a mock function for the type MockConfigService
215+func (_mock *MockConfigService) GetAttachmentsFor(ctx context.Context, domainOrGroup string) ([]string, error) {
216+ ret := _mock.Called(ctx, domainOrGroup)
217+
218+ if len(ret) == 0 {
219+ panic("no return value specified for GetAttachmentsFor")
220+ }
221+
222+ var r0 []string
223+ var r1 error
224+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) ([]string, error)); ok {
225+ return returnFunc(ctx, domainOrGroup)
226+ }
227+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) []string); ok {
228+ r0 = returnFunc(ctx, domainOrGroup)
229+ } else {
230+ if ret.Get(0) != nil {
231+ r0 = ret.Get(0).([]string)
232+ }
233+ }
234+ if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok {
235+ r1 = returnFunc(ctx, domainOrGroup)
236+ } else {
237+ r1 = ret.Error(1)
238+ }
239+ return r0, r1
240+}
241+
242+// MockConfigService_GetAttachmentsFor_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetAttachmentsFor'
243+type MockConfigService_GetAttachmentsFor_Call struct {
244+ *mock.Call
245+}
246+
247+// GetAttachmentsFor is a helper method to define mock.On call
248+// - ctx context.Context
249+// - domainOrGroup string
250+func (_e *MockConfigService_Expecter) GetAttachmentsFor(ctx interface{}, domainOrGroup interface{}) *MockConfigService_GetAttachmentsFor_Call {
251+ return &MockConfigService_GetAttachmentsFor_Call{Call: _e.mock.On("GetAttachmentsFor", ctx, domainOrGroup)}
252+}
253+
254+func (_c *MockConfigService_GetAttachmentsFor_Call) Run(run func(ctx context.Context, domainOrGroup string)) *MockConfigService_GetAttachmentsFor_Call {
255+ _c.Call.Run(func(args mock.Arguments) {
256+ var arg0 context.Context
257+ if args[0] != nil {
258+ arg0 = args[0].(context.Context)
259+ }
260+ var arg1 string
261+ if args[1] != nil {
262+ arg1 = args[1].(string)
263+ }
264+ run(
265+ arg0,
266+ arg1,
267+ )
268+ })
269+ return _c
270+}
271+
272+func (_c *MockConfigService_GetAttachmentsFor_Call) Return(strings []string, err error) *MockConfigService_GetAttachmentsFor_Call {
273+ _c.Call.Return(strings, err)
274+ return _c
275+}
276+
277+func (_c *MockConfigService_GetAttachmentsFor_Call) RunAndReturn(run func(ctx context.Context, domainOrGroup string) ([]string, error)) *MockConfigService_GetAttachmentsFor_Call {
278+ _c.Call.Return(run)
279+ return _c
280+}
281+
98282 // GetDataDir provides a mock function for the type MockConfigService
99283 func (_mock *MockConfigService) GetDataDir() string {
100284 ret := _mock.Called()
@@ -621,6 +805,69 @@ func (_c *MockConfigService_Load_Call) RunAndReturn(run func(ctx context.Context
621805 return _c
622806 }
623807
808+// RemoveAttachment provides a mock function for the type MockConfigService
809+func (_mock *MockConfigService) RemoveAttachment(ctx context.Context, domainOrGroup string, image string) error {
810+ ret := _mock.Called(ctx, domainOrGroup, image)
811+
812+ if len(ret) == 0 {
813+ panic("no return value specified for RemoveAttachment")
814+ }
815+
816+ var r0 error
817+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) error); ok {
818+ r0 = returnFunc(ctx, domainOrGroup, image)
819+ } else {
820+ r0 = ret.Error(0)
821+ }
822+ return r0
823+}
824+
825+// MockConfigService_RemoveAttachment_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RemoveAttachment'
826+type MockConfigService_RemoveAttachment_Call struct {
827+ *mock.Call
828+}
829+
830+// RemoveAttachment is a helper method to define mock.On call
831+// - ctx context.Context
832+// - domainOrGroup string
833+// - image string
834+func (_e *MockConfigService_Expecter) RemoveAttachment(ctx interface{}, domainOrGroup interface{}, image interface{}) *MockConfigService_RemoveAttachment_Call {
835+ return &MockConfigService_RemoveAttachment_Call{Call: _e.mock.On("RemoveAttachment", ctx, domainOrGroup, image)}
836+}
837+
838+func (_c *MockConfigService_RemoveAttachment_Call) Run(run func(ctx context.Context, domainOrGroup string, image string)) *MockConfigService_RemoveAttachment_Call {
839+ _c.Call.Run(func(args mock.Arguments) {
840+ var arg0 context.Context
841+ if args[0] != nil {
842+ arg0 = args[0].(context.Context)
843+ }
844+ var arg1 string
845+ if args[1] != nil {
846+ arg1 = args[1].(string)
847+ }
848+ var arg2 string
849+ if args[2] != nil {
850+ arg2 = args[2].(string)
851+ }
852+ run(
853+ arg0,
854+ arg1,
855+ arg2,
856+ )
857+ })
858+ return _c
859+}
860+
861+func (_c *MockConfigService_RemoveAttachment_Call) Return(err error) *MockConfigService_RemoveAttachment_Call {
862+ _c.Call.Return(err)
863+ return _c
864+}
865+
866+func (_c *MockConfigService_RemoveAttachment_Call) RunAndReturn(run func(ctx context.Context, domainOrGroup string, image string) error) *MockConfigService_RemoveAttachment_Call {
867+ _c.Call.Return(run)
868+ return _c
869+}
870+
624871 // RemoveRoute provides a mock function for the type MockConfigService
625872 func (_mock *MockConfigService) RemoveRoute(ctx context.Context, domain1 string) error {
626873 ret := _mock.Called(ctx, domain1)
M internal/domain/errors.go +6 -0
@@ -62,4 +62,10 @@ var (
6262 ErrInvalidPath = errors.New("invalid path format")
6363 ErrInsufficientScope = errors.New("insufficient scope for operation")
6464 ErrInvalidScope = errors.New("invalid scope format")
65+
66+ // Attachment errors
67+ ErrAttachmentNotFound = errors.New("attachment not found")
68+ ErrAttachmentExists = errors.New("attachment already exists")
69+ ErrAttachmentImageEmpty = errors.New("attachment image cannot be empty")
70+ ErrAttachmentTargetEmpty = errors.New("attachment target (domain or group) cannot be empty")
6571 )
M internal/testutils/helpers.go +3 -2
@@ -44,8 +44,9 @@ registry_port = 5000
4444 registry_domain = "registry.example.com"
4545 runtime = "docker"
4646
47-[registry_auth]
47+[auth]
4848 enabled = true
49+secrets_backend = "unsafe"
4950 username = "admin"
5051 password = "password123"
5152
@@ -72,7 +73,7 @@ level = "info"`
7273 registry_domain = "registry.example.com"
7374 runtime = "docker"
7475
75-[registry_auth]
76+[auth]
7677 enabled = false`
7778 case "invalid.toml":
7879 return `[server
M internal/usecase/config/service.go +156 -4
@@ -101,9 +101,9 @@ func (s *Service) loadConfigValues() Config {
101101 AutoRouteEnabled: s.viper.GetBool("auto_route.enabled"),
102102 NetworkIsolation: s.viper.GetBool("network_isolation.enabled"),
103103 NetworkPrefix: s.viper.GetString("network_isolation.network_prefix"),
104- RegistryAuthEnabled: s.viper.GetBool("registry_auth.enabled"),
105- RegistryAuthUsername: s.viper.GetString("registry_auth.username"),
106- RegistryAuthPassword: s.viper.GetString("registry_auth.password"),
104+ RegistryAuthEnabled: s.viper.GetBool("auth.enabled"),
105+ RegistryAuthUsername: s.viper.GetString("auth.username"),
106+ RegistryAuthPassword: s.viper.GetString("auth.password"),
107107 VolumeAutoCreate: s.viper.GetBool("volumes.auto_create"),
108108 VolumePrefix: s.viper.GetString("volumes.prefix"),
109109 VolumePreserve: s.viper.GetBool("volumes.preserve"),
@@ -333,8 +333,9 @@ func (s *Service) Save(ctx context.Context) error {
333333 s.mu.Lock()
334334 defer s.mu.Unlock()
335335
336- // Update viper with current routes
336+ // Update viper with current config
337337 s.viper.Set("routes", s.config.Routes)
338+ s.viper.Set("attachments", s.config.Attachments)
338339
339340 // Record save time to debounce file watcher events
340341 atomic.StoreInt64(&s.lastSaveTime, time.Now().UnixNano())
@@ -475,6 +476,7 @@ func (s *Service) GetNetworkGroups() map[string][]string {
475476 }
476477
477478 // GetAttachments returns attachment configuration.
479+// Deprecated: Use GetAllAttachments instead.
478480 func (s *Service) GetAttachments() map[string][]string {
479481 s.mu.RLock()
480482 defer s.mu.RUnlock()
@@ -486,6 +488,156 @@ func (s *Service) GetAttachments() map[string][]string {
486488 return result
487489 }
488490
491+// GetAllAttachments returns all configured attachments.
492+func (s *Service) GetAllAttachments(_ context.Context) map[string][]string {
493+ s.mu.RLock()
494+ defer s.mu.RUnlock()
495+
496+ result := make(map[string][]string)
497+ for k, v := range s.config.Attachments {
498+ result[k] = append([]string{}, v...)
499+ }
500+ return result
501+}
502+
503+// GetAttachmentsFor returns attachments for a specific domain or network group.
504+func (s *Service) GetAttachmentsFor(_ context.Context, domainOrGroup string) ([]string, error) {
505+ s.mu.RLock()
506+ defer s.mu.RUnlock()
507+
508+ images, exists := s.config.Attachments[domainOrGroup]
509+ if !exists {
510+ return nil, domain.ErrAttachmentNotFound
511+ }
512+
513+ return append([]string{}, images...), nil
514+}
515+
516+// AddAttachment adds an image to a domain/group's attachments.
517+func (s *Service) AddAttachment(ctx context.Context, domainOrGroup, image string) error {
518+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
519+ zerowrap.FieldLayer: "usecase",
520+ zerowrap.FieldUseCase: "AddAttachment",
521+ "target": domainOrGroup,
522+ "image": image,
523+ })
524+ log := zerowrap.FromCtx(ctx)
525+
526+ // Validate input
527+ if domainOrGroup == "" {
528+ return domain.ErrAttachmentTargetEmpty
529+ }
530+ if image == "" {
531+ return domain.ErrAttachmentImageEmpty
532+ }
533+
534+ s.mu.Lock()
535+
536+ // Initialize map if needed
537+ if s.config.Attachments == nil {
538+ s.config.Attachments = make(map[string][]string)
539+ }
540+
541+ // Check if already exists
542+ existing := s.config.Attachments[domainOrGroup]
543+ for _, img := range existing {
544+ if img == image {
545+ s.mu.Unlock()
546+ return domain.ErrAttachmentExists
547+ }
548+ }
549+
550+ // Store previous value for rollback
551+ previousImages := append([]string{}, existing...)
552+ hadKey := len(existing) > 0
553+
554+ // Add the image
555+ s.config.Attachments[domainOrGroup] = append(existing, image)
556+ s.mu.Unlock()
557+
558+ // Persist to disk - rollback on failure
559+ if err := s.Save(ctx); err != nil {
560+ log.Warn().Err(err).Msg("failed to persist attachment to disk, rolling back")
561+ s.mu.Lock()
562+ if hadKey {
563+ s.config.Attachments[domainOrGroup] = previousImages
564+ } else {
565+ delete(s.config.Attachments, domainOrGroup)
566+ }
567+ s.mu.Unlock()
568+ return err
569+ }
570+
571+ log.Info().Msg("attachment added to configuration")
572+ return nil
573+}
574+
575+// RemoveAttachment removes an image from a domain/group's attachments.
576+func (s *Service) RemoveAttachment(ctx context.Context, domainOrGroup, image string) error {
577+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
578+ zerowrap.FieldLayer: "usecase",
579+ zerowrap.FieldUseCase: "RemoveAttachment",
580+ "target": domainOrGroup,
581+ "image": image,
582+ })
583+ log := zerowrap.FromCtx(ctx)
584+
585+ // Validate input
586+ if domainOrGroup == "" {
587+ return domain.ErrAttachmentTargetEmpty
588+ }
589+ if image == "" {
590+ return domain.ErrAttachmentImageEmpty
591+ }
592+
593+ s.mu.Lock()
594+
595+ existing, exists := s.config.Attachments[domainOrGroup]
596+ if !exists {
597+ s.mu.Unlock()
598+ return domain.ErrAttachmentNotFound
599+ }
600+
601+ // Find and remove the image
602+ found := false
603+ newImages := make([]string, 0, len(existing))
604+ for _, img := range existing {
605+ if img == image {
606+ found = true
607+ } else {
608+ newImages = append(newImages, img)
609+ }
610+ }
611+
612+ if !found {
613+ s.mu.Unlock()
614+ return domain.ErrAttachmentNotFound
615+ }
616+
617+ // Store previous value for rollback
618+ previousImages := existing
619+
620+ // Update or remove the key
621+ if len(newImages) == 0 {
622+ delete(s.config.Attachments, domainOrGroup)
623+ } else {
624+ s.config.Attachments[domainOrGroup] = newImages
625+ }
626+ s.mu.Unlock()
627+
628+ // Persist to disk - rollback on failure
629+ if err := s.Save(ctx); err != nil {
630+ log.Warn().Err(err).Msg("failed to persist attachment removal to disk, rolling back")
631+ s.mu.Lock()
632+ s.config.Attachments[domainOrGroup] = previousImages
633+ s.mu.Unlock()
634+ return err
635+ }
636+
637+ log.Info().Msg("attachment removed from configuration")
638+ return nil
639+}
640+
489641 // GetExternalRoutes returns all configured external routes.
490642 func (s *Service) GetExternalRoutes() map[string]string {
491643 s.mu.RLock()
M internal/usecase/config/service_test.go +379 -5
@@ -28,9 +28,9 @@ func TestService_Load(t *testing.T) {
2828 v.Set("auto_route.enabled", true)
2929 v.Set("network_isolation.enabled", true)
3030 v.Set("network_isolation.network_prefix", "gordon")
31- v.Set("registry_auth.enabled", true)
32- v.Set("registry_auth.username", "admin")
33- v.Set("registry_auth.password", "secret")
31+ v.Set("auth.enabled", true)
32+ v.Set("auth.username", "admin")
33+ v.Set("auth.password", "secret")
3434 v.Set("volumes.auto_create", true)
3535 v.Set("volumes.prefix", "gordon")
3636 v.Set("volumes.preserve", false)
@@ -332,7 +332,7 @@ func TestService_GetNetworkGroups(t *testing.T) {
332332 func TestService_GetAttachments(t *testing.T) {
333333 v := viper.New()
334334 v.Set("attachments", map[string]interface{}{
335- "app.example.com": []interface{}{"redis:latest", "postgres:15"},
335+ "app.example.com": []interface{}{"redis:latest", "postgres:18"},
336336 })
337337
338338 eventBus := mocks.NewMockEventPublisher(t)
@@ -344,7 +344,381 @@ func TestService_GetAttachments(t *testing.T) {
344344 attachments := svc.GetAttachments()
345345
346346 assert.Len(t, attachments, 1)
347- assert.ElementsMatch(t, []string{"redis:latest", "postgres:15"}, attachments["app.example.com"])
347+ assert.ElementsMatch(t, []string{"redis:latest", "postgres:18"}, attachments["app.example.com"])
348+}
349+
350+func TestService_GetAllAttachments(t *testing.T) {
351+ t.Run("returns all attachments", func(t *testing.T) {
352+ v := viper.New()
353+ v.Set("attachments", map[string]interface{}{
354+ "app.example.com": []interface{}{"redis:latest", "postgres:18"},
355+ "api.example.com": []interface{}{"rabbitmq:3"},
356+ })
357+
358+ eventBus := mocks.NewMockEventPublisher(t)
359+ svc := NewService(v, eventBus)
360+ ctx := testContext()
361+
362+ _ = svc.Load(ctx)
363+
364+ attachments := svc.GetAllAttachments(ctx)
365+
366+ assert.Len(t, attachments, 2)
367+ assert.ElementsMatch(t, []string{"redis:latest", "postgres:18"}, attachments["app.example.com"])
368+ assert.ElementsMatch(t, []string{"rabbitmq:3"}, attachments["api.example.com"])
369+ })
370+
371+ t.Run("returns empty map when no attachments", func(t *testing.T) {
372+ v := viper.New()
373+ eventBus := mocks.NewMockEventPublisher(t)
374+ svc := NewService(v, eventBus)
375+ ctx := testContext()
376+
377+ _ = svc.Load(ctx)
378+
379+ attachments := svc.GetAllAttachments(ctx)
380+
381+ assert.Empty(t, attachments)
382+ })
383+
384+ t.Run("returns copy not reference", func(t *testing.T) {
385+ v := viper.New()
386+ v.Set("attachments", map[string]interface{}{
387+ "app.example.com": []interface{}{"redis:latest"},
388+ })
389+
390+ eventBus := mocks.NewMockEventPublisher(t)
391+ svc := NewService(v, eventBus)
392+ ctx := testContext()
393+
394+ _ = svc.Load(ctx)
395+
396+ attachments := svc.GetAllAttachments(ctx)
397+ // Modify the returned map
398+ attachments["app.example.com"] = append(attachments["app.example.com"], "postgres:18")
399+
400+ // Original should be unchanged
401+ original := svc.GetAllAttachments(ctx)
402+ assert.Len(t, original["app.example.com"], 1)
403+ })
404+}
405+
406+func TestService_GetAttachmentsFor(t *testing.T) {
407+ t.Run("existing domain", func(t *testing.T) {
408+ v := viper.New()
409+ v.Set("attachments", map[string]interface{}{
410+ "app.example.com": []interface{}{"redis:latest", "postgres:18"},
411+ })
412+
413+ eventBus := mocks.NewMockEventPublisher(t)
414+ svc := NewService(v, eventBus)
415+ ctx := testContext()
416+
417+ _ = svc.Load(ctx)
418+
419+ images, err := svc.GetAttachmentsFor(ctx, "app.example.com")
420+
421+ require.NoError(t, err)
422+ assert.ElementsMatch(t, []string{"redis:latest", "postgres:18"}, images)
423+ })
424+
425+ t.Run("existing network group", func(t *testing.T) {
426+ v := viper.New()
427+ v.Set("attachments", map[string]interface{}{
428+ "backend": []interface{}{"rabbitmq:3", "redis:latest"},
429+ })
430+
431+ eventBus := mocks.NewMockEventPublisher(t)
432+ svc := NewService(v, eventBus)
433+ ctx := testContext()
434+
435+ _ = svc.Load(ctx)
436+
437+ images, err := svc.GetAttachmentsFor(ctx, "backend")
438+
439+ require.NoError(t, err)
440+ assert.ElementsMatch(t, []string{"rabbitmq:3", "redis:latest"}, images)
441+ })
442+
443+ t.Run("non-existent target", func(t *testing.T) {
444+ v := viper.New()
445+ eventBus := mocks.NewMockEventPublisher(t)
446+ svc := NewService(v, eventBus)
447+ ctx := testContext()
448+
449+ _ = svc.Load(ctx)
450+
451+ images, err := svc.GetAttachmentsFor(ctx, "notfound.example.com")
452+
453+ assert.ErrorIs(t, err, domain.ErrAttachmentNotFound)
454+ assert.Nil(t, images)
455+ })
456+
457+ t.Run("returns copy not reference", func(t *testing.T) {
458+ v := viper.New()
459+ v.Set("attachments", map[string]interface{}{
460+ "app.example.com": []interface{}{"redis:latest"},
461+ })
462+
463+ eventBus := mocks.NewMockEventPublisher(t)
464+ svc := NewService(v, eventBus)
465+ ctx := testContext()
466+
467+ _ = svc.Load(ctx)
468+
469+ images, err := svc.GetAttachmentsFor(ctx, "app.example.com")
470+ require.NoError(t, err)
471+
472+ // Modify the returned slice (use _ to satisfy linter)
473+ _ = append(images, "postgres:18")
474+
475+ // Original should be unchanged
476+ original, _ := svc.GetAttachmentsFor(ctx, "app.example.com")
477+ assert.Len(t, original, 1)
478+ })
479+}
480+
481+func TestService_AddAttachment(t *testing.T) {
482+ t.Run("success - new target", func(t *testing.T) {
483+ tmpDir := t.TempDir()
484+ configFile := filepath.Join(tmpDir, "gordon.toml")
485+ err := os.WriteFile(configFile, []byte("[attachments]\n"), 0600)
486+ require.NoError(t, err)
487+
488+ v := viper.New()
489+ v.SetConfigFile(configFile)
490+ eventBus := mocks.NewMockEventPublisher(t)
491+ svc := NewService(v, eventBus)
492+ ctx := testContext()
493+
494+ _ = svc.Load(ctx)
495+
496+ err = svc.AddAttachment(ctx, "app.example.com", "postgres:18")
497+
498+ assert.NoError(t, err)
499+
500+ // Verify attachment was added
501+ config := svc.GetConfig()
502+ assert.Contains(t, config.Attachments["app.example.com"], "postgres:18")
503+ })
504+
505+ t.Run("success - existing target", func(t *testing.T) {
506+ tmpDir := t.TempDir()
507+ configFile := filepath.Join(tmpDir, "gordon.toml")
508+ err := os.WriteFile(configFile, []byte("[attachments]\n\"app.example.com\" = [\"redis:latest\"]\n"), 0600)
509+ require.NoError(t, err)
510+
511+ v := viper.New()
512+ v.SetConfigFile(configFile)
513+ v.Set("attachments", map[string]interface{}{
514+ "app.example.com": []interface{}{"redis:latest"},
515+ })
516+ eventBus := mocks.NewMockEventPublisher(t)
517+ svc := NewService(v, eventBus)
518+ ctx := testContext()
519+
520+ _ = svc.Load(ctx)
521+
522+ err = svc.AddAttachment(ctx, "app.example.com", "postgres:18")
523+
524+ assert.NoError(t, err)
525+
526+ // Verify attachment was added alongside existing
527+ config := svc.GetConfig()
528+ assert.ElementsMatch(t, []string{"redis:latest", "postgres:18"}, config.Attachments["app.example.com"])
529+ })
530+
531+ t.Run("duplicate attachment", func(t *testing.T) {
532+ v := viper.New()
533+ v.Set("attachments", map[string]interface{}{
534+ "app.example.com": []interface{}{"redis:latest"},
535+ })
536+ eventBus := mocks.NewMockEventPublisher(t)
537+ svc := NewService(v, eventBus)
538+ ctx := testContext()
539+
540+ _ = svc.Load(ctx)
541+
542+ err := svc.AddAttachment(ctx, "app.example.com", "redis:latest")
543+
544+ assert.ErrorIs(t, err, domain.ErrAttachmentExists)
545+ })
546+
547+ t.Run("empty target", func(t *testing.T) {
548+ v := viper.New()
549+ eventBus := mocks.NewMockEventPublisher(t)
550+ svc := NewService(v, eventBus)
551+ ctx := testContext()
552+
553+ err := svc.AddAttachment(ctx, "", "postgres:18")
554+
555+ assert.ErrorIs(t, err, domain.ErrAttachmentTargetEmpty)
556+ })
557+
558+ t.Run("empty image", func(t *testing.T) {
559+ v := viper.New()
560+ eventBus := mocks.NewMockEventPublisher(t)
561+ svc := NewService(v, eventBus)
562+ ctx := testContext()
563+
564+ err := svc.AddAttachment(ctx, "app.example.com", "")
565+
566+ assert.ErrorIs(t, err, domain.ErrAttachmentImageEmpty)
567+ })
568+
569+ t.Run("network group target", func(t *testing.T) {
570+ tmpDir := t.TempDir()
571+ configFile := filepath.Join(tmpDir, "gordon.toml")
572+ err := os.WriteFile(configFile, []byte("[attachments]\n"), 0600)
573+ require.NoError(t, err)
574+
575+ v := viper.New()
576+ v.SetConfigFile(configFile)
577+ eventBus := mocks.NewMockEventPublisher(t)
578+ svc := NewService(v, eventBus)
579+ ctx := testContext()
580+
581+ _ = svc.Load(ctx)
582+
583+ err = svc.AddAttachment(ctx, "backend", "rabbitmq:3")
584+
585+ assert.NoError(t, err)
586+
587+ config := svc.GetConfig()
588+ assert.Contains(t, config.Attachments["backend"], "rabbitmq:3")
589+ })
590+}
591+
592+func TestService_RemoveAttachment(t *testing.T) {
593+ t.Run("success - single attachment", func(t *testing.T) {
594+ tmpDir := t.TempDir()
595+ configFile := filepath.Join(tmpDir, "gordon.toml")
596+ err := os.WriteFile(configFile, []byte("[attachments]\n\"app.example.com\" = [\"redis:latest\"]\n"), 0600)
597+ require.NoError(t, err)
598+
599+ v := viper.New()
600+ v.SetConfigFile(configFile)
601+ v.Set("attachments", map[string]interface{}{
602+ "app.example.com": []interface{}{"redis:latest"},
603+ })
604+ eventBus := mocks.NewMockEventPublisher(t)
605+ svc := NewService(v, eventBus)
606+ ctx := testContext()
607+
608+ _ = svc.Load(ctx)
609+
610+ err = svc.RemoveAttachment(ctx, "app.example.com", "redis:latest")
611+
612+ assert.NoError(t, err)
613+
614+ // Verify target was removed (no attachments left)
615+ config := svc.GetConfig()
616+ _, exists := config.Attachments["app.example.com"]
617+ assert.False(t, exists)
618+ })
619+
620+ t.Run("success - multiple attachments", func(t *testing.T) {
621+ tmpDir := t.TempDir()
622+ configFile := filepath.Join(tmpDir, "gordon.toml")
623+ err := os.WriteFile(configFile, []byte("[attachments]\n\"app.example.com\" = [\"redis:latest\", \"postgres:18\"]\n"), 0600)
624+ require.NoError(t, err)
625+
626+ v := viper.New()
627+ v.SetConfigFile(configFile)
628+ v.Set("attachments", map[string]interface{}{
629+ "app.example.com": []interface{}{"redis:latest", "postgres:18"},
630+ })
631+ eventBus := mocks.NewMockEventPublisher(t)
632+ svc := NewService(v, eventBus)
633+ ctx := testContext()
634+
635+ _ = svc.Load(ctx)
636+
637+ err = svc.RemoveAttachment(ctx, "app.example.com", "redis:latest")
638+
639+ assert.NoError(t, err)
640+
641+ // Verify only redis was removed
642+ config := svc.GetConfig()
643+ assert.Equal(t, []string{"postgres:18"}, config.Attachments["app.example.com"])
644+ })
645+
646+ t.Run("target not found", func(t *testing.T) {
647+ v := viper.New()
648+ eventBus := mocks.NewMockEventPublisher(t)
649+ svc := NewService(v, eventBus)
650+ ctx := testContext()
651+
652+ _ = svc.Load(ctx)
653+
654+ err := svc.RemoveAttachment(ctx, "notfound.example.com", "redis:latest")
655+
656+ assert.ErrorIs(t, err, domain.ErrAttachmentNotFound)
657+ })
658+
659+ t.Run("image not found in target", func(t *testing.T) {
660+ v := viper.New()
661+ v.Set("attachments", map[string]interface{}{
662+ "app.example.com": []interface{}{"redis:latest"},
663+ })
664+ eventBus := mocks.NewMockEventPublisher(t)
665+ svc := NewService(v, eventBus)
666+ ctx := testContext()
667+
668+ _ = svc.Load(ctx)
669+
670+ err := svc.RemoveAttachment(ctx, "app.example.com", "postgres:18")
671+
672+ assert.ErrorIs(t, err, domain.ErrAttachmentNotFound)
673+ })
674+
675+ t.Run("empty target", func(t *testing.T) {
676+ v := viper.New()
677+ eventBus := mocks.NewMockEventPublisher(t)
678+ svc := NewService(v, eventBus)
679+ ctx := testContext()
680+
681+ err := svc.RemoveAttachment(ctx, "", "redis:latest")
682+
683+ assert.ErrorIs(t, err, domain.ErrAttachmentTargetEmpty)
684+ })
685+
686+ t.Run("empty image", func(t *testing.T) {
687+ v := viper.New()
688+ eventBus := mocks.NewMockEventPublisher(t)
689+ svc := NewService(v, eventBus)
690+ ctx := testContext()
691+
692+ err := svc.RemoveAttachment(ctx, "app.example.com", "")
693+
694+ assert.ErrorIs(t, err, domain.ErrAttachmentImageEmpty)
695+ })
696+
697+ t.Run("network group target", func(t *testing.T) {
698+ tmpDir := t.TempDir()
699+ configFile := filepath.Join(tmpDir, "gordon.toml")
700+ err := os.WriteFile(configFile, []byte("[attachments]\n\"backend\" = [\"rabbitmq:3\"]\n"), 0600)
701+ require.NoError(t, err)
702+
703+ v := viper.New()
704+ v.SetConfigFile(configFile)
705+ v.Set("attachments", map[string]interface{}{
706+ "backend": []interface{}{"rabbitmq:3"},
707+ })
708+ eventBus := mocks.NewMockEventPublisher(t)
709+ svc := NewService(v, eventBus)
710+ ctx := testContext()
711+
712+ _ = svc.Load(ctx)
713+
714+ err = svc.RemoveAttachment(ctx, "backend", "rabbitmq:3")
715+
716+ assert.NoError(t, err)
717+
718+ config := svc.GetConfig()
719+ _, exists := config.Attachments["backend"]
720+ assert.False(t, exists)
721+ })
348722 }
349723
350724 func TestService_GetExternalRoutes(t *testing.T) {
M internal/usecase/container/service.go +38 -2
@@ -937,8 +937,9 @@ func (s *Service) cleanupNetworkIfEmpty(ctx context.Context, networkName string)
937937 }
938938
939939 func (s *Service) deployAttachments(ctx context.Context, domainName, networkName string) error {
940- attachments, ok := s.config.Attachments[domainName]
941- if !ok {
940+ // Collect attachments from both domain-specific and network group configs
941+ attachments := s.resolveAttachmentsForDomain(domainName)
942+ if len(attachments) == 0 {
942943 return nil
943944 }
944945
@@ -952,6 +953,41 @@ func (s *Service) deployAttachments(ctx context.Context, domainName, networkName
952953 return nil
953954 }
954955
956+// resolveAttachmentsForDomain returns attachments for a domain by checking:
957+// 1. Direct domain attachments (attachments[domain])
958+// 2. Network group attachments (attachments[group] where domain is in network_groups[group])
959+func (s *Service) resolveAttachmentsForDomain(domainName string) []string {
960+ seen := make(map[string]bool)
961+ var result []string
962+
963+ // First, add domain-specific attachments
964+ if domainAttachments, ok := s.config.Attachments[domainName]; ok {
965+ for _, img := range domainAttachments {
966+ if !seen[img] {
967+ seen[img] = true
968+ result = append(result, img)
969+ }
970+ }
971+ }
972+
973+ // Then, find which network group this domain belongs to and add group attachments
974+ for groupName, domains := range s.config.NetworkGroups {
975+ if slices.Contains(domains, domainName) {
976+ if groupAttachments, ok := s.config.Attachments[groupName]; ok {
977+ for _, img := range groupAttachments {
978+ if !seen[img] {
979+ seen[img] = true
980+ result = append(result, img)
981+ }
982+ }
983+ }
984+ break // Domain can only be in one network group
985+ }
986+ }
987+
988+ return result
989+}
990+
955991 func (s *Service) getAttachmentsForDomain(ctx context.Context, domainName string) []domain.Attachment {
956992 containers, err := s.runtime.ListContainers(ctx, true)
957993 if err != nil {
M wiki/examples/production.md +5 -8
@@ -19,15 +19,12 @@ port = 8080 # Cloudflare forwards 443 → 8080
1919 registry_port = 5000
2020 gordon_domain = "gordon.company.com"
2121
22-# Use pass for secrets (recommended)
23-[secrets]
24-backend = "pass"
25-
26-# Token authentication for CI/CD
27-[registry_auth]
22+# Authentication with pass backend (recommended)
23+[auth]
2824 enabled = true
2925 type = "token"
30-token_secret = "gordon/registry/token_secret"
26+secrets_backend = "pass"
27+token_secret = "gordon/auth/token_secret"
3128
3229 # File-based logging with rotation
3330 [logging]
@@ -96,7 +93,7 @@ pass init your-gpg-key-id
9693
9794 ```bash
9895 # Generate random secret
99-openssl rand -base64 32 | pass insert -m gordon/registry/token_secret
96+openssl rand -base64 32 | pass insert -m gordon/auth/token_secret
10097 ```
10198
10299 ### 3. Generate CI Token
M wiki/guides/remote-cli.md +6 -3
@@ -206,6 +206,9 @@ These commands work with remote targeting:
206206 | `gordon routes add <domain> <image>` | Add a route |
207207 | `gordon routes remove <domain>` | Remove a route |
208208 | `gordon routes deploy <domain>` | Deploy/redeploy a route |
209+| `gordon attachments list [target]` | List all or targeted attachments |
210+| `gordon attachments add <target> <image>` | Add an attachment |
211+| `gordon attachments remove <target> <image>` | Remove an attachment |
209212 | `gordon secrets list <domain>` | List secrets for a domain |
210213 | `gordon secrets set <domain> KEY=value` | Set secrets |
211214 | `gordon secrets remove <domain> <key>` | Remove a secret |
@@ -432,6 +435,6 @@ See [Remote Logs Requirement](#remote-logs-requirement) for details.
432435
433436 ## Related
434437
435-- [CLI Commands](/docs/cli/commands.md)
436-- [Authentication](/docs/config/registry-auth.md)
437-- [Admin API](/docs/config/admin.md)
438+- [CLI Commands](/docs/cli/index.md)
439+- [Authentication](/docs/config/auth.md)
440+- [Attachments](/docs/config/attachments.md)
M wiki/guides/secrets-pass.md +8 -10
@@ -86,10 +86,10 @@ This creates `~/.password-store/`.
8686 ```bash
8787 # Registry password hash (bcrypt)
8888 # Generate with: htpasswd -nbB admin yourpassword | cut -d: -f2
89-pass insert gordon/registry/password_hash
89+pass insert gordon/auth/password_hash
9090
9191 # Token secret (random 32+ chars for JWT signing)
92-pass generate gordon/registry/token_secret 32
92+pass generate gordon/auth/token_secret 32
9393 ```
9494
9595 ## Gordon Configuration
@@ -97,15 +97,13 @@ pass generate gordon/registry/token_secret 32
9797 ### Enable Pass Backend
9898
9999 ```toml
100-[secrets]
101-backend = "pass"
102-
103-[registry_auth]
100+[auth]
104101 enabled = true
105102 type = "token"
103+secrets_backend = "pass"
106104 username = "admin"
107-password_hash = "gordon/registry/password_hash"
108-token_secret = "gordon/registry/token_secret"
105+password_hash = "gordon/auth/password_hash"
106+token_secret = "gordon/auth/token_secret"
109107 ```
110108
111109 ### Using Secrets in Environment Files
@@ -126,7 +124,7 @@ Recommended directory structure in pass:
126124 ```
127125 ~/.password-store/
128126 ├── gordon/
129-│ └── registry/
127+│ └── auth/
130128 │ ├── password_hash.gpg
131129 │ └── token_secret.gpg
132130 ├── myapp/
@@ -286,6 +284,6 @@ chmod 700 ~/.password-store
286284 ## Related
287285
288286 - [Secrets Configuration](/docs/config/secrets.md)
289-- [Registry Authentication](/docs/config/registry-auth.md)
287+- [Authentication](/docs/config/auth.md)
290288 - [Running in Containers](/wiki/guides/running-in-container.md)
291289 - [Using SOPS for Secrets](./secrets-sops.md)
M wiki/guides/secrets-sops.md +7 -9
@@ -136,7 +136,7 @@ sops edit secrets.yaml
136136 This opens your editor. Add secrets in YAML format:
137137
138138 ```yaml
139-registry:
139+auth:
140140 password_hash: "$2y$10$..."
141141 token_secret: "your-random-32-char-string-here"
142142 database:
@@ -156,7 +156,7 @@ cat secrets.yaml
156156 Output shows encrypted values:
157157
158158 ```yaml
159-registry:
159+auth:
160160 password_hash: ENC[AES256_GCM,data:...,iv:...,tag:...]
161161 token_secret: ENC[AES256_GCM,data:...,iv:...,tag:...]
162162 database:
@@ -195,15 +195,13 @@ sops edit secrets.yaml
195195 ### Enable SOPS Backend
196196
197197 ```toml
198-[secrets]
199-backend = "sops"
200-
201-[registry_auth]
198+[auth]
202199 enabled = true
203200 type = "token"
201+secrets_backend = "sops"
204202 username = "admin"
205-password_hash = "secrets.yaml:registry.password_hash"
206-token_secret = "secrets.yaml:registry.token_secret"
203+password_hash = "secrets.yaml:auth.password_hash"
204+token_secret = "secrets.yaml:auth.token_secret"
207205 ```
208206
209207 The path format is `file:key.path` where:
@@ -424,6 +422,6 @@ ls -la ~/.config/sops/age/keys.txt
424422 ## Related
425423
426424 - [Secrets Configuration](/docs/config/secrets.md)
427-- [Registry Authentication](/docs/config/registry-auth.md)
425+- [Authentication](/docs/config/auth.md)
428426 - [Running in Containers](/wiki/guides/running-in-container.md)
429427 - [Using Pass for Secrets](./secrets-pass.md)
M wiki/tutorials/postgres-service.md +1 -1
@@ -20,7 +20,7 @@ Add a PostgreSQL database as an attachment to your application.
2020 Create `postgres.Dockerfile`:
2121
2222 ```dockerfile
23-FROM postgres:15-alpine
23+FROM postgres:18-alpine
2424
2525 # Persistent data directory
2626 VOLUME ["/var/lib/postgresql/data"]

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