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 registry
- Apply the app manifest declaring the image
- Deploy to activate it
This means faster builds, less VPS resource usage, and a simpler deployment workflow.
Push, Apply, Deploy
Gordon combines a Docker registry with a declarative app runtime:
┌──────────────┐ push ┌──────────────┐
│ docker build │ ──────────────>│ Gordon │
│ docker push │ (OCI only, │ Registry │
└──────────────┘ never deploys)└──────┬───────┘
│
┌───────────────────┴───────────────────┐
│ gordon apps apply --file blog.toml │ desired state
│ gordon apps deploy blog │ activation
└───────────────────┬───────────────────┘
v
┌──────────────┐
│ App │
│ Containers │
└──────────────┘
Pushing an image only stores it. Nothing runs, no route is created, no manifest is modified. Activation is always an explicit gordon apps deploy.
Declarative Apps
One standalone TOML file defines one globally named app with one or more explicitly named image-backed services. The app owns its entrypoints and routes; containers are replaceable runtime instances, not public identities.
name = "blog"
[env]
APP_ENV = "production" # app-wide public env, injected into all services
[services.web]
image = "gordon.mydomain.com/blog:1.4.2"
[[services.web.http]]
host = "blog.mydomain.com"
port = 3000
[services.web.env] # public env for this service only
LOG_LEVEL = "info"
[services.web.secrets] # ENV name -> secret name (values stay in pass)
DATABASE_URL = "database-url"
apps apply --file FILE validates and persists desired configuration only.
deploy APP activates it. apply --deploy chains both using exactly the revision accepted by apply.
--dry-run validates and previews without persistence or runtime effects.
- Version tags are recommended, not constrained to SemVer.
latest remains valid; explicit deploy re-resolves mutable tags while restart uses the active pinned content.
- The file is intended for Git: it must never contain secret values. Confidential values use
secrets; public values use [env] or [services.<name>.env].
- Staging is an ordinary app in another TOML file. There is no pin, no preview environments, and no historical rollback command.
Updates
Gordon deploys an app's services one at a time in sorted order. A deploy may cause a short service interruption; there is no zero-downtime promise for app services. Gordon never runs two Gordon-managed generations of the same service at the same time.
Every replaced service follows the same flow: preflight completes before anything is disrupted (image resolution and pull, secrets, volumes, networks, bind policy, reservations); the service is withdrawn from traffic; the old container is stopped and removed; the new container is created and started; the declared readiness probe runs; the new ACTIVE state is published; and traffic is rebuilt and published for the new container.
Preflight ─► withdraw traffic ─► stop + remove old ─► start new ─► readiness ─► publish ACTIVE ─► route traffic
If preflight fails, the running service is left untouched. If replacement fails after the old container was removed, the failure is explicit: Gordon does not recreate the old container during that operation and does not promise automatic data rollback. A stateful service's recovery inhibition stays in place until its never-published candidate is confirmed removed; a later boot/start/restart then clears it and rebuilds the generation ACTIVE records from its pinned digest, while a candidate that cannot be removed fails recovery closed.
Deployment stops at the first service failure: services already deployed in that run are kept and are not rolled back. This flow covers every service, including TCP, UDP, mixed, and volume-owning services. An open UDP socket is not application readiness, and Gordon never restarts an old volume-owning image automatically after a replacement may have written data.
A deploy interrupted after the replacement container was created but before it was published leaves the failure explicit: the candidate is recorded in the deployment journal, and Gordon removes it before it creates or rebuilds any generation of that app — at boot and before every mutation — so two generations of one service never run together. An interrupted operation is finalized instead of staying in flight: as a failure, or as the success it was when every step had already completed. If the recorded candidate cannot be removed, reconciliation fails closed: the operation is not finalized, stays the latest journal, and every later mutation is refused so a newer operation can never mask the orphan. The next recovery pass republishes routing within its 15-second cadence if the final traffic publication was the step that failed.
Deploy is the only command that applies changes: it recreates a service whose image digest, spec, app environment, or secret values changed, and keeps an unchanged one, reported unchanged.
gordon apps restart restarts the same container and applies nothing: it withdraws traffic, restarts the pinned container, verifies readiness, and republishes traffic. No second container is created. When the recorded container is gone, restart rebuilds the service from its pinned ACTIVE digest and publishes it.
Deletion and Cleanup Lifecycle
Gordon separates workload removal from destructive data cleanup:
- desired — the persisted manifest revision waiting to be activated.
- active — the pinned, running definition (never inferred from containers).
- stopped — durable stopped intent: workloads are down, data preserved, reboot keeps them stopped.
- retained — volumes and secrets kept after app removal under the old internal UUID, visible but never implicitly adopted by a new app reusing the name.
Safe removal is the default. gordon apps remove withdraws workloads and frees the name; volumes and secrets are retained as owned orphans. There is deliberately no purge: destructive volume deletion requires a separately accepted destructive-action contract. Ordinary apply/deploy/restart/stop/remove never delete user volumes.
App HTTP Hosts
An app's HTTP interfaces declare the hosts Gordon serves:
[[services.web.http]]
host = "app.example.com"
port = 3000
When a request comes in for app.example.com, Gordon:
- Looks up the host in the ACTIVE projection (merged with installation external routes)
- Finds the recorded loopback backend for that host (never a container IP — rootless-first)
- Proxies the request to that backend
Hostnames must be plain hostnames. https behavior per host follows the tls mode (auto, always, never).
Networks
Each app gets a private network automatically. Services can additionally join named shared networks, created and reused only within verified Gordon ownership:
[[network.shared]]
network = "backend"
services = ["web", "worker"]
Deploy adds AND removes memberships without disconnecting unrelated services. Short DNS names resolve privately; app-qualified aliases apply on shared networks.
Volumes
Services declare persistent storage in the app manifest. Every Dockerfile VOLUME path must be declared explicitly; deployment rejects unmanaged image volumes:
[[services.web.volume]]
name = "web-data"
path = "/data"
Docker/Podman own the named volumes; Gordon records app UUID, service, and logical volume ownership. App manifests allow neither bind mounts nor service-shared volumes. Replacement and restart reuse volumes. Removing a service or app retains its volumes under the original app UUID, and a new app reusing the public name never adopts them.
Use gordon volumes prune --dry-run to inspect Gordon's ownership-aware plan. Do not use docker volume prune or an equivalent runtime command for Gordon data: it bypasses Gordon's retention checks and can delete unmounted retained volumes.
Environment and Secrets
App-wide public env is declared in the manifest:
[env]
APP_ENV = "production"
Public values for one service use [services.<name>.env] and override [env] keys for that service. Confidential values use secrets:
[services.web.env]
LOG_LEVEL = "info"
[services.web.secrets]
DATABASE_URL = "database-url"
Secret values stay in pass under gordon/apps/<uuid>/<service>/<name>, keyed by the stable internal UUID so a removed app's secrets are never adopted by a new app reusing the name. Running containers keep the values they were created with; gordon apps deploy applies new values. restart does not. Write values with gordon apps secrets set; only key names are ever echoed back, never values.
Installation Reload
Gordon watches gordon.toml and reloads installation-only settings (entrypoints, TLS, limits, external routes). Reload never activates pending app desired state, never re-resolves image tags, and never starts app workloads.
gordon daemon reload
gordon daemon reload sends SIGUSR1 to the running Gordon process. gordon.toml holds installation settings only — app workloads live in app files.
Event System
Gordon uses an internal event system for coordination. Registry storage events no longer deploy anything: push events never deploy, create routes, or modify manifests.
Backups and Recovery
Gordon runs PostgreSQL logical backups and volume archives to S3 for explicitly declared app targets. Declarations live in the app manifest ([[services.<name>.database]], [services.<name>.backup]); storage infrastructure (destinations, schedules, retention) stays global.
Stored backups are never deleted when declarations change — only schedules update on deploy.
For configuration details and usage examples, see the Backups Configuration guide, Backup CLI reference, and Configuration Reference.
Container Identity
Gordon stamps app ownership labels on every container and volume it creates:
| Label |
Purpose |
gordon.managed=true |
Identifies Gordon-managed resources |
gordon.app |
App public name |
gordon.app.service |
Service name |
gordon.app.revision |
Active revision that created it |
Queries by logical identity use labels, never name parsing. Unknown resources (no labels, old labels) are preserved, never adopted or deleted.
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 registry
133. **Apply the app manifest** declaring the image
144. **Deploy** to activate it
15
16This means faster builds, less VPS resource usage, and a simpler deployment workflow.
17
18## Push, Apply, Deploy
19
20Gordon combines a Docker registry with a declarative app runtime:
21
22```
23┌──────────────┐ push ┌──────────────┐
24│ docker build │ ──────────────>│ Gordon │
25│ docker push │ (OCI only, │ Registry │
26└──────────────┘ never deploys)└──────┬───────┘
27 │
28 ┌───────────────────┴───────────────────┐
29 │ gordon apps apply --file blog.toml │ desired state
30 │ gordon apps deploy blog │ activation
31 └───────────────────┬───────────────────┘
32 v
33 ┌──────────────┐
34 │ App │
35 │ Containers │
36 └──────────────┘
37```
38
39Pushing an image only stores it. Nothing runs, no route is created, no manifest is modified. Activation is always an explicit `gordon apps deploy`.
40
41## Declarative Apps
42
43One standalone TOML file defines one globally named app with one or more explicitly named image-backed services. The app owns its entrypoints and routes; containers are replaceable runtime instances, not public identities.
44
45```toml
46name = "blog"
47
48[env]
49APP_ENV = "production" # app-wide public env, injected into all services
50
51[services.web]
52image = "gordon.mydomain.com/blog:1.4.2"
53
54[[services.web.http]]
55host = "blog.mydomain.com"
56port = 3000
57
58[services.web.env] # public env for this service only
59LOG_LEVEL = "info"
60
61[services.web.secrets] # ENV name -> secret name (values stay in pass)
62DATABASE_URL = "database-url"
63```
64
65- `apps apply --file FILE` validates and persists desired configuration only.
66- `deploy APP` activates it. `apply --deploy` chains both using exactly the revision accepted by apply.
67- `--dry-run` validates and previews without persistence or runtime effects.
68- Version tags are recommended, not constrained to SemVer. `latest` remains valid; explicit deploy re-resolves mutable tags while restart uses the active pinned content.
69- The file is intended for Git: it must never contain secret values. Confidential values use `secrets`; public values use `[env]` or `[services.<name>.env]`.
70- Staging is an ordinary app in another TOML file. There is no pin, no preview environments, and no historical rollback command.
71
72## Updates
73
74Gordon deploys an app's services one at a time in sorted order. A deploy may cause a short service interruption; there is no zero-downtime promise for app services. Gordon never runs two Gordon-managed generations of the same service at the same time.
75
76Every replaced service follows the same flow: preflight completes before anything is disrupted (image resolution and pull, secrets, volumes, networks, bind policy, reservations); the service is withdrawn from traffic; the old container is stopped and removed; the new container is created and started; the declared readiness probe runs; the new `ACTIVE` state is published; and traffic is rebuilt and published for the new container.
77
78```
79Preflight ─► withdraw traffic ─► stop + remove old ─► start new ─► readiness ─► publish ACTIVE ─► route traffic
80```
81
82If preflight fails, the running service is left untouched. If replacement fails after the old container was removed, the failure is explicit: Gordon does not recreate the old container during that operation and does not promise automatic data rollback. A stateful service's recovery inhibition stays in place until its never-published candidate is confirmed removed; a later boot/start/restart then clears it and rebuilds the generation `ACTIVE` records from its pinned digest, while a candidate that cannot be removed fails recovery closed.
83
84Deployment stops at the first service failure: services already deployed in that run are kept and are not rolled back. This flow covers every service, including TCP, UDP, mixed, and volume-owning services. An open UDP socket is not application readiness, and Gordon never restarts an old volume-owning image automatically after a replacement may have written data.
85
86A deploy interrupted after the replacement container was created but before it was published leaves the failure explicit: the candidate is recorded in the deployment journal, and Gordon removes it before it creates or rebuilds any generation of that app — at boot and before every mutation — so two generations of one service never run together. An interrupted operation is finalized instead of staying in flight: as a failure, or as the success it was when every step had already completed. If the recorded candidate cannot be removed, reconciliation fails closed: the operation is not finalized, stays the latest journal, and every later mutation is refused so a newer operation can never mask the orphan. The next recovery pass republishes routing within its 15-second cadence if the final traffic publication was the step that failed.
87
88Deploy is the only command that applies changes: it recreates a service whose image digest, spec, app environment, or secret values changed, and keeps an unchanged one, reported `unchanged`.
89
90`gordon apps restart` restarts the same container and applies nothing: it withdraws traffic, restarts the pinned container, verifies readiness, and republishes traffic. No second container is created. When the recorded container is gone, restart rebuilds the service from its pinned `ACTIVE` digest and publishes it.
91
92## Deletion and Cleanup Lifecycle
93
94Gordon separates workload removal from destructive data cleanup:
95
96- **desired** — the persisted manifest revision waiting to be activated.
97- **active** — the pinned, running definition (never inferred from containers).
98- **stopped** — durable stopped intent: workloads are down, data preserved, reboot keeps them stopped.
99- **retained** — volumes and secrets kept after app removal under the old internal UUID, visible but never implicitly adopted by a new app reusing the name.
100
101Safe removal is the default. `gordon apps remove` withdraws workloads and frees the name; volumes and secrets are retained as owned orphans. There is deliberately no `purge`: destructive volume deletion requires a separately accepted destructive-action contract. Ordinary apply/deploy/restart/stop/remove never delete user volumes.
102
103## App HTTP Hosts
104
105An app's HTTP interfaces declare the hosts Gordon serves:
106
107```toml
108[[services.web.http]]
109host = "app.example.com"
110port = 3000
111```
112
113When a request comes in for `app.example.com`, Gordon:
114
1151. Looks up the host in the ACTIVE projection (merged with installation external routes)
1162. Finds the recorded loopback backend for that host (never a container IP — rootless-first)
1173. Proxies the request to that backend
118
119Hostnames must be plain hostnames. `https` behavior per host follows the `tls` mode (`auto`, `always`, `never`).
120
121## Networks
122
123Each app gets a private network automatically. Services can additionally join named shared networks, created and reused only within verified Gordon ownership:
124
125```toml
126[[network.shared]]
127network = "backend"
128services = ["web", "worker"]
129```
130
131Deploy adds AND removes memberships without disconnecting unrelated services. Short DNS names resolve privately; app-qualified aliases apply on shared networks.
132
133## Volumes
134
135Services declare persistent storage in the app manifest. Every Dockerfile `VOLUME` path must be declared explicitly; deployment rejects unmanaged image volumes:
136
137```toml
138[[services.web.volume]]
139name = "web-data"
140path = "/data"
141```
142
143Docker/Podman own the named volumes; Gordon records app UUID, service, and logical volume ownership. App manifests allow neither bind mounts nor service-shared volumes. Replacement and restart reuse volumes. Removing a service or app retains its volumes under the original app UUID, and a new app reusing the public name never adopts them.
144
145Use `gordon volumes prune --dry-run` to inspect Gordon's ownership-aware plan. Do not use `docker volume prune` or an equivalent runtime command for Gordon data: it bypasses Gordon's retention checks and can delete unmounted retained volumes.
146
147## Environment and Secrets
148
149App-wide public env is declared in the manifest:
150
151```toml
152[env]
153APP_ENV = "production"
154```
155
156Public values for one service use `[services.<name>.env]` and override `[env]` keys for that service. Confidential values use `secrets`:
157
158```toml
159[services.web.env]
160LOG_LEVEL = "info"
161
162[services.web.secrets]
163DATABASE_URL = "database-url"
164```
165
166Secret values stay in pass under `gordon/apps/<uuid>/<service>/<name>`, keyed by the stable internal UUID so a removed app's secrets are never adopted by a new app reusing the name. Running containers keep the values they were created with; `gordon apps deploy` applies new values. `restart` does not. Write values with `gordon apps secrets set`; only key names are ever echoed back, never values.
167
168## Installation Reload
169
170Gordon watches `gordon.toml` and reloads installation-only settings (entrypoints, TLS, limits, external routes). Reload never activates pending app desired state, never re-resolves image tags, and never starts app workloads.
171
172```bash
173gordon daemon reload
174```
175
176`gordon daemon reload` sends `SIGUSR1` to the running Gordon process. `gordon.toml` holds installation settings only — app workloads live in app files.
177
178## Event System
179
180Gordon uses an internal event system for coordination. Registry storage events no longer deploy anything: push events never deploy, create routes, or modify manifests.
181
182## Backups and Recovery
183
184Gordon runs PostgreSQL logical backups and volume archives to S3 for explicitly declared app targets. Declarations live in the app manifest (`[[services.<name>.database]]`, `[services.<name>.backup]`); storage infrastructure (destinations, schedules, retention) stays global.
185
186Stored backups are never deleted when declarations change — only schedules update on deploy.
187
188For 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).
189
190## Container Identity
191
192Gordon stamps app ownership labels on every container and volume it creates:
193
194| Label | Purpose |
195|-------|---------|
196| `gordon.managed=true` | Identifies Gordon-managed resources |
197| `gordon.app` | App public name |
198| `gordon.app.service` | Service name |
199| `gordon.app.revision` | Active revision that created it |
200
201Queries by logical identity use labels, never name parsing. Unknown resources (no labels, old labels) are preserved, never adopted or deleted.
202
203## Related
204
205- [Configuration Reference](./config/index.md)
206- [Apps CLI](./cli/apps.md)
207- [Getting Started](./getting-started.md)