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 FILEvalidates and persists desired configuration only.deploy APPactivates it.apply --deploychains both using exactly the revision accepted by apply.--dry-runvalidates and previews without persistence or runtime effects.- Version tags are recommended, not constrained to SemVer.
latestremains 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.