detached 4e3a25d

docs(security): document secret files, runtime guard, and hardening

commit
4e3a25d2ce9766b540ce4164a01cff55c64ea9b3
author
brice · · 2026-10-08 20:33 UTC
parent
49efad0
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +98 -1
M docs/config/apps.md +7 -1
@@ -52,7 +52,9 @@ port = 8080 # required; no host or tls
5252 LOG_LEVEL = "debug" # overrides an app-wide [env] key of the same name
5353
5454 [services.web.secrets] # ENV name -> secret name (values in pass)
55-DATABASE_URL = "database-url"
55+API_KEY = "api-key" # env delivery
56+DATABASE_URL = { name = "database-url", as = "file" } # /run/secrets/DATABASE_URL
57+DB_PASS = { name = "db-pass", as = "env" } # explicit env delivery
5658
5759 [[services.web.volume]] # 0..n named volumes only
5860 name = "web-data"
@@ -113,6 +115,10 @@ Image registry names and digest syntax are validated during manifest apply, reso
113115 - `[env]` is app-wide public env injected into all services. Each key must be disjoint from every `[services.<name>.secrets]` key in the app.
114116 - `[services.<name>.env]` is public env for that service only. A key here overrides the same key in `[env]` for this service. Each key must be disjoint from the same service's `[services.<name>.secrets]` keys. Values follow the `[env]` rules: no newlines, 1 byte to 64 KiB, no secret references. Values are stored in revisions and shown in plain text, so use secrets for anything sensitive. A change shows as `service/<name>/env` in diffs and applies on the next deploy.
115117 - `[services.<name>.secrets]` maps ENV var name to service-local secret name. Values are written with `gordon apps secrets set` and stay in pass under `gordon/apps/<uuid>/<service>/<name>`. Running containers keep the values they were created with; `gordon apps deploy` applies new values. `restart` does not.
118+- A secret entry is either a secret name string (env delivery) or a table `{ name = "...", as = "env" | "file" }`. `as` defaults to `env`; any other key or value is rejected.
119+- `as = "file"` delivers the value as a read-only file (mode `0444`) at `/run/secrets/<ENV>` and sets `<ENV>_FILE=/run/secrets/<ENV>` instead of `<ENV>`. The value never appears in the container environment, `docker inspect`, or `/proc/1/environ`. Use it for images that support the `*_FILE` convention.
120+- With file delivery, `<ENV>_FILE` must not be another secret key or a key in `[env]` or `[services.<name>.env]`, and no bind or volume of the service may mount at or under `/run/secrets`.
121+- Switching a secret between `env` and `file` shows as `service/<name>/secret/<secret>` in diffs and applies on the next deploy. See [Secrets](./secrets.md#secret-files).
116122
117123 ## Volumes and Databases
118124
@@ -52,7 +52,9 @@ port = 8080 # required; no host or tls
52 LOG_LEVEL = "debug" # overrides an app-wide [env] key of the same name52 LOG_LEVEL = "debug" # overrides an app-wide [env] key of the same name
53 53
54 [services.web.secrets] # ENV name -> secret name (values in pass)54 [services.web.secrets] # ENV name -> secret name (values in pass)
55-DATABASE_URL = "database-url"55+API_KEY = "api-key" # env delivery
56+DATABASE_URL = { name = "database-url", as = "file" } # /run/secrets/DATABASE_URL
57+DB_PASS = { name = "db-pass", as = "env" } # explicit env delivery
56 58
57 [[services.web.volume]] # 0..n named volumes only59 [[services.web.volume]] # 0..n named volumes only
58 name = "web-data"60 name = "web-data"
@@ -113,6 +115,10 @@ Image registry names and digest syntax are validated during manifest apply, reso
113 - `[env]` is app-wide public env injected into all services. Each key must be disjoint from every `[services.<name>.secrets]` key in the app.115 - `[env]` is app-wide public env injected into all services. Each key must be disjoint from every `[services.<name>.secrets]` key in the app.
114 - `[services.<name>.env]` is public env for that service only. A key here overrides the same key in `[env]` for this service. Each key must be disjoint from the same service's `[services.<name>.secrets]` keys. Values follow the `[env]` rules: no newlines, 1 byte to 64 KiB, no secret references. Values are stored in revisions and shown in plain text, so use secrets for anything sensitive. A change shows as `service/<name>/env` in diffs and applies on the next deploy.116 - `[services.<name>.env]` is public env for that service only. A key here overrides the same key in `[env]` for this service. Each key must be disjoint from the same service's `[services.<name>.secrets]` keys. Values follow the `[env]` rules: no newlines, 1 byte to 64 KiB, no secret references. Values are stored in revisions and shown in plain text, so use secrets for anything sensitive. A change shows as `service/<name>/env` in diffs and applies on the next deploy.
115 - `[services.<name>.secrets]` maps ENV var name to service-local secret name. Values are written with `gordon apps secrets set` and stay in pass under `gordon/apps/<uuid>/<service>/<name>`. Running containers keep the values they were created with; `gordon apps deploy` applies new values. `restart` does not.117 - `[services.<name>.secrets]` maps ENV var name to service-local secret name. Values are written with `gordon apps secrets set` and stay in pass under `gordon/apps/<uuid>/<service>/<name>`. Running containers keep the values they were created with; `gordon apps deploy` applies new values. `restart` does not.
118+- A secret entry is either a secret name string (env delivery) or a table `{ name = "...", as = "env" | "file" }`. `as` defaults to `env`; any other key or value is rejected.
119+- `as = "file"` delivers the value as a read-only file (mode `0444`) at `/run/secrets/<ENV>` and sets `<ENV>_FILE=/run/secrets/<ENV>` instead of `<ENV>`. The value never appears in the container environment, `docker inspect`, or `/proc/1/environ`. Use it for images that support the `*_FILE` convention.
120+- With file delivery, `<ENV>_FILE` must not be another secret key or a key in `[env]` or `[services.<name>.env]`, and no bind or volume of the service may mount at or under `/run/secrets`.
121+- Switching a secret between `env` and `file` shows as `service/<name>/secret/<secret>` in diffs and applies on the next deploy. See [Secrets](./secrets.md#secret-files).
116 122
117 ## Volumes and Databases123 ## Volumes and Databases
118 124
M docs/config/secrets.md +19 -0
@@ -117,6 +117,22 @@ gordon apps secrets set blog --service web DATABASE_URL=...
117117
118118 Values affect the next deploy/restart, never running containers. Only key names are ever echoed back, never values. See [App Manifest](./apps.md) and [Apps CLI](../cli/apps.md).
119119
120+### Secret files
121+
122+A secret declared with `as = "file"` is delivered as a file instead of an env value:
123+
124+```toml
125+[services.web.secrets]
126+DATABASE_URL = { name = "database-url", as = "file" }
127+```
128+
129+- The container sees a read-only file at `/run/secrets/DATABASE_URL` and the env var `DATABASE_URL_FILE=/run/secrets/DATABASE_URL`.
130+- On the host, Gordon writes one file per secret under `<data_dir>/secrets-run/<app>/<container>/`, with directories `0700` and files `0444`. The files persist so containers restarted by the runtime or after a reboot still find them.
131+- Gordon removes a container's files once the container is confirmed gone, and sweeps files of unknown containers at startup.
132+- Exclude `<data_dir>/secrets-run` from host backups: it holds plain secret values.
133+- Env-delivered secrets stay visible through `docker inspect` and `/proc/1/environ` inside the container. Prefer file delivery for apps that read `*_FILE` variables.
134+- An older Gordon v3 binary ignores file delivery and injects those secrets as env vars again. Its leftover `secrets-run` directories are harmless and are swept by the next start of a version that supports file delivery.
135+
120136 ## Examples
121137
122138 ### Production with Pass
@@ -152,9 +168,12 @@ secrets_backend = "unsafe"
152168 3. **Rotate regularly**: Regenerate tokens and passwords periodically
153169 4. **Least privilege**: Use separate secrets per environment
154170 5. **Path validation**: SOPS provider rejects absolute paths and path traversal attempts for security
171+6. **File delivery**: Use `as = "file"` for app secrets when the image supports `*_FILE` variables (see [Secret files](#secret-files))
172+7. **Hardening**: See [Security Hardening](./security-hardening.md) for the runtime guard, process protection, and credential scoping
155173
156174 ## Related
157175
158176 - [Authentication](./auth.md)
159177 - [App Manifest](./apps.md)
178+- [Security Hardening](./security-hardening.md)
160179 - [Configuration Overview](./index.md)
@@ -117,6 +117,22 @@ gordon apps secrets set blog --service web DATABASE_URL=...
117 117
118 Values affect the next deploy/restart, never running containers. Only key names are ever echoed back, never values. See [App Manifest](./apps.md) and [Apps CLI](../cli/apps.md).118 Values affect the next deploy/restart, never running containers. Only key names are ever echoed back, never values. See [App Manifest](./apps.md) and [Apps CLI](../cli/apps.md).
119 119
120+### Secret files
121+
122+A secret declared with `as = "file"` is delivered as a file instead of an env value:
123+
124+```toml
125+[services.web.secrets]
126+DATABASE_URL = { name = "database-url", as = "file" }
127+```
128+
129+- The container sees a read-only file at `/run/secrets/DATABASE_URL` and the env var `DATABASE_URL_FILE=/run/secrets/DATABASE_URL`.
130+- On the host, Gordon writes one file per secret under `<data_dir>/secrets-run/<app>/<container>/`, with directories `0700` and files `0444`. The files persist so containers restarted by the runtime or after a reboot still find them.
131+- Gordon removes a container's files once the container is confirmed gone, and sweeps files of unknown containers at startup.
132+- Exclude `<data_dir>/secrets-run` from host backups: it holds plain secret values.
133+- Env-delivered secrets stay visible through `docker inspect` and `/proc/1/environ` inside the container. Prefer file delivery for apps that read `*_FILE` variables.
134+- An older Gordon v3 binary ignores file delivery and injects those secrets as env vars again. Its leftover `secrets-run` directories are harmless and are swept by the next start of a version that supports file delivery.
135+
120 ## Examples136 ## Examples
121 137
122 ### Production with Pass138 ### Production with Pass
@@ -152,9 +168,12 @@ secrets_backend = "unsafe"
152 3. **Rotate regularly**: Regenerate tokens and passwords periodically168 3. **Rotate regularly**: Regenerate tokens and passwords periodically
153 4. **Least privilege**: Use separate secrets per environment169 4. **Least privilege**: Use separate secrets per environment
154 5. **Path validation**: SOPS provider rejects absolute paths and path traversal attempts for security170 5. **Path validation**: SOPS provider rejects absolute paths and path traversal attempts for security
171+6. **File delivery**: Use `as = "file"` for app secrets when the image supports `*_FILE` variables (see [Secret files](#secret-files))
172+7. **Hardening**: See [Security Hardening](./security-hardening.md) for the runtime guard, process protection, and credential scoping
155 173
156 ## Related174 ## Related
157 175
158 - [Authentication](./auth.md)176 - [Authentication](./auth.md)
159 - [App Manifest](./apps.md)177 - [App Manifest](./apps.md)
178+- [Security Hardening](./security-hardening.md)
160 - [Configuration Overview](./index.md)179 - [Configuration Overview](./index.md)
M docs/config/security-hardening.md +70 -0
@@ -172,9 +172,79 @@ security_profile = "compat" # or "strict"
172172
173173 Use `strict` for images designed to write only to mounted volumes and run without extra Linux capabilities.
174174
175+## Runtime guard
176+
177+Every app container config passes a final guard right before the runtime creates the container. A refused config creates no container and fails with error code `runtime-guard-violation` (HTTP 400). The guard refuses:
178+
179+- network mode `host`, `container:<id>`, or `ns:<path>`;
180+- a volume name containing `/` or `\`, which the runtime would read as a host path;
181+- a bind whose host source is equal to, under, or a parent of a protected path, compared both as written and after resolving symlinks.
182+
183+Protected paths:
184+
185+- the data directory and the loaded config file;
186+- runtime sockets: `/var/run/docker.sock`, `/run/docker.sock`, `/run/podman/podman.sock`, `$XDG_RUNTIME_DIR/docker.sock`, `$XDG_RUNTIME_DIR/podman/podman.sock`, the `unix://` path of `DOCKER_HOST`, and the `server.runtime` socket;
187+- Gordon runtime credentials: `$XDG_RUNTIME_DIR/gordon` and `~/.gordon/run`;
188+- key stores: `$GNUPGHOME`, `~/.gnupg`, `$PASSWORD_STORE_DIR`, `~/.password-store`, `$SOPS_AGE_KEY_FILE`, and `${XDG_CONFIG_HOME:-~/.config}/sops/age`.
189+
190+The only exception is Gordon's own read-only secret file mount: `<data_dir>/secrets-run/<app>/<container>/<KEY>` at `/run/secrets/<KEY>`. A refused `[app_mounts]` policy is reported at deploy preflight, before the running generation is replaced. Point the policy `source` outside every protected path.
191+
192+## Process memory protection
193+
194+On Linux, `gordon serve` marks itself non-dumpable (`PR_SET_DUMPABLE=0`) before loading any configuration or secret. Other processes of the same user can no longer read its `/proc/<pid>/environ`, `mem`, or file descriptors, attach with ptrace, or get a core dump. Startup fails if the flag cannot be set.
195+
196+This does not stop root, nor a process of the same user that can replace the Gordon binary or its systemd unit.
197+
198+## Hardened systemd user unit
199+
200+Add these directives to the `[Service]` section of the [user unit](../installation.md#user-service-recommended-for-rootless):
201+
202+```ini
203+[Service]
204+NoNewPrivileges=yes
205+UMask=0077
206+LockPersonality=yes
207+RestrictSUIDSGID=yes
208+RestrictRealtime=yes
209+SystemCallArchitectures=native
210+```
211+
212+Apply and check:
213+
214+```bash
215+systemctl --user daemon-reload
216+systemctl --user restart gordon
217+systemctl --user status gordon
218+systemd-analyze --user security gordon.service
219+```
220+
221+## Credential scoping
222+
223+- **Cloudflare DNS-01 token**: grant only `Zone:Read` and `DNS:Edit`, limited to the zones Gordon serves. A leaked token can issue certificates for, and take over, every hostname in those zones. See [Server](./server.md).
224+- **Registry push tokens**: issue one token per repository and pipeline instead of a global one:
225+
226+ ```bash
227+ gordon auth token generate --subject ci-blog --scopes push --repo blog
228+ ```
229+
230+## Protection boundary
231+
232+Gordon v3 protects against:
233+
234+- file-delivered secret values showing up in container env, `docker inspect`, or `/proc/1/environ`;
235+- app configs that mount the runtime socket, Gordon data, config, credentials, or key stores, or that join the host or another container's namespaces;
236+- same-user processes reading Gordon's memory or environment through `/proc`.
237+
238+Gordon v3 does not protect against:
239+
240+- a compromised Gordon process, proxy, or registry: they run as the same user that owns the runtime socket and `pass`;
241+- a malicious image deployed for an app: it receives that app's secrets;
242+- root on the host.
243+
175244 ## Related
176245
177246 - [Auth](./auth.md)
247+- [Secrets](./secrets.md)
178248 - [Images](./images.md)
179249 - [Network Isolation](./network-isolation.md)
180250 - [Volumes](./volumes.md)
@@ -172,9 +172,79 @@ security_profile = "compat" # or "strict"
172 172
173 Use `strict` for images designed to write only to mounted volumes and run without extra Linux capabilities.173 Use `strict` for images designed to write only to mounted volumes and run without extra Linux capabilities.
174 174
175+## Runtime guard
176+
177+Every app container config passes a final guard right before the runtime creates the container. A refused config creates no container and fails with error code `runtime-guard-violation` (HTTP 400). The guard refuses:
178+
179+- network mode `host`, `container:<id>`, or `ns:<path>`;
180+- a volume name containing `/` or `\`, which the runtime would read as a host path;
181+- a bind whose host source is equal to, under, or a parent of a protected path, compared both as written and after resolving symlinks.
182+
183+Protected paths:
184+
185+- the data directory and the loaded config file;
186+- runtime sockets: `/var/run/docker.sock`, `/run/docker.sock`, `/run/podman/podman.sock`, `$XDG_RUNTIME_DIR/docker.sock`, `$XDG_RUNTIME_DIR/podman/podman.sock`, the `unix://` path of `DOCKER_HOST`, and the `server.runtime` socket;
187+- Gordon runtime credentials: `$XDG_RUNTIME_DIR/gordon` and `~/.gordon/run`;
188+- key stores: `$GNUPGHOME`, `~/.gnupg`, `$PASSWORD_STORE_DIR`, `~/.password-store`, `$SOPS_AGE_KEY_FILE`, and `${XDG_CONFIG_HOME:-~/.config}/sops/age`.
189+
190+The only exception is Gordon's own read-only secret file mount: `<data_dir>/secrets-run/<app>/<container>/<KEY>` at `/run/secrets/<KEY>`. A refused `[app_mounts]` policy is reported at deploy preflight, before the running generation is replaced. Point the policy `source` outside every protected path.
191+
192+## Process memory protection
193+
194+On Linux, `gordon serve` marks itself non-dumpable (`PR_SET_DUMPABLE=0`) before loading any configuration or secret. Other processes of the same user can no longer read its `/proc/<pid>/environ`, `mem`, or file descriptors, attach with ptrace, or get a core dump. Startup fails if the flag cannot be set.
195+
196+This does not stop root, nor a process of the same user that can replace the Gordon binary or its systemd unit.
197+
198+## Hardened systemd user unit
199+
200+Add these directives to the `[Service]` section of the [user unit](../installation.md#user-service-recommended-for-rootless):
201+
202+```ini
203+[Service]
204+NoNewPrivileges=yes
205+UMask=0077
206+LockPersonality=yes
207+RestrictSUIDSGID=yes
208+RestrictRealtime=yes
209+SystemCallArchitectures=native
210+```
211+
212+Apply and check:
213+
214+```bash
215+systemctl --user daemon-reload
216+systemctl --user restart gordon
217+systemctl --user status gordon
218+systemd-analyze --user security gordon.service
219+```
220+
221+## Credential scoping
222+
223+- **Cloudflare DNS-01 token**: grant only `Zone:Read` and `DNS:Edit`, limited to the zones Gordon serves. A leaked token can issue certificates for, and take over, every hostname in those zones. See [Server](./server.md).
224+- **Registry push tokens**: issue one token per repository and pipeline instead of a global one:
225+
226+ ```bash
227+ gordon auth token generate --subject ci-blog --scopes push --repo blog
228+ ```
229+
230+## Protection boundary
231+
232+Gordon v3 protects against:
233+
234+- file-delivered secret values showing up in container env, `docker inspect`, or `/proc/1/environ`;
235+- app configs that mount the runtime socket, Gordon data, config, credentials, or key stores, or that join the host or another container's namespaces;
236+- same-user processes reading Gordon's memory or environment through `/proc`.
237+
238+Gordon v3 does not protect against:
239+
240+- a compromised Gordon process, proxy, or registry: they run as the same user that owns the runtime socket and `pass`;
241+- a malicious image deployed for an app: it receives that app's secrets;
242+- root on the host.
243+
175 ## Related244 ## Related
176 245
177 - [Auth](./auth.md)246 - [Auth](./auth.md)
247+- [Secrets](./secrets.md)
178 - [Images](./images.md)248 - [Images](./images.md)
179 - [Network Isolation](./network-isolation.md)249 - [Network Isolation](./network-isolation.md)
180 - [Volumes](./volumes.md)250 - [Volumes](./volumes.md)
M docs/installation.md +2 -0
@@ -266,6 +266,8 @@ sudo loginctl enable-linger $USER
266266 systemctl --user status gordon
267267 ```
268268
269+See [Hardened systemd user unit](./config/security-hardening.md#hardened-systemd-user-unit) for the recommended sandboxing directives.
270+
269271 ### System Service (Root Mode)
270272
271273 ```bash
@@ -266,6 +266,8 @@ sudo loginctl enable-linger $USER
266 systemctl --user status gordon266 systemctl --user status gordon
267 ```267 ```
268 268
269+See [Hardened systemd user unit](./config/security-hardening.md#hardened-systemd-user-unit) for the recommended sandboxing directives.
270+
269 ### System Service (Root Mode)271 ### System Service (Root Mode)
270 272
271 ```bash273 ```bash

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