Core Concepts
Understanding how Gordon works and why it's designed this way.
Local-First Development
Your development machine likely has 8-16 cores and 16-32GB RAM. Your VPS has 1-2 cores and 1-4GB RAM. Why build containers on the weak machine?
Gordon flips the typical deployment model:
- Build locally where you have computing power
- Push the finished image to your VPS
- Gordon deploys automatically
This means faster builds, less VPS resource usage, and a simpler deployment workflow.
Push-to-Deploy
Gordon combines a Docker registry with automatic deployment:
┌──────────────┐ push ┌──────────────┐
│ docker build │ ──────────────>│ Gordon │
│ docker push │ │ Registry │
└──────────────┘ └──────┬───────┘
│
│ event: image.pushed
v
┌──────────────┐
│ Deploy │
│ Container │
└──────────────┘
When you push an image, Gordon:
- Stores the image in its registry
- Fires an
image.pushed event
- Looks up the route for that image
- Deploys a new container
- Updates the proxy routing
- Stops the old container
Zero-Downtime Updates
Gordon ensures your app stays available during updates:
- New container starts while old container is still running
- Health check waits for new container to be ready
- Traffic switches to the new container
- Old container stops after traffic has moved
Time ─────────────────────────────────────────────>
Old Container: [═══════════════════]
↓ stop
New Container: [═════════════════════════>
↑ start ↑ traffic routed
Deletion and Cleanup Lifecycle
Gordon separates configuration removal from destructive data cleanup:
- configured — an entity exists in Gordon configuration, such as a route in
gordon.toml.
- active — an entity has runtime state, such as a running Gordon-managed container.
- preserved — state intentionally kept after configuration removal, such as volumes or attachment data.
- orphaned — runtime state no longer referenced by configuration and requiring follow-up cleanup or diagnosis.
- purged — state explicitly deleted by a destructive cleanup command.
- retained — preserved state that Gordon reports so operators can decide whether to keep or purge it later.
Safe deletion is the default. Removing a route reconciles active route containers so the app is no longer served or restarted by Gordon's monitor. Stateful data such as volumes and attachment data is preserved unless a later purge command explicitly requests deletion.
Cleanup reports use additive-only JSON fields so humans and automation can rely on stable keys while Gordon adds more details over time.
Current runtime cleanup capabilities are intentionally conservative:
- Gordon can identify managed route and attachment containers by labels such as
gordon.managed, gordon.route, and gordon.attachment.
- Gordon can stop and remove containers.
- Existing volume attribution is limited for older volumes; Gordon may need naming heuristics when labels do not identify owner, mount path, or category.
- Updating runtime restart policy and relabeling existing volumes are runtime-dependent capabilities and should be treated as best-effort when added.
Routes
Routes map domains to container images:
[routes]
"app.example.com" = { image = "myapp:latest" }
"api.example.com" = { image = "myapi:v2.1.0" }
Route domains must be plain hostnames such as app.example.com. Gordon rejects http:// and https:// prefixes, .local and .internal suffixes, localhost names, and IP literals. Legacy http://... keys are still read for backward compatibility and rewritten on the next save.
When a request comes in for app.example.com, Gordon:
- Looks up the route configuration
- Finds the running container for
myapp:latest
- Proxies the request to that container
Route Domains
Routes use plain hostnames. HTTPS is enabled by default for routes; see the routes example with dev-app.example.com. Cloudflare is one deployment option, but Gordon can also terminate TLS with its built-in listener and certificates issued by Gordon's internal CA, with static certificates, or with public ACME certificates when [tls.acme] is enabled. ACME supports HTTP-01 and Cloudflare DNS-01 challenge modes.
[routes]
"dev-app.example.com" = { image = "internal-app:latest", https = false }
Network Isolation
Each app runs in its own isolated Docker network:
┌────────────────────────────────────────────────┐
│ gordon-app-mydomain-com │
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ App │───>│ Postgres │ │ Redis │ │
│ │ :3000 │ │ :5432 │ │ :6379 │ │
│ └──────────┘ └──────────┘ └──────────┘ │
│ │
└────────────────────────────────────────────────┘
Benefits:
- Containers can't access each other's services
- Services are only accessible by name within their network
- No port conflicts between apps
Attachments
Attachments are service dependencies for your apps:
[attachments]
"app.mydomain.com" = ["postgres:latest", "redis:latest"]
Gordon deploys attachments to the same network as your app. Services are accessible by their image name:
// In your app
const db = await connect("postgresql://postgres:5432/mydb");
const cache = await connect("redis://redis:6379");
Network Groups
Network groups allow multiple apps to share services:
[network_groups]
"backend" = ["app.mydomain.com", "api.mydomain.com"]
[attachments]
"backend" = ["shared-postgres:latest", "shared-redis:latest"]
Both app.mydomain.com and api.mydomain.com can access the shared services.
Volumes
Gordon automatically creates persistent storage from Dockerfile VOLUME directives:
FROM postgres:18
VOLUME ["/var/lib/postgresql/data"]
Volume behavior:
- auto_create: Volumes are created automatically (default: true)
- prefix: Volume names are prefixed with
gordon- (configurable)
- preserve: Volumes persist across container updates (default: true)
Environment Variables
Gordon loads environment variables from files based on the domain:
~/.gordon/env/
├── app_mydomain_com.env
├── api_mydomain_com.env
└── admin_mydomain_com.env
Domain dots become underscores: app.mydomain.com → app_mydomain_com.env
Variables are merged in order:
- Dockerfile
ENV directives (lowest priority)
.env file values (highest priority)
Secret Providers
Environment files support secret provider syntax:
# From Unix password manager (pass)
DATABASE_PASSWORD=${pass:myapp/db-password}
# From SOPS encrypted files
API_SECRET=${sops:secrets.yaml:api.secret}
Configuration Hot-Reload
Gordon watches its config file and reloads automatically:
- Edit
~/.config/gordon/gordon.toml
- Save the file
- Gordon reloads hot-reloaded settings such as attachments, network groups, and routes
- Containers and proxy config sync to match the new configuration
Route file edits now reload automatically again.
You can also trigger a reload:
gordon reload
gordon reload sends SIGUSR1 to the running Gordon process.
Event System
Gordon uses an internal event system for coordination:
| Event |
Trigger |
Action |
image.pushed |
Image pushed to registry |
Deploy container |
config.reload |
Config file changed or gordon reload sends SIGUSR1 |
Reload config, sync containers, and refresh proxy state |
manual.deploy |
gordon deploy <domain> command |
Deploy specific route |
container.deployed |
Container started |
Update proxy cache |
Backups and Recovery
Gordon can run logical PostgreSQL backups for attachment containers.
Current design:
- Detect PostgreSQL attachments attached to a route
- Execute
pg_dump -Fc through Gordon runtime operations
- Store backup artifacts on local filesystem storage
- Expose backup actions through admin API and CLI
This is intentionally scoped for operational safety and predictable behavior.
Future extensions can add physical backups, PITR, and remote object storage adapters.
For configuration details and usage examples, see the Backups Configuration guide, Backup CLI reference, and Configuration Reference.
Container Labels
Gordon uses labels to track managed containers:
| Label |
Purpose |
gordon.managed=true |
Identifies Gordon-managed containers |
gordon.domain |
Domain this container serves |
gordon.image |
Image name and tag |
gordon.route |
Route this container handles |
gordon.attachment=true |
Container is an attachment service |
gordon.attached-to |
Which route this attachment serves |
Proxy Port Selection
When a container exposes multiple ports, Gordon needs to know which one serves HTTP:
FROM gitea/gitea:latest
LABEL gordon.proxy.port=3000 # Route HTTP to port 3000
EXPOSE 22 # SSH
EXPOSE 3000 # HTTP
Without the label, Gordon uses the first exposed port.
source
1# Core Concepts
2
3Understanding how Gordon works and why it's designed this way.
4
5## Local-First Development
6
7Your development machine likely has 8-16 cores and 16-32GB RAM. Your VPS has 1-2 cores and 1-4GB RAM. Why build containers on the weak machine?
8
9Gordon flips the typical deployment model:
10
111. **Build locally** where you have computing power
122. **Push the finished image** to your VPS
133. **Gordon deploys automatically**
14
15This means faster builds, less VPS resource usage, and a simpler deployment workflow.
16
17## Push-to-Deploy
18
19Gordon combines a Docker registry with automatic deployment:
20
21```
22┌──────────────┐ push ┌──────────────┐
23│ docker build │ ──────────────>│ Gordon │
24│ docker push │ │ Registry │
25└──────────────┘ └──────┬───────┘
26 │
27 │ event: image.pushed
28 v
29 ┌──────────────┐
30 │ Deploy │
31 │ Container │
32 └──────────────┘
33```
34
35When you push an image, Gordon:
36
371. Stores the image in its registry
382. Fires an `image.pushed` event
393. Looks up the route for that image
404. Deploys a new container
415. Updates the proxy routing
426. Stops the old container
43
44## Zero-Downtime Updates
45
46Gordon ensures your app stays available during updates:
47
481. **New container starts** while old container is still running
492. **Health check** waits for new container to be ready
503. **Traffic switches** to the new container
514. **Old container stops** after traffic has moved
52
53```
54Time ─────────────────────────────────────────────>
55
56Old Container: [═══════════════════]
57 ↓ stop
58New Container: [═════════════════════════>
59 ↑ start ↑ traffic routed
60```
61
62## Deletion and Cleanup Lifecycle
63
64Gordon separates configuration removal from destructive data cleanup:
65
66- **configured** — an entity exists in Gordon configuration, such as a route in `gordon.toml`.
67- **active** — an entity has runtime state, such as a running Gordon-managed container.
68- **preserved** — state intentionally kept after configuration removal, such as volumes or attachment data.
69- **orphaned** — runtime state no longer referenced by configuration and requiring follow-up cleanup or diagnosis.
70- **purged** — state explicitly deleted by a destructive cleanup command.
71- **retained** — preserved state that Gordon reports so operators can decide whether to keep or purge it later.
72
73Safe deletion is the default. Removing a route reconciles active route containers so the app is no longer served or restarted by Gordon's monitor. Stateful data such as volumes and attachment data is preserved unless a later purge command explicitly requests deletion.
74
75Cleanup reports use additive-only JSON fields so humans and automation can rely on stable keys while Gordon adds more details over time.
76
77Current runtime cleanup capabilities are intentionally conservative:
78
79- Gordon can identify managed route and attachment containers by labels such as `gordon.managed`, `gordon.route`, and `gordon.attachment`.
80- Gordon can stop and remove containers.
81- Existing volume attribution is limited for older volumes; Gordon may need naming heuristics when labels do not identify owner, mount path, or category.
82- Updating runtime restart policy and relabeling existing volumes are runtime-dependent capabilities and should be treated as best-effort when added.
83
84## Routes
85
86Routes map domains to container images:
87
88```toml
89[routes]
90"app.example.com" = { image = "myapp:latest" }
91"api.example.com" = { image = "myapi:v2.1.0" }
92```
93
94Route domains must be plain hostnames such as `app.example.com`. Gordon rejects `http://` and `https://` prefixes, `.local` and `.internal` suffixes, localhost names, and IP literals. Legacy `http://...` keys are still read for backward compatibility and rewritten on the next save.
95
96When a request comes in for `app.example.com`, Gordon:
97
981. Looks up the route configuration
992. Finds the running container for `myapp:latest`
1003. Proxies the request to that container
101
102### Route Domains
103
104Routes use plain hostnames. HTTPS is enabled by default for routes; see the [routes example](./config/routes.md#development-setup) with `dev-app.example.com`. Cloudflare is one deployment option, but Gordon can also terminate TLS with its built-in listener and certificates issued by Gordon's internal CA, with static certificates, or with public ACME certificates when `[tls.acme]` is enabled. ACME supports HTTP-01 and Cloudflare DNS-01 challenge modes.
105
106```toml
107[routes]
108"dev-app.example.com" = { image = "internal-app:latest", https = false }
109```
110
111## Network Isolation
112
113Each app runs in its own isolated Docker network:
114
115```
116┌────────────────────────────────────────────────┐
117│ gordon-app-mydomain-com │
118│ │
119│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
120│ │ App │───>│ Postgres │ │ Redis │ │
121│ │ :3000 │ │ :5432 │ │ :6379 │ │
122│ └──────────┘ └──────────┘ └──────────┘ │
123│ │
124└────────────────────────────────────────────────┘
125```
126
127Benefits:
128
129- Containers can't access each other's services
130- Services are only accessible by name within their network
131- No port conflicts between apps
132
133## Attachments
134
135Attachments are service dependencies for your apps:
136
137```toml
138[attachments]
139"app.mydomain.com" = ["postgres:latest", "redis:latest"]
140```
141
142Gordon deploys attachments to the same network as your app. Services are accessible by their image name:
143
144```javascript
145// In your app
146const db = await connect("postgresql://postgres:5432/mydb");
147const cache = await connect("redis://redis:6379");
148```
149
150## Network Groups
151
152Network groups allow multiple apps to share services:
153
154```toml
155[network_groups]
156"backend" = ["app.mydomain.com", "api.mydomain.com"]
157
158[attachments]
159"backend" = ["shared-postgres:latest", "shared-redis:latest"]
160```
161
162Both `app.mydomain.com` and `api.mydomain.com` can access the shared services.
163
164## Volumes
165
166Gordon automatically creates persistent storage from Dockerfile `VOLUME` directives:
167
168```dockerfile
169FROM postgres:18
170VOLUME ["/var/lib/postgresql/data"]
171```
172
173Volume behavior:
174
175- **auto_create**: Volumes are created automatically (default: true)
176- **prefix**: Volume names are prefixed with `gordon-` (configurable)
177- **preserve**: Volumes persist across container updates (default: true)
178
179## Environment Variables
180
181Gordon loads environment variables from files based on the domain:
182
183```
184~/.gordon/env/
185├── app_mydomain_com.env
186├── api_mydomain_com.env
187└── admin_mydomain_com.env
188```
189
190Domain dots become underscores: `app.mydomain.com` → `app_mydomain_com.env`
191
192Variables are merged in order:
193
1941. Dockerfile `ENV` directives (lowest priority)
1952. `.env` file values (highest priority)
196
197### Secret Providers
198
199Environment files support secret provider syntax:
200
201```bash
202# From Unix password manager (pass)
203DATABASE_PASSWORD=${pass:myapp/db-password}
204
205# From SOPS encrypted files
206API_SECRET=${sops:secrets.yaml:api.secret}
207```
208
209## Configuration Hot-Reload
210
211Gordon watches its config file and reloads automatically:
212
2131. Edit `~/.config/gordon/gordon.toml`
2142. Save the file
2153. Gordon reloads hot-reloaded settings such as attachments, network groups, and routes
2164. Containers and proxy config sync to match the new configuration
217
218Route file edits now reload automatically again.
219
220You can also trigger a reload:
221
222```bash
223gordon reload
224```
225
226`gordon reload` sends `SIGUSR1` to the running Gordon process.
227
228## Event System
229
230Gordon uses an internal event system for coordination:
231
232| Event | Trigger | Action |
233|-------|---------|--------|
234| `image.pushed` | Image pushed to registry | Deploy container |
235| `config.reload` | Config file changed or `gordon reload` sends `SIGUSR1` | Reload config, sync containers, and refresh proxy state |
236| `manual.deploy` | `gordon deploy <domain>` command | Deploy specific route |
237| `container.deployed` | Container started | Update proxy cache |
238
239## Backups and Recovery
240
241Gordon can run logical PostgreSQL backups for attachment containers.
242
243Current design:
244
2451. Detect PostgreSQL attachments attached to a route
2462. Execute `pg_dump -Fc` through Gordon runtime operations
2473. Store backup artifacts on local filesystem storage
2484. Expose backup actions through admin API and CLI
249
250This is intentionally scoped for operational safety and predictable behavior.
251Future extensions can add physical backups, PITR, and remote object storage adapters.
252For configuration details and usage examples, see the [Backups Configuration guide](./config/backups.md), [Backup CLI reference](./cli/backup.md), and [Configuration Reference](./config/reference.md).
253
254## Container Labels
255
256Gordon uses labels to track managed containers:
257
258| Label | Purpose |
259|-------|---------|
260| `gordon.managed=true` | Identifies Gordon-managed containers |
261| `gordon.domain` | Domain this container serves |
262| `gordon.image` | Image name and tag |
263| `gordon.route` | Route this container handles |
264| `gordon.attachment=true` | Container is an attachment service |
265| `gordon.attached-to` | Which route this attachment serves |
266
267## Proxy Port Selection
268
269When a container exposes multiple ports, Gordon needs to know which one serves HTTP:
270
271```dockerfile
272FROM gitea/gitea:latest
273LABEL gordon.proxy.port=3000 # Route HTTP to port 3000
274EXPOSE 22 # SSH
275EXPOSE 3000 # HTTP
276```
277
278Without the label, Gordon uses the first exposed port.
279
280## Related
281
282- [Configuration Reference](./config/index.md)
283- [Docker Labels Reference](./reference/docker-labels.md)
284- [Environment Variables](./config/env.md)