From 1f8b926bbafb040b95c6b03c5fcd5a7465a5ccab Mon Sep 17 00:00:00 2001 From: Yuriy Kirillov Date: Sun, 23 Aug 2026 01:57:28 +0200 Subject: [PATCH 01/12] chore: drop redundant prefixes from hawkeye vault GitHub Secret/Variable names Two prefixes were doing no real work here: the target-name prefix (RUBYKATZEN_COM_) only matters once a second target shares this repo's GitHub Secrets/Variables namespace, which isn't planned; the app-name prefix (TRAEFIK_) is redundant given vaults are already one-per-app. Drop both, and fix two names that had drifted from their app-side counterpart (CLOUDFLARE_TOKEN -> CLOUDFLARE_DNS_API_TOKEN matching APPS_CLOUDFLARE_DNS_API_TOKEN, CERT_RESOLVER -> CERTIFICATE_RESOLVER matching APPS_CERTIFICATE_RESOLVER). GitHub Secret/Variable name is now identical to the app-side var name it feeds, minus the APPS_ prefix where present. The actual GitHub Secrets/Variables still need updating to match - tracked separately since that's a live-state change outside git. --- vaults/hawkeye-rybbit.yml | 8 ++++---- vaults/hawkeye-traefik.yml | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/vaults/hawkeye-rybbit.yml b/vaults/hawkeye-rybbit.yml index 09e1ee4..2d9c25e 100644 --- a/vaults/hawkeye-rybbit.yml +++ b/vaults/hawkeye-rybbit.yml @@ -2,7 +2,7 @@ asset: hawkeye-rybbit.sops.env keys: - hawkeye env: - APPS_DOMAIN: RUBYKATZEN_COM_DOMAIN - APPS_CERTIFICATE_RESOLVER: RUBYKATZEN_COM_CERT_RESOLVER - APPS_DATABASE_PASSWORD: RUBYKATZEN_COM_DATABASE_PASSWORD - APPS_KEY_HEX_32: RUBYKATZEN_COM_KEY_HEX_32 + APPS_DOMAIN: DOMAIN + APPS_CERTIFICATE_RESOLVER: CERTIFICATE_RESOLVER + APPS_DATABASE_PASSWORD: DATABASE_PASSWORD + APPS_KEY_HEX_32: KEY_HEX_32 diff --git a/vaults/hawkeye-traefik.yml b/vaults/hawkeye-traefik.yml index 25535ec..969e842 100644 --- a/vaults/hawkeye-traefik.yml +++ b/vaults/hawkeye-traefik.yml @@ -2,7 +2,7 @@ asset: hawkeye-traefik.sops.env keys: - hawkeye env: - APPS_ADMIN_MAIL: RUBYKATZEN_COM_ADMIN_MAIL - APPS_CLOUDFLARE_DNS_API_TOKEN: RUBYKATZEN_COM_CLOUDFLARE_TOKEN - HTTP_PORT: RUBYKATZEN_COM_TRAEFIK_HTTP_PORT - HTTPS_PORT: RUBYKATZEN_COM_TRAEFIK_HTTPS_PORT + APPS_ADMIN_MAIL: ADMIN_MAIL + APPS_CLOUDFLARE_DNS_API_TOKEN: CLOUDFLARE_DNS_API_TOKEN + HTTP_PORT: HTTP_PORT + HTTPS_PORT: HTTPS_PORT From e88cc6a9405906d27f0bc4c7c48da3b3af13e225 Mon Sep 17 00:00:00 2001 From: Yuriy Kirillov Date: Sun, 23 Aug 2026 02:04:27 +0200 Subject: [PATCH 02/12] refactor: drop the APPS_ prefix from shared env variable naming APPS_{VAR} was flightdeck's convention for "server-wide variable shared across apps" (vs. {APPNAME}_{VAR} for a per-app override), but the prefix wasn't distinguishing anything a bare name doesn't already convey by not having an app prefix - it's dead weight the same way the vault-level RUBYKATZEN_COM_/TRAEFIK_ prefixes were. Drop it across the whole catalog: common.yml, every app's docker-compose.yml, the shared database templates (postgres/mysql/mongodb/etc.), and the two rendered *.tpl config files (traefik, codecov) - all now reference plain names (DOMAIN, DATABASE_PASSWORD, KEY_HEX_32, ...). Per-app override vars follow along automatically (CODECOV_APPS_DOMAIN -> CODECOV_DOMAIN, TWOFAUTH_APPS_DOMAIN -> TWOFAUTH_DOMAIN), since they were always just {APPNAME}_ glued onto the shared name. One deliberate exception: AGENTS.md's UID/GID example now uses PUID/PGID (the linuxserver.io convention), not bare UID/GID - unlike every other bare name here, those two are actual shell-reserved variable names, so dropping their prefix would trade a redundant prefix for a real collision risk instead of removing dead weight. Updated AGENTS.md/README.md's naming-convention writeup, the encrypt-env action's example, and every test fixture that illustrated the old convention. Verified: full deploy/ test suite (48 tests), pre-commit, and `docker compose config` for traefik and rybbit. --- .github/actions/encrypt-env/README.md | 4 +-- AGENTS.md | 36 +++++++++++++-------------- README.md | 10 ++++---- apps/clickhouse-26.5.yml | 2 +- apps/codecov/codecov.yml.tpl | 18 +++++++------- apps/codecov/docker-compose.yml | 8 +++--- apps/common.yml | 16 ++++++------ apps/gatus/docker-compose.yml | 8 +++--- apps/glitchtip/docker-compose.yml | 8 +++--- apps/homepage/docker-compose.yml | 2 +- apps/mongodb-8.yml | 4 +-- apps/mysql-8.yml | 4 +-- apps/paradedb-17.yml | 2 +- apps/pgvector-17.yml | 2 +- apps/postgres-17.yml | 2 +- apps/postgres-18.yml | 2 +- apps/rybbit/docker-compose.yml | 18 +++++++------- apps/semaphore/docker-compose.yml | 12 ++++----- apps/timescale-17.yml | 2 +- apps/traefik/docker-compose.yml | 2 +- apps/traefik/traefik.yml.tpl | 4 +-- apps/twofauth/docker-compose.yml | 18 +++++++------- deploy/collisions.py | 2 +- deploy/tests/test_collisions.py | 10 ++++---- deploy/tests/test_deploy.py | 22 ++++++++-------- deploy/tests/test_render.py | 10 ++++---- deploy/tests/test_vault.py | 20 +++++++-------- vaults/hawkeye-rybbit.yml | 8 +++--- vaults/hawkeye-traefik.yml | 4 +-- 29 files changed, 130 insertions(+), 130 deletions(-) diff --git a/.github/actions/encrypt-env/README.md b/.github/actions/encrypt-env/README.md index 482da85..c0766e6 100644 --- a/.github/actions/encrypt-env/README.md +++ b/.github/actions/encrypt-env/README.md @@ -33,8 +33,8 @@ asset: mainframe.sops.env keys: - mainframe env: - APPS_DOMAIN: APPS_DOMAIN # output name: GitHub Secret/Variable name - APPS_TIMEZONE: APPS_TIMEZONE + DOMAIN: DOMAIN # output name: GitHub Secret/Variable name + TIMEZONE: TIMEZONE ``` For each name in `keys`, it loads `/.pub`. Secrets take precedence over Variables when both contain the same source key. Every source key must resolve or the action fails. The manifest has no `apps` field — app selection lives on the deploy target, not the vault; see the main README's "Vaults And Targets" section. diff --git a/AGENTS.md b/AGENTS.md index f0136cc..de0518b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -115,29 +115,29 @@ Compose files explicitly declare which variables are overridable using bash fall ```yaml # App-specific override, falls back to server-wide value -SHOWS_PATH: ${JELLYFIN_SHOWS_PATH:-${APPS_SHOWS_PATH}} +SHOWS_PATH: ${JELLYFIN_SHOWS_PATH:-${SHOWS_PATH}} # App-specific only — must be set per app SHOWS_PATH: ${JELLYFIN_SHOWS_PATH} # Server-wide — same value for all apps on this server -SHOWS_PATH: ${APPS_SHOWS_PATH} +SHOWS_PATH: ${SHOWS_PATH} ``` Naming convention: -- `{APPNAME}_{VAR}` — app-specific variable where `{APPNAME}` is the uppercased app directory with hyphens replaced by underscores (e.g. `JELLYFIN_SHOWS_PATH`, `BESZEL_AGENT_PUBLIC_KEY`, `TWOFAUTH_APPS_DOMAIN`) -- `APPS_{VAR}` — server-wide variable shared across apps (e.g. `APPS_DOMAIN`, `APPS_TIMEZONE`) +- `{APPNAME}_{VAR}` — app-specific variable where `{APPNAME}` is the uppercased app directory with hyphens replaced by underscores (e.g. `JELLYFIN_SHOWS_PATH`, `BESZEL_AGENT_PUBLIC_KEY`, `TWOFAUTH_DOMAIN`) +- `{VAR}` (bare, no prefix) — server-wide variable shared across apps (e.g. `DOMAIN`, `TIMEZONE`) -The compose file is the source of truth for which overrides are allowed. Not every variable needs an app-specific override — only declare one when you actually want to allow it. +The compose file is the source of truth for which overrides are allowed. Not every variable needs an app-specific override — only declare one when you actually want to allow it. There's no reserved namespace separating "shared" from "app-specific" bare names - a plain `{VAR}` is shared by convention (every vault that maps it feeds the same value to every app referencing it), not by any prefix enforcing it. -Common `APPS_*` variables a target's vaults map into one or more apps' `.env`: +Common shared variables a target's vaults map into one or more apps' `.env`: -- `APPS_DOMAIN` - Base domain for all services -- `APPS_CERTIFICATE_RESOLVER` - SSL resolver (Cloudflare DNS or HTTP challenge) -- `APPS_DATABASE_PASSWORD` - Shared database password -- `APPS_KEY_HEX_{16,32,64}` - Encryption keys for various apps -- `APPS_TIMEZONE` - System timezone +- `DOMAIN` - Base domain for all services +- `CERTIFICATE_RESOLVER` - SSL resolver (Cloudflare DNS or HTTP challenge) +- `DATABASE_PASSWORD` - Shared database password +- `KEY_HEX_{16,32,64}` - Encryption keys for various apps +- `TIMEZONE` - System timezone ### Traefik Integration @@ -145,12 +145,12 @@ All apps use Traefik labels pattern: ```yaml traefik.enable=true -traefik.http.routers.${APP_NAME}.rule=Host(`${APP_NAME}.${APPS_DOMAIN}`) -traefik.http.routers.${APP_NAME}.tls.certresolver=${APPS_CERTIFICATE_RESOLVER} +traefik.http.routers.${APP_NAME}.rule=Host(`${APP_NAME}.${DOMAIN}`) +traefik.http.routers.${APP_NAME}.tls.certresolver=${CERTIFICATE_RESOLVER} traefik.http.services.${APP_NAME}.loadbalancer.server.port=8080 ``` -Apps are accessible at `{app-name}.{APPS_DOMAIN}` with automatic SSL. +Apps are accessible at `{app-name}.{DOMAIN}` with automatic SSL. ## Operations @@ -230,7 +230,7 @@ x-image: &image # 3. X-ENVIRONMENT (only if environment variables exist) x-environment: &environment VAR1: ${VALUE1} - DATABASE_URL: postgresql://postgres:${APPS_DATABASE_PASSWORD}@postgres:5432/${APP_NAME} + DATABASE_URL: postgresql://postgres:${DATABASE_PASSWORD}@postgres:5432/${APP_NAME} # 4. X-VOLUMES (if multiple services share volumes) x-volumes: &volumes @@ -258,7 +258,7 @@ services: command: ["start", "--config", "/config.yml"] # 4. USER (if required) - user: "${APPS_UID}:${APPS_GID}" + user: "${PUID}:${PGID}" # not UID/GID - those are shell-reserved # 5. ENVIRONMENT (mandatory, via anchor) environment: *environment @@ -324,7 +324,7 @@ include: - ../networks.yml - ../postgres-18.yml x-environment: &environment - DATABASE_URL: postgresql://postgres:${APPS_DATABASE_PASSWORD}@postgres:5432/${APP_NAME} + DATABASE_URL: postgresql://postgres:${DATABASE_PASSWORD}@postgres:5432/${APP_NAME} ENABLE_FEATURE: true PORT: 8080 services: @@ -334,7 +334,7 @@ services: file: ../common.yml service: main command: ["worker", "--concurrency", "10"] - user: "${APPS_UID}:${APPS_GID}" + user: "${PUID}:${PGID}" # not UID/GID - those are shell-reserved environment: *environment volumes: - ../../apps-data/${APP_NAME}/data:/data diff --git a/README.md b/README.md index fd0eeff..d87d6b9 100644 --- a/README.md +++ b/README.md @@ -97,16 +97,16 @@ This catalog is itself published as its own release asset (`flightdeck-apps.zip` ### Environment Variables -Every app's env comes from its own vault(s), declared in that target's manifest (see "Vaults And Targets" below). A vault declares the exact final variable names an app receives, mapped to GitHub Secret/Variable names - there is no server-side prefix filtering or shared root env file. Two apps' vaults can share a source secret (e.g. both mapping `APPS_DOMAIN`) without conflict, since each app ends up with its own separate `.env`. +Every app's env comes from its own vault(s), declared in that target's manifest (see "Vaults And Targets" below). A vault declares the exact final variable names an app receives, mapped to GitHub Secret/Variable names - there is no server-side prefix filtering or shared root env file. Two apps' vaults can share a source secret (e.g. both mapping `DOMAIN`) without conflict, since each app ends up with its own separate `.env`. **Per-app overrides** are declared directly in each app's `docker-compose.yml` using bash fallback syntax: ```yaml # App-specific override, falls back to server-wide value -SOME_PATH: ${MYAPP_SOME_PATH:-${APPS_SOME_PATH}} +SOME_PATH: ${MYAPP_SOME_PATH:-${SOME_PATH}} ``` -To use the override for a given deploy, that app's own vault sets `MYAPP_SOME_PATH` in its `env:` mapping; to fall back to the shared value, the vault just omits it and relies on `APPS_SOME_PATH` alone. App prefixes are the uppercased app directory with hyphens replaced by underscores. See `AGENTS.md` for the full naming convention. +To use the override for a given deploy, that app's own vault sets `MYAPP_SOME_PATH` in its `env:` mapping; to fall back to the shared value, the vault just omits it and relies on `SOME_PATH` alone. App prefixes are the uppercased app directory with hyphens replaced by underscores. See `AGENTS.md` for the full naming convention. ### Network Architecture @@ -214,7 +214,7 @@ asset: mainframe-rybbit.sops.env keys: - mainframe env: - APPS_DOMAIN: MAINFRAME_DOMAIN + DOMAIN: MAINFRAME_DOMAIN ``` `targets/mainframe.yml`: @@ -243,7 +243,7 @@ credentials: sops_age_key: MAINFRAME_AGE_PRIVATE_KEY ``` -Credential fields contain GitHub Variable/Secret names, never credential values. `app_refs` and `hosts` are YAML arrays; `apps` is a mapping from app name to that app's own `env_refs` array. Each host uses the SSH `user@host` format. `app_refs` must list at least one app bundle — flightdeck's own `apps/` catalog is just another entry, not implicit. Each app in `apps` must list at least one `env_refs` entry; `deploy/deploy.py` decrypts and concatenates all of an app's sources into that app's own `.env` on the runner, failing loud on any key collision — but only within that one app's own sources. Two different apps' vaults sharing a key (e.g. both declaring `APPS_DOMAIN`) is expected, since each app gets a separate `.env`. `credentials.secrets.sops_age_key` names the GitHub Secret holding this target's *private* age key — the one used to decrypt its vaults, matching the public key in `keys/.pub` used to encrypt them. +Credential fields contain GitHub Variable/Secret names, never credential values. `app_refs` and `hosts` are YAML arrays; `apps` is a mapping from app name to that app's own `env_refs` array. Each host uses the SSH `user@host` format. `app_refs` must list at least one app bundle — flightdeck's own `apps/` catalog is just another entry, not implicit. Each app in `apps` must list at least one `env_refs` entry; `deploy/deploy.py` decrypts and concatenates all of an app's sources into that app's own `.env` on the runner, failing loud on any key collision — but only within that one app's own sources. Two different apps' vaults sharing a key (e.g. both declaring `DOMAIN`) is expected, since each app gets a separate `.env`. `credentials.secrets.sops_age_key` names the GitHub Secret holding this target's *private* age key — the one used to decrypt its vaults, matching the public key in `keys/.pub` used to encrypt them. `load-yaml-matrix` reads every file in `vaults/` or `targets/` into a matrix — it does not validate the manifest shape. Each manifest's fields are the responsibility of whatever consumes them: `encrypt-env` re-parses and validates its own manifest from `manifest`, and the workflows calling `deploy-shared.yml` apply `path`/`keep-releases` defaults and pull `credentials.secrets`/`credentials.variables` values directly from the matrix item. diff --git a/apps/clickhouse-26.5.yml b/apps/clickhouse-26.5.yml index ead2d2f..99d7d2f 100644 --- a/apps/clickhouse-26.5.yml +++ b/apps/clickhouse-26.5.yml @@ -5,7 +5,7 @@ services: environment: CLICKHOUSE_DB: ${APP_NAME} CLICKHOUSE_USER: ${APP_NAME} - CLICKHOUSE_PASSWORD: ${APPS_DATABASE_PASSWORD} + CLICKHOUSE_PASSWORD: ${DATABASE_PASSWORD} CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT: 1 volumes: - ../apps-data/${APP_NAME}/clickhouse/data:/var/lib/clickhouse diff --git a/apps/codecov/codecov.yml.tpl b/apps/codecov/codecov.yml.tpl index ed59516..c8a76cd 100644 --- a/apps/codecov/codecov.yml.tpl +++ b/apps/codecov/codecov.yml.tpl @@ -1,7 +1,7 @@ setup: # Replace with the http location of your Codecov # https://docs.codecov.io/docs/configuration#section-codecov-url - codecov_url: https://${APP_NAME}.${CODECOV_APPS_DOMAIN:-${APPS_DOMAIN}} + codecov_url: https://${APP_NAME}.${CODECOV_DOMAIN:-${DOMAIN}} # codecov_api_url: # this defaults to and is designed to work out of the box like this # api_allowed_hosts: [] # this defaults to and is designed to work out of the box like this # Replace with your Codecov Enterprise License key. This is required for the containers to function. @@ -11,8 +11,8 @@ setup: - service: github username: ${CODECOV_ADMIN_GITHUB_USERNAME} http: - cookie_secret: ${APPS_KEY_HEX_32} # Replace it with a random string - cookies_domain: ${APP_NAME}.${CODECOV_APPS_DOMAIN:-${APPS_DOMAIN}} + cookie_secret: ${KEY_HEX_32} # Replace it with a random string + cookies_domain: ${APP_NAME}.${CODECOV_DOMAIN:-${DOMAIN}} timeseries: enabled: true guest_access: "off" @@ -25,13 +25,13 @@ github: pem: /config/key.pem services: redis_url: "redis://redis:6379" - database_url: "postgres://${APP_NAME}:${APPS_DATABASE_PASSWORD}@postgres:5432/${APP_NAME}" - timeseries_database_url: "postgres://${APP_NAME}:${APPS_DATABASE_PASSWORD}@timescale:5432/${APP_NAME}" + database_url: "postgres://${APP_NAME}:${DATABASE_PASSWORD}@postgres:5432/${APP_NAME}" + timeseries_database_url: "postgres://${APP_NAME}:${DATABASE_PASSWORD}@timescale:5432/${APP_NAME}" minio: - host: ${APPS_S3_HOST} + host: ${S3_HOST} bucket: ${CODECOV_S3_BUCKET} - region: ${APPS_S3_REGION} + region: ${S3_REGION} verify_ssl: true port: 443 - access_key_id: ${APPS_S3_ACCESS_KEY_ID} - secret_access_key: ${APPS_S3_SECRET_ACCESS_KEY} + access_key_id: ${S3_ACCESS_KEY_ID} + secret_access_key: ${S3_SECRET_ACCESS_KEY} diff --git a/apps/codecov/docker-compose.yml b/apps/codecov/docker-compose.yml index 6455b74..019e461 100644 --- a/apps/codecov/docker-compose.yml +++ b/apps/codecov/docker-compose.yml @@ -4,9 +4,9 @@ include: - ../postgres-17.yml - ../timescale-17.yml x-environment: &environment - CODECOV_BASE_HOST: ${APP_NAME}.${CODECOV_APPS_DOMAIN:-${APPS_DOMAIN}} - CODECOV_API_HOST: ${APP_NAME}.${CODECOV_APPS_DOMAIN:-${APPS_DOMAIN}} - CODECOV_IA_HOST: ${APP_NAME}.${CODECOV_APPS_DOMAIN:-${APPS_DOMAIN}} + CODECOV_BASE_HOST: ${APP_NAME}.${CODECOV_DOMAIN:-${DOMAIN}} + CODECOV_API_HOST: ${APP_NAME}.${CODECOV_DOMAIN:-${DOMAIN}} + CODECOV_IA_HOST: ${APP_NAME}.${CODECOV_DOMAIN:-${DOMAIN}} CODECOV_SCHEME: https RUN_ENV: ENTERPRISE x-volumes: &volumes @@ -21,7 +21,7 @@ services: - 8080 labels: - "traefik.http.services.${APP_NAME}.loadbalancer.server.port=8080" - - "traefik.http.routers.${APP_NAME}.rule=Host(`${APP_NAME}.${CODECOV_APPS_DOMAIN:-${APPS_DOMAIN}}`)" + - "traefik.http.routers.${APP_NAME}.rule=Host(`${APP_NAME}.${CODECOV_DOMAIN:-${DOMAIN}}`)" depends_on: - api - frontend diff --git a/apps/common.yml b/apps/common.yml index 0e2b830..1c80104 100644 --- a/apps/common.yml +++ b/apps/common.yml @@ -3,26 +3,26 @@ x-labels: &labels - "traefik.docker.network=traefik" - "traefik.http.middlewares.redirect-to-https.redirectscheme.scheme=https" - "traefik.http.middlewares.redirect-to-https.redirectscheme.permanent=false" - - "traefik.http.routers.${APP_NAME}-http.rule=Host(`${APP_NAME}.${APPS_DOMAIN}`)" + - "traefik.http.routers.${APP_NAME}-http.rule=Host(`${APP_NAME}.${DOMAIN}`)" - "traefik.http.routers.${APP_NAME}-http.entrypoints=http" - "traefik.http.routers.${APP_NAME}-http.middlewares=redirect-to-https" - - "traefik.http.routers.${APP_NAME}.rule=Host(`${APP_NAME}.${APPS_DOMAIN}`)" + - "traefik.http.routers.${APP_NAME}.rule=Host(`${APP_NAME}.${DOMAIN}`)" - "traefik.http.routers.${APP_NAME}.entrypoints=https" - - "traefik.http.routers.${APP_NAME}.tls.certresolver=${APPS_CERTIFICATE_RESOLVER}" + - "traefik.http.routers.${APP_NAME}.tls.certresolver=${CERTIFICATE_RESOLVER}" x-labels-http: &labels-http - "traefik.enable=true" - "traefik.docker.network=traefik" - - "traefik.http.routers.${APP_NAME}.rule=Host(`${APP_NAME}.${APPS_DOMAIN}`)" + - "traefik.http.routers.${APP_NAME}.rule=Host(`${APP_NAME}.${DOMAIN}`)" - "traefik.http.routers.${APP_NAME}.entrypoints=https" - - "traefik.http.routers.${APP_NAME}.tls.certresolver=${APPS_CERTIFICATE_RESOLVER}" - - "traefik.http.routers.${APP_NAME}-plain.rule=Host(`${APP_NAME}.${APPS_DOMAIN}`)" + - "traefik.http.routers.${APP_NAME}.tls.certresolver=${CERTIFICATE_RESOLVER}" + - "traefik.http.routers.${APP_NAME}-plain.rule=Host(`${APP_NAME}.${DOMAIN}`)" - "traefik.http.routers.${APP_NAME}-plain.entrypoints=http" x-labels-api: &labels-api - traefik.enable=true - traefik.docker.network=traefik - - traefik.http.routers.${APP_NAME}-api.rule=Host(`${APP_NAME}.${APPS_DOMAIN}`) && PathPrefix(`/api`) + - traefik.http.routers.${APP_NAME}-api.rule=Host(`${APP_NAME}.${DOMAIN}`) && PathPrefix(`/api`) - traefik.http.routers.${APP_NAME}-api.entrypoints=https - - traefik.http.routers.${APP_NAME}-api.tls.certresolver=${APPS_CERTIFICATE_RESOLVER} + - traefik.http.routers.${APP_NAME}-api.tls.certresolver=${CERTIFICATE_RESOLVER} x-env-file: &env-file - ./${APP_NAME}/.env x-restart: &restart unless-stopped diff --git a/apps/gatus/docker-compose.yml b/apps/gatus/docker-compose.yml index 0cd939a..a069abd 100644 --- a/apps/gatus/docker-compose.yml +++ b/apps/gatus/docker-compose.yml @@ -3,12 +3,12 @@ include: - ../postgres-17.yml x-environment: &environment GATUS_CONFIG_PATH: /config/yml - TZ: ${APPS_TIMEZONE} + TZ: ${TIMEZONE} POSTGRES_USER: ${APP_NAME} POSTGRES_DB: ${APP_NAME} - POSTGRES_PASSWORD: ${APPS_DATABASE_PASSWORD} - TELEGRAM_TOKEN: ${GATUS_TELEGRAM_TOKEN:-${APPS_TELEGRAM_TOKEN}} - TELEGRAM_ID: ${GATUS_TELEGRAM_CHAT:-${APPS_TELEGRAM_CHAT}} + POSTGRES_PASSWORD: ${DATABASE_PASSWORD} + TELEGRAM_TOKEN: ${GATUS_TELEGRAM_TOKEN:-${TELEGRAM_TOKEN}} + TELEGRAM_ID: ${GATUS_TELEGRAM_CHAT:-${TELEGRAM_CHAT}} services: gatus: image: twinproduction/gatus diff --git a/apps/glitchtip/docker-compose.yml b/apps/glitchtip/docker-compose.yml index d1770c9..ce6d005 100644 --- a/apps/glitchtip/docker-compose.yml +++ b/apps/glitchtip/docker-compose.yml @@ -4,13 +4,13 @@ include: - ../redis-8.yml x-image: &image glitchtip/glitchtip x-environment: &environment - DATABASE_URL: postgresql://${APP_NAME}:${APPS_DATABASE_PASSWORD}@postgres:5432/${APP_NAME} + DATABASE_URL: postgresql://${APP_NAME}:${DATABASE_PASSWORD}@postgres:5432/${APP_NAME} REDIS_URL: redis://redis:6379 - SECRET_KEY: ${APPS_KEY_HEX_64} + SECRET_KEY: ${KEY_HEX_64} PORT: 8000 EMAIL_URL: ${GLITCHTIP_EMAIL_URL:-consolemail://} - GLITCHTIP_DOMAIN: https://${APP_NAME}.${APPS_DOMAIN} - DEFAULT_FROM_EMAIL: ${GLITCHTIP_DEFAULT_FROM_EMAIL:-noreply@${APPS_DOMAIN}} + GLITCHTIP_DOMAIN: https://${APP_NAME}.${DOMAIN} + DEFAULT_FROM_EMAIL: ${GLITCHTIP_DEFAULT_FROM_EMAIL:-noreply@${DOMAIN}} CELERY_WORKER_AUTOSCALE: "1,3" services: web: diff --git a/apps/homepage/docker-compose.yml b/apps/homepage/docker-compose.yml index 518b374..7d3ccd3 100644 --- a/apps/homepage/docker-compose.yml +++ b/apps/homepage/docker-compose.yml @@ -1,7 +1,7 @@ include: - ../networks.yml x-environment: &environment - HOMEPAGE_ALLOWED_HOSTS: ${APP_NAME}.${APPS_DOMAIN} + HOMEPAGE_ALLOWED_HOSTS: ${APP_NAME}.${DOMAIN} services: homepage: image: ghcr.io/gethomepage/homepage diff --git a/apps/mongodb-8.yml b/apps/mongodb-8.yml index c332a17..69b1c6e 100644 --- a/apps/mongodb-8.yml +++ b/apps/mongodb-8.yml @@ -3,9 +3,9 @@ services: image: bitnami/mongodb restart: unless-stopped environment: - MONGODB_ROOT_PASSWORD: ${APPS_DATABASE_PASSWORD} + MONGODB_ROOT_PASSWORD: ${DATABASE_PASSWORD} MONGODB_USERNAME: ${APP_NAME} - MONGODB_PASSWORD: ${APPS_DATABASE_PASSWORD} + MONGODB_PASSWORD: ${DATABASE_PASSWORD} MONGODB_DATABASE: ${APP_NAME} volumes: - ../apps-data/${APP_NAME}/mongodb:/bitnami/mongodb diff --git a/apps/mysql-8.yml b/apps/mysql-8.yml index 656ac4d..6c8e75e 100644 --- a/apps/mysql-8.yml +++ b/apps/mysql-8.yml @@ -5,8 +5,8 @@ services: environment: MYSQL_DATABASE: ${APP_NAME} MYSQL_USER: ${APP_NAME} - MYSQL_PASSWORD: ${APPS_DATABASE_PASSWORD} - MYSQL_ROOT_PASSWORD: ${APPS_DATABASE_PASSWORD} + MYSQL_PASSWORD: ${DATABASE_PASSWORD} + MYSQL_ROOT_PASSWORD: ${DATABASE_PASSWORD} volumes: - ../apps-data/${APP_NAME}/mysql:/var/lib/mysql healthcheck: diff --git a/apps/paradedb-17.yml b/apps/paradedb-17.yml index 08698c6..544a17d 100644 --- a/apps/paradedb-17.yml +++ b/apps/paradedb-17.yml @@ -5,7 +5,7 @@ services: command: ["postgres", "-c", "log_min_messages=ERROR", "-c", "client_min_messages=ERROR"] environment: POSTGRES_DB: ${APP_NAME} - POSTGRES_PASSWORD: ${APPS_DATABASE_PASSWORD} + POSTGRES_PASSWORD: ${DATABASE_PASSWORD} POSTGRES_USER: ${APP_NAME} volumes: - ../apps-data/${APP_NAME}/paradedb:/var/lib/postgresql/data diff --git a/apps/pgvector-17.yml b/apps/pgvector-17.yml index 417b13c..7629fd0 100644 --- a/apps/pgvector-17.yml +++ b/apps/pgvector-17.yml @@ -5,7 +5,7 @@ services: command: ["postgres", "-c", "log_min_messages=ERROR", "-c", "client_min_messages=ERROR"] environment: POSTGRES_DB: ${APP_NAME} - POSTGRES_PASSWORD: ${APPS_DATABASE_PASSWORD} + POSTGRES_PASSWORD: ${DATABASE_PASSWORD} POSTGRES_USER: ${APP_NAME} volumes: - ../apps-data/${APP_NAME}/pgvector:/var/lib/postgresql/data diff --git a/apps/postgres-17.yml b/apps/postgres-17.yml index f97e4d1..2ea9b0c 100644 --- a/apps/postgres-17.yml +++ b/apps/postgres-17.yml @@ -5,7 +5,7 @@ services: command: ["postgres", "-c", "log_min_messages=ERROR", "-c", "client_min_messages=ERROR"] environment: POSTGRES_DB: ${APP_NAME} - POSTGRES_PASSWORD: ${APPS_DATABASE_PASSWORD} + POSTGRES_PASSWORD: ${DATABASE_PASSWORD} POSTGRES_USER: ${APP_NAME} volumes: - ../apps-data/${APP_NAME}/postgres:/var/lib/postgresql/data diff --git a/apps/postgres-18.yml b/apps/postgres-18.yml index dfb8434..e8988de 100644 --- a/apps/postgres-18.yml +++ b/apps/postgres-18.yml @@ -5,7 +5,7 @@ services: command: ["postgres", "-c", "log_min_messages=ERROR", "-c", "client_min_messages=ERROR"] environment: POSTGRES_DB: ${APP_NAME} - POSTGRES_PASSWORD: ${APPS_DATABASE_PASSWORD} + POSTGRES_PASSWORD: ${DATABASE_PASSWORD} POSTGRES_USER: ${APP_NAME} volumes: - ../apps-data/${APP_NAME}/postgres:/var/lib/postgresql diff --git a/apps/rybbit/docker-compose.yml b/apps/rybbit/docker-compose.yml index 2ef7a29..b2b90b4 100644 --- a/apps/rybbit/docker-compose.yml +++ b/apps/rybbit/docker-compose.yml @@ -4,20 +4,20 @@ include: - ../clickhouse-26.5.yml x-environment: &environment NODE_ENV: production - BASE_URL: https://${APP_NAME}.${APPS_DOMAIN} - BETTER_AUTH_SECRET: ${APPS_KEY_HEX_32} - DISABLE_SIGNUP: ${APPS_DISABLE_SIGNUP:-false} - DISABLE_TELEMETRY: ${APPS_DISABLE_TELEMETRY:-true} - MAPBOX_TOKEN: ${APPS_MAPBOX_TOKEN:-} + BASE_URL: https://${APP_NAME}.${DOMAIN} + BETTER_AUTH_SECRET: ${KEY_HEX_32} + DISABLE_SIGNUP: ${DISABLE_SIGNUP:-false} + DISABLE_TELEMETRY: ${DISABLE_TELEMETRY:-true} + MAPBOX_TOKEN: ${MAPBOX_TOKEN:-} POSTGRES_HOST: postgres POSTGRES_PORT: 5432 POSTGRES_DB: ${APP_NAME} POSTGRES_USER: ${APP_NAME} - POSTGRES_PASSWORD: ${APPS_DATABASE_PASSWORD} + POSTGRES_PASSWORD: ${DATABASE_PASSWORD} CLICKHOUSE_HOST: http://clickhouse:8123 CLICKHOUSE_DB: ${APP_NAME} CLICKHOUSE_USER: ${APP_NAME} - CLICKHOUSE_PASSWORD: ${APPS_DATABASE_PASSWORD} + CLICKHOUSE_PASSWORD: ${DATABASE_PASSWORD} services: client: image: ghcr.io/rybbit-io/rybbit-client @@ -30,8 +30,8 @@ services: - "traefik.http.services.${APP_NAME}.loadbalancer.server.port=3002" environment: NODE_ENV: production - NEXT_PUBLIC_BACKEND_URL: https://${APP_NAME}.${APPS_DOMAIN} - NEXT_PUBLIC_DISABLE_SIGNUP: ${APPS_DISABLE_SIGNUP:-false} + NEXT_PUBLIC_BACKEND_URL: https://${APP_NAME}.${DOMAIN} + NEXT_PUBLIC_DISABLE_SIGNUP: ${DISABLE_SIGNUP:-false} depends_on: - backend backend: diff --git a/apps/semaphore/docker-compose.yml b/apps/semaphore/docker-compose.yml index 32b6416..48d9c26 100644 --- a/apps/semaphore/docker-compose.yml +++ b/apps/semaphore/docker-compose.yml @@ -2,19 +2,19 @@ include: - ../networks.yml - ../postgres-17.yml x-environment: &environment - SEMAPHORE_ACCESS_KEY_ENCRYPTION: ${APPS_KEY_HEX_16} + SEMAPHORE_ACCESS_KEY_ENCRYPTION: ${KEY_HEX_16} SEMAPHORE_DB_DIALECT: postgres SEMAPHORE_DB_HOST: postgres SEMAPHORE_DB_NAME: ${APP_NAME} SEMAPHORE_DB_USER: ${APP_NAME} - SEMAPHORE_DB_PASS: ${APPS_DATABASE_PASSWORD} + SEMAPHORE_DB_PASS: ${DATABASE_PASSWORD} SEMAPHORE_ADMIN: admin - SEMAPHORE_ADMIN_PASSWORD: ${APPS_ADMIN_DEFAULT_PASS} + SEMAPHORE_ADMIN_PASSWORD: ${ADMIN_DEFAULT_PASS} SEMAPHORE_ADMIN_NAME: admin - SEMAPHORE_ADMIN_EMAIL: ${APPS_ADMIN_MAIL} + SEMAPHORE_ADMIN_EMAIL: ${ADMIN_MAIL} SEMAPHORE_TELEGRAM_ALERT: true - SEMAPHORE_TELEGRAM_TOKEN: ${SEMAPHORE_TELEGRAM_TOKEN:-${APPS_TELEGRAM_TOKEN}} - SEMAPHORE_TELEGRAM_CHAT: ${SEMAPHORE_TELEGRAM_CHAT:-${APPS_TELEGRAM_CHAT}} + SEMAPHORE_TELEGRAM_TOKEN: ${SEMAPHORE_TELEGRAM_TOKEN:-${TELEGRAM_TOKEN}} + SEMAPHORE_TELEGRAM_CHAT: ${SEMAPHORE_TELEGRAM_CHAT:-${TELEGRAM_CHAT}} ANSIBLE_HOST_KEY_CHECKING: false services: semaphore: diff --git a/apps/timescale-17.yml b/apps/timescale-17.yml index abddbae..48e90fe 100644 --- a/apps/timescale-17.yml +++ b/apps/timescale-17.yml @@ -5,7 +5,7 @@ services: environment: POSTGRES_DB: ${APP_NAME} POSTGRES_USER: ${APP_NAME} - POSTGRES_PASSWORD: ${APPS_DATABASE_PASSWORD} + POSTGRES_PASSWORD: ${DATABASE_PASSWORD} volumes: - ../apps-data/${APP_NAME}/timescale:/var/lib/postgresql/data healthcheck: diff --git a/apps/traefik/docker-compose.yml b/apps/traefik/docker-compose.yml index 6e03b4a..680bbc4 100644 --- a/apps/traefik/docker-compose.yml +++ b/apps/traefik/docker-compose.yml @@ -1,5 +1,5 @@ x-environment: &environment - CF_DNS_API_TOKEN: ${APPS_CLOUDFLARE_DNS_API_TOKEN} + CF_DNS_API_TOKEN: ${CLOUDFLARE_DNS_API_TOKEN} services: traefik: image: traefik diff --git a/apps/traefik/traefik.yml.tpl b/apps/traefik/traefik.yml.tpl index 2b94bb4..b822446 100644 --- a/apps/traefik/traefik.yml.tpl +++ b/apps/traefik/traefik.yml.tpl @@ -30,13 +30,13 @@ providers: certificatesResolvers: acmeHttpChallengeResolver: acme: - email: ${APPS_ADMIN_MAIL} + email: ${ADMIN_MAIL} storage: acme.json httpChallenge: entryPoint: http acmeCloudflareDnsChallengeResolver: acme: - email: ${APPS_ADMIN_MAIL} + email: ${ADMIN_MAIL} storage: acme.json dnsChallenge: provider: cloudflare diff --git a/apps/twofauth/docker-compose.yml b/apps/twofauth/docker-compose.yml index ea9f5c3..48ccea6 100644 --- a/apps/twofauth/docker-compose.yml +++ b/apps/twofauth/docker-compose.yml @@ -5,9 +5,9 @@ x-environment: &environment APP_ENV: local APP_TIMEZONE: UTC APP_DEBUG: false - SITE_OWNER: ${APPS_ADMIN_MAIL} - APP_KEY: ${APPS_KEY_HEX_16} - APP_URL: https://${APP_NAME}.${TWOFAUTH_APPS_DOMAIN:-${APPS_DOMAIN}} + SITE_OWNER: ${ADMIN_MAIL} + APP_KEY: ${KEY_HEX_16} + APP_URL: https://${APP_NAME}.${TWOFAUTH_DOMAIN:-${DOMAIN}} IS_DEMO_APP: false LOG_CHANNEL: daily LOG_LEVEL: error @@ -15,13 +15,13 @@ x-environment: &environment CACHE_DRIVER: file SESSION_DRIVER: file MAIL_MAILER: smtp - MAIL_HOST: ${APPS_SMTP_HOSTNAME} - MAIL_PORT: ${APPS_SMTP_PORT} - MAIL_USERNAME: ${APPS_SMTP_USERNAME} - MAIL_PASSWORD: ${APPS_SMTP_PASSWORD} + MAIL_HOST: ${SMTP_HOSTNAME} + MAIL_PORT: ${SMTP_PORT} + MAIL_USERNAME: ${SMTP_USERNAME} + MAIL_PASSWORD: ${SMTP_PASSWORD} MAIL_ENCRYPTION: true MAIL_FROM_NAME: ${APP_NAME} - MAIL_FROM_ADDRESS: ${APPS_SMTP_FROM} + MAIL_FROM_ADDRESS: ${SMTP_FROM} MAIL_VERIFY_SSL_PEER: false THROTTLE_API: 60 LOGIN_THROTTLE: 5 @@ -45,7 +45,7 @@ services: - 8000 labels: - "traefik.http.services.${APP_NAME}.loadbalancer.server.port=8000" - - "traefik.http.routers.${APP_NAME}.rule=Host(`${APP_NAME}.${TWOFAUTH_APPS_DOMAIN:-${APPS_DOMAIN}}`)" + - "traefik.http.routers.${APP_NAME}.rule=Host(`${APP_NAME}.${TWOFAUTH_DOMAIN:-${DOMAIN}}`)" environment: *environment volumes: - ../../apps-data/${APP_NAME}/2fauth:/2fauth diff --git a/deploy/collisions.py b/deploy/collisions.py index aac1847..d9d57e6 100644 --- a/deploy/collisions.py +++ b/deploy/collisions.py @@ -2,7 +2,7 @@ """Detect duplicate env keys across an app's env_refs sources, from ciphertext. SOPS's dotenv output format only encrypts values, not key names -(`APPS_DOMAIN=ENC[...]`), so this needs no decryption at all - it runs in +(`DOMAIN=ENC[...]`), so this needs no decryption at all - it runs in CI, before anything is pushed to the target host, on files whose private key CI never has access to in the first place. """ diff --git a/deploy/tests/test_collisions.py b/deploy/tests/test_collisions.py index aa3575c..50db97c 100644 --- a/deploy/tests/test_collisions.py +++ b/deploy/tests/test_collisions.py @@ -12,7 +12,7 @@ TRAEFIK_ENV = """\ HTTP_PORT=ENC[AES256_GCM,data:Ab==,iv:xx==,tag:yy==,type:str] HTTPS_PORT=ENC[AES256_GCM,data:Cd==,iv:xx==,tag:yy==,type:str] -APPS_DOMAIN=ENC[AES256_GCM,data:Ef==,iv:xx==,tag:yy==,type:str] +DOMAIN=ENC[AES256_GCM,data:Ef==,iv:xx==,tag:yy==,type:str] sops_age__list_0__map_enc=-----BEGIN AGE ENCRYPTED FILE----- sops_lastmodified=2026-08-19T00:00:00Z sops_mac=ENC[AES256_GCM,data:Gh==,iv:xx==,tag:yy==,type:str] @@ -20,14 +20,14 @@ """ RYBBIT_ENV = """\ -APPS_DATABASE_PASSWORD=ENC[AES256_GCM,data:Ij==,iv:xx==,tag:yy==,type:str] -APPS_KEY_HEX_32=ENC[AES256_GCM,data:Kl==,iv:xx==,tag:yy==,type:str] +DATABASE_PASSWORD=ENC[AES256_GCM,data:Ij==,iv:xx==,tag:yy==,type:str] +KEY_HEX_32=ENC[AES256_GCM,data:Kl==,iv:xx==,tag:yy==,type:str] sops_lastmodified=2026-08-19T00:00:00Z sops_version=3.13.1 """ COLLIDING_ENV = """\ -APPS_DOMAIN=ENC[AES256_GCM,data:Mn==,iv:xx==,tag:yy==,type:str] +DOMAIN=ENC[AES256_GCM,data:Mn==,iv:xx==,tag:yy==,type:str] sops_version=3.13.1 """ @@ -59,7 +59,7 @@ def test_ignores_sops_metadata_keys(self): keys = collisions.extract_keys(traefik) | collisions.extract_keys(rybbit) self.assertFalse(any(key.startswith("sops_") for key in keys)) self.assertIn("HTTP_PORT", keys) - self.assertIn("APPS_KEY_HEX_32", keys) + self.assertIn("KEY_HEX_32", keys) if __name__ == "__main__": diff --git a/deploy/tests/test_deploy.py b/deploy/tests/test_deploy.py index 0b7f187..5fefbcb 100644 --- a/deploy/tests/test_deploy.py +++ b/deploy/tests/test_deploy.py @@ -150,9 +150,9 @@ def test_renders_templates_found_for_app_next_to_the_template(self): release_dir = Path(directory) app_dir = release_dir / "apps" / "traefik" app_dir.mkdir(parents=True) - (app_dir / "traefik.yml.tpl").write_text("email: ${APPS_ADMIN_MAIL}\n") + (app_dir / "traefik.yml.tpl").write_text("email: ${ADMIN_MAIL}\n") - deploy.render_app_configs(release_dir, "traefik", {"APPS_ADMIN_MAIL": "a@example.com"}) + deploy.render_app_configs(release_dir, "traefik", {"ADMIN_MAIL": "a@example.com"}) rendered_path = app_dir / "traefik.yml" self.assertEqual(rendered_path.read_text(), "email: a@example.com\n") @@ -179,20 +179,20 @@ def _release_dir_with_app(self, work_dir, app, template=None): def test_writes_decrypted_env_and_renders_configs(self): with tempfile.TemporaryDirectory() as directory: work_dir = Path(directory) - release_dir = self._release_dir_with_app(work_dir, "traefik", template="email: ${APPS_ADMIN_MAIL}\n") + release_dir = self._release_dir_with_app(work_dir, "traefik", template="email: ${ADMIN_MAIL}\n") ciphertext = work_dir / "a.sops.env" - ciphertext.write_text("APPS_ADMIN_MAIL=ENC[...]\n") + ciphertext.write_text("ADMIN_MAIL=ENC[...]\n") config = {"apps": {"traefik": {"env_refs": ["owner/repo@latest:a.sops.env"]}}} with ( patch.object(deploy, "download_ref", return_value=ciphertext), - patch.object(deploy, "decrypt_env", return_value="APPS_ADMIN_MAIL=a@example.com\n"), + patch.object(deploy, "decrypt_env", return_value="ADMIN_MAIL=a@example.com\n"), ): deploy.resolve_app_envs(config, work_dir / "work", release_dir, work_dir / "key.txt") env_path = release_dir / "apps" / "traefik" / ".env" - self.assertEqual(env_path.read_text(), "APPS_ADMIN_MAIL=a@example.com\n") + self.assertEqual(env_path.read_text(), "ADMIN_MAIL=a@example.com\n") self.assertEqual(oct(env_path.stat().st_mode)[-3:], "600") rendered_path = release_dir / "apps" / "traefik" / "traefik.yml" @@ -203,9 +203,9 @@ def test_raises_on_collision_within_one_apps_own_env_refs(self): work_dir = Path(directory) release_dir = self._release_dir_with_app(work_dir, "traefik") first_env = work_dir / "a.sops.env" - first_env.write_text("APPS_DOMAIN=ENC[AES256_GCM,data:Ab==,iv:xx==,tag:yy==,type:str]\n") + first_env.write_text("DOMAIN=ENC[AES256_GCM,data:Ab==,iv:xx==,tag:yy==,type:str]\n") second_env = work_dir / "b.sops.env" - second_env.write_text("APPS_DOMAIN=ENC[AES256_GCM,data:Cd==,iv:xx==,tag:yy==,type:str]\n") + second_env.write_text("DOMAIN=ENC[AES256_GCM,data:Cd==,iv:xx==,tag:yy==,type:str]\n") def fake_download_ref(ref, out_dir, default_asset=None, run=None): return first_env if ref.endswith(":a.sops.env") else second_env @@ -229,15 +229,15 @@ def test_allows_same_key_across_different_apps(self): release_dir = self._release_dir_with_app(work_dir, "traefik") self._release_dir_with_app(work_dir, "rybbit") traefik_env = work_dir / "a.sops.env" - traefik_env.write_text("APPS_DOMAIN=ENC[AES256_GCM,data:Ab==,iv:xx==,tag:yy==,type:str]\n") + traefik_env.write_text("DOMAIN=ENC[AES256_GCM,data:Ab==,iv:xx==,tag:yy==,type:str]\n") rybbit_env = work_dir / "b.sops.env" - rybbit_env.write_text("APPS_DOMAIN=ENC[AES256_GCM,data:Cd==,iv:xx==,tag:yy==,type:str]\n") + rybbit_env.write_text("DOMAIN=ENC[AES256_GCM,data:Cd==,iv:xx==,tag:yy==,type:str]\n") def fake_download_ref(ref, out_dir, default_asset=None, run=None): return traefik_env if "traefik" in ref else rybbit_env def fake_decrypt_env(path, age_key_file, run=None): - return "APPS_DOMAIN=example.com\n" + return "DOMAIN=example.com\n" config = { "apps": { diff --git a/deploy/tests/test_render.py b/deploy/tests/test_render.py index 759660c..834ed5e 100644 --- a/deploy/tests/test_render.py +++ b/deploy/tests/test_render.py @@ -10,20 +10,20 @@ class RenderTemplateTest(unittest.TestCase): def test_substitutes_braced_var(self): - result = render.render_template("email: ${APPS_ADMIN_MAIL}", {"APPS_ADMIN_MAIL": "a@example.com"}) + result = render.render_template("email: ${ADMIN_MAIL}", {"ADMIN_MAIL": "a@example.com"}) self.assertEqual(result, "email: a@example.com") def test_substitutes_bare_var(self): - result = render.render_template("email: $APPS_ADMIN_MAIL", {"APPS_ADMIN_MAIL": "a@example.com"}) + result = render.render_template("email: $ADMIN_MAIL", {"ADMIN_MAIL": "a@example.com"}) self.assertEqual(result, "email: a@example.com") def test_missing_var_becomes_empty_string(self): - result = render.render_template("email: ${APPS_ADMIN_MAIL}", {}) + result = render.render_template("email: ${ADMIN_MAIL}", {}) self.assertEqual(result, "email: ") def test_leaves_bash_default_syntax_untouched(self): - text = "email: ${APPS_ADMIN_MAIL:-fallback@example.com}" - result = render.render_template(text, {"APPS_ADMIN_MAIL": "a@example.com"}) + text = "email: ${ADMIN_MAIL:-fallback@example.com}" + result = render.render_template(text, {"ADMIN_MAIL": "a@example.com"}) self.assertEqual(result, text) def test_leaves_double_dollar_untouched(self): diff --git a/deploy/tests/test_vault.py b/deploy/tests/test_vault.py index 3df2d3d..a42215e 100644 --- a/deploy/tests/test_vault.py +++ b/deploy/tests/test_vault.py @@ -28,9 +28,9 @@ def fail(stderr="boom"): class DecryptEnvTest(unittest.TestCase): def test_returns_decrypted_stdout(self): calls = [] - run = fake_run(calls, [ok("APPS_DOMAIN=example.com\n")]) + run = fake_run(calls, [ok("DOMAIN=example.com\n")]) plaintext = vault.decrypt_env("traefik.sops.env", "/tmp/key.txt", run=run) - self.assertEqual(plaintext, "APPS_DOMAIN=example.com\n") + self.assertEqual(plaintext, "DOMAIN=example.com\n") def test_passes_sops_decrypt_dotenv_args(self): calls = [] @@ -49,20 +49,20 @@ def test_raises_on_failure(self): class ParseDotenvTest(unittest.TestCase): def test_parses_key_value_pairs(self): - values = vault.parse_dotenv("APPS_DOMAIN=example.com\nHTTP_PORT=80\n") - self.assertEqual(values, {"APPS_DOMAIN": "example.com", "HTTP_PORT": "80"}) + values = vault.parse_dotenv("DOMAIN=example.com\nHTTP_PORT=80\n") + self.assertEqual(values, {"DOMAIN": "example.com", "HTTP_PORT": "80"}) def test_splits_only_on_first_equals(self): - values = vault.parse_dotenv("APPS_HTPASSWD=user:pass=word\n") - self.assertEqual(values["APPS_HTPASSWD"], "user:pass=word") + values = vault.parse_dotenv("HTPASSWD=user:pass=word\n") + self.assertEqual(values["HTPASSWD"], "user:pass=word") def test_ignores_lines_without_equals(self): - values = vault.parse_dotenv("not a valid line\nAPPS_DOMAIN=example.com\n") - self.assertEqual(values, {"APPS_DOMAIN": "example.com"}) + values = vault.parse_dotenv("not a valid line\nDOMAIN=example.com\n") + self.assertEqual(values, {"DOMAIN": "example.com"}) def test_ignores_blank_lines(self): - values = vault.parse_dotenv("\n\nAPPS_DOMAIN=example.com\n") - self.assertEqual(values, {"APPS_DOMAIN": "example.com"}) + values = vault.parse_dotenv("\n\nDOMAIN=example.com\n") + self.assertEqual(values, {"DOMAIN": "example.com"}) if __name__ == "__main__": diff --git a/vaults/hawkeye-rybbit.yml b/vaults/hawkeye-rybbit.yml index 2d9c25e..4557bc7 100644 --- a/vaults/hawkeye-rybbit.yml +++ b/vaults/hawkeye-rybbit.yml @@ -2,7 +2,7 @@ asset: hawkeye-rybbit.sops.env keys: - hawkeye env: - APPS_DOMAIN: DOMAIN - APPS_CERTIFICATE_RESOLVER: CERTIFICATE_RESOLVER - APPS_DATABASE_PASSWORD: DATABASE_PASSWORD - APPS_KEY_HEX_32: KEY_HEX_32 + DOMAIN: DOMAIN + CERTIFICATE_RESOLVER: CERTIFICATE_RESOLVER + DATABASE_PASSWORD: DATABASE_PASSWORD + KEY_HEX_32: KEY_HEX_32 diff --git a/vaults/hawkeye-traefik.yml b/vaults/hawkeye-traefik.yml index 969e842..aeb6ac4 100644 --- a/vaults/hawkeye-traefik.yml +++ b/vaults/hawkeye-traefik.yml @@ -2,7 +2,7 @@ asset: hawkeye-traefik.sops.env keys: - hawkeye env: - APPS_ADMIN_MAIL: ADMIN_MAIL - APPS_CLOUDFLARE_DNS_API_TOKEN: CLOUDFLARE_DNS_API_TOKEN + ADMIN_MAIL: ADMIN_MAIL + CLOUDFLARE_DNS_API_TOKEN: CLOUDFLARE_DNS_API_TOKEN HTTP_PORT: HTTP_PORT HTTPS_PORT: HTTPS_PORT From be3e48000177edfa282776f387046d164287f976 Mon Sep 17 00:00:00 2001 From: Yuriy Kirillov Date: Sun, 23 Aug 2026 02:05:50 +0200 Subject: [PATCH 03/12] fix: keep a TRAEFIK_ prefix on the port GitHub Variables Unlike the other shared vars (ADMIN_MAIL, CLOUDFLARE_DNS_API_TOKEN, DOMAIN, CERTIFICATE_RESOLVER, DATABASE_PASSWORD, KEY_HEX_32- each an inherently target-wide, singular concept), HTTP_PORT/HTTPS_PORT are specific to traefik being the one app that binds host ports directly. A bare HTTP_PORT/HTTPS_PORT GitHub Variable name doesn't self-document which app it belongs to the way the others do. App-side var names in traefik's docker-compose.yml are unaffected - still plain HTTP_PORT/ HTTPS_PORT; only the GitHub Variable name (the vault mapping's right side) gets TRAEFIK_ back. --- vaults/hawkeye-traefik.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/vaults/hawkeye-traefik.yml b/vaults/hawkeye-traefik.yml index aeb6ac4..1c88642 100644 --- a/vaults/hawkeye-traefik.yml +++ b/vaults/hawkeye-traefik.yml @@ -4,5 +4,5 @@ keys: env: ADMIN_MAIL: ADMIN_MAIL CLOUDFLARE_DNS_API_TOKEN: CLOUDFLARE_DNS_API_TOKEN - HTTP_PORT: HTTP_PORT - HTTPS_PORT: HTTPS_PORT + HTTP_PORT: TRAEFIK_HTTP_PORT + HTTPS_PORT: TRAEFIK_HTTPS_PORT From 19992ff21b8346589212290258244f2643cb7b8a Mon Sep 17 00:00:00 2001 From: Yuriy Kirillov Date: Sun, 23 Aug 2026 02:23:22 +0200 Subject: [PATCH 04/12] refactor: drop the unused per-app override fallback pattern ${APPNAME_VAR:-${VAR}} let an app override a shared value while falling back to it - a genuine capability, not just naming cruft, but one nothing in the catalog or in flightdeck's own deploy state ever actually used (grep across every vault: zero). Simplify all four apps that carried it (gatus, codecov, semaphore, twofauth) to reference the shared variable directly. The app-specific-only pattern ({APPNAME}_{VAR} with no fallback, e.g. BESZEL_AGENT_PUBLIC_KEY) is real and stays - only the fallback/override half is gone. Downstream consumers of the apps catalog lose the override option; if one ever needs it, add an app-specific variable name instead of reaching for a fallback nobody exercises today. --- AGENTS.md | 21 ++++----------------- README.md | 9 +-------- apps/codecov/codecov.yml.tpl | 4 ++-- apps/codecov/docker-compose.yml | 8 ++++---- apps/gatus/docker-compose.yml | 4 ++-- apps/semaphore/docker-compose.yml | 4 ++-- apps/twofauth/docker-compose.yml | 4 ++-- 7 files changed, 17 insertions(+), 37 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index de0518b..b017305 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -109,27 +109,14 @@ Compose files mount the rendered file by its plain relative path (`./traefik.yml When adding config for a new app: only reach for a template if the image has no env-var-driven config path at all. If it does (most well-behaved images do), prefer plain `environment:` entries over a template - fewer moving parts, and the value never touches disk as a separate file. -### Per-app and per-server overrides - -Compose files explicitly declare which variables are overridable using bash fallback syntax: - -```yaml -# App-specific override, falls back to server-wide value -SHOWS_PATH: ${JELLYFIN_SHOWS_PATH:-${SHOWS_PATH}} - -# App-specific only — must be set per app -SHOWS_PATH: ${JELLYFIN_SHOWS_PATH} - -# Server-wide — same value for all apps on this server -SHOWS_PATH: ${SHOWS_PATH} -``` +### App-specific vs. shared variables Naming convention: -- `{APPNAME}_{VAR}` — app-specific variable where `{APPNAME}` is the uppercased app directory with hyphens replaced by underscores (e.g. `JELLYFIN_SHOWS_PATH`, `BESZEL_AGENT_PUBLIC_KEY`, `TWOFAUTH_DOMAIN`) -- `{VAR}` (bare, no prefix) — server-wide variable shared across apps (e.g. `DOMAIN`, `TIMEZONE`) +- `{APPNAME}_{VAR}` — app-specific variable where `{APPNAME}` is the uppercased app directory with hyphens replaced by underscores, only that app receives it (e.g. `BESZEL_AGENT_PUBLIC_KEY`) +- `{VAR}` (bare, no prefix) — shared across apps (e.g. `DOMAIN`, `TIMEZONE`) — shared by convention (every vault that maps it feeds the same value to every app referencing it), not by any prefix enforcing it -The compose file is the source of truth for which overrides are allowed. Not every variable needs an app-specific override — only declare one when you actually want to allow it. There's no reserved namespace separating "shared" from "app-specific" bare names - a plain `{VAR}` is shared by convention (every vault that maps it feeds the same value to every app referencing it), not by any prefix enforcing it. +There's no per-app override mechanism (a bash-fallback `${APPNAME_VAR:-${VAR}}` pattern existed here before and was removed - nothing in the catalog ever used it). If an app genuinely needs to deviate from a shared value, give it its own app-specific variable name instead. Common shared variables a target's vaults map into one or more apps' `.env`: diff --git a/README.md b/README.md index d87d6b9..2c04b5d 100644 --- a/README.md +++ b/README.md @@ -99,14 +99,7 @@ This catalog is itself published as its own release asset (`flightdeck-apps.zip` Every app's env comes from its own vault(s), declared in that target's manifest (see "Vaults And Targets" below). A vault declares the exact final variable names an app receives, mapped to GitHub Secret/Variable names - there is no server-side prefix filtering or shared root env file. Two apps' vaults can share a source secret (e.g. both mapping `DOMAIN`) without conflict, since each app ends up with its own separate `.env`. -**Per-app overrides** are declared directly in each app's `docker-compose.yml` using bash fallback syntax: - -```yaml -# App-specific override, falls back to server-wide value -SOME_PATH: ${MYAPP_SOME_PATH:-${SOME_PATH}} -``` - -To use the override for a given deploy, that app's own vault sets `MYAPP_SOME_PATH` in its `env:` mapping; to fall back to the shared value, the vault just omits it and relies on `SOME_PATH` alone. App prefixes are the uppercased app directory with hyphens replaced by underscores. See `AGENTS.md` for the full naming convention. +**App-specific variables** (a value only one app needs) are prefixed with the uppercased app directory name, e.g. `BESZEL_AGENT_PUBLIC_KEY`. See `AGENTS.md` for the full naming convention. ### Network Architecture diff --git a/apps/codecov/codecov.yml.tpl b/apps/codecov/codecov.yml.tpl index c8a76cd..da308c6 100644 --- a/apps/codecov/codecov.yml.tpl +++ b/apps/codecov/codecov.yml.tpl @@ -1,7 +1,7 @@ setup: # Replace with the http location of your Codecov # https://docs.codecov.io/docs/configuration#section-codecov-url - codecov_url: https://${APP_NAME}.${CODECOV_DOMAIN:-${DOMAIN}} + codecov_url: https://${APP_NAME}.${DOMAIN} # codecov_api_url: # this defaults to and is designed to work out of the box like this # api_allowed_hosts: [] # this defaults to and is designed to work out of the box like this # Replace with your Codecov Enterprise License key. This is required for the containers to function. @@ -12,7 +12,7 @@ setup: username: ${CODECOV_ADMIN_GITHUB_USERNAME} http: cookie_secret: ${KEY_HEX_32} # Replace it with a random string - cookies_domain: ${APP_NAME}.${CODECOV_DOMAIN:-${DOMAIN}} + cookies_domain: ${APP_NAME}.${DOMAIN} timeseries: enabled: true guest_access: "off" diff --git a/apps/codecov/docker-compose.yml b/apps/codecov/docker-compose.yml index 019e461..6bd6cfc 100644 --- a/apps/codecov/docker-compose.yml +++ b/apps/codecov/docker-compose.yml @@ -4,9 +4,9 @@ include: - ../postgres-17.yml - ../timescale-17.yml x-environment: &environment - CODECOV_BASE_HOST: ${APP_NAME}.${CODECOV_DOMAIN:-${DOMAIN}} - CODECOV_API_HOST: ${APP_NAME}.${CODECOV_DOMAIN:-${DOMAIN}} - CODECOV_IA_HOST: ${APP_NAME}.${CODECOV_DOMAIN:-${DOMAIN}} + CODECOV_BASE_HOST: ${APP_NAME}.${DOMAIN} + CODECOV_API_HOST: ${APP_NAME}.${DOMAIN} + CODECOV_IA_HOST: ${APP_NAME}.${DOMAIN} CODECOV_SCHEME: https RUN_ENV: ENTERPRISE x-volumes: &volumes @@ -21,7 +21,7 @@ services: - 8080 labels: - "traefik.http.services.${APP_NAME}.loadbalancer.server.port=8080" - - "traefik.http.routers.${APP_NAME}.rule=Host(`${APP_NAME}.${CODECOV_DOMAIN:-${DOMAIN}}`)" + - "traefik.http.routers.${APP_NAME}.rule=Host(`${APP_NAME}.${DOMAIN}`)" depends_on: - api - frontend diff --git a/apps/gatus/docker-compose.yml b/apps/gatus/docker-compose.yml index a069abd..e7376ae 100644 --- a/apps/gatus/docker-compose.yml +++ b/apps/gatus/docker-compose.yml @@ -7,8 +7,8 @@ x-environment: &environment POSTGRES_USER: ${APP_NAME} POSTGRES_DB: ${APP_NAME} POSTGRES_PASSWORD: ${DATABASE_PASSWORD} - TELEGRAM_TOKEN: ${GATUS_TELEGRAM_TOKEN:-${TELEGRAM_TOKEN}} - TELEGRAM_ID: ${GATUS_TELEGRAM_CHAT:-${TELEGRAM_CHAT}} + TELEGRAM_TOKEN: ${TELEGRAM_TOKEN} + TELEGRAM_ID: ${TELEGRAM_CHAT} services: gatus: image: twinproduction/gatus diff --git a/apps/semaphore/docker-compose.yml b/apps/semaphore/docker-compose.yml index 48d9c26..303094d 100644 --- a/apps/semaphore/docker-compose.yml +++ b/apps/semaphore/docker-compose.yml @@ -13,8 +13,8 @@ x-environment: &environment SEMAPHORE_ADMIN_NAME: admin SEMAPHORE_ADMIN_EMAIL: ${ADMIN_MAIL} SEMAPHORE_TELEGRAM_ALERT: true - SEMAPHORE_TELEGRAM_TOKEN: ${SEMAPHORE_TELEGRAM_TOKEN:-${TELEGRAM_TOKEN}} - SEMAPHORE_TELEGRAM_CHAT: ${SEMAPHORE_TELEGRAM_CHAT:-${TELEGRAM_CHAT}} + SEMAPHORE_TELEGRAM_TOKEN: ${TELEGRAM_TOKEN} + SEMAPHORE_TELEGRAM_CHAT: ${TELEGRAM_CHAT} ANSIBLE_HOST_KEY_CHECKING: false services: semaphore: diff --git a/apps/twofauth/docker-compose.yml b/apps/twofauth/docker-compose.yml index 48ccea6..1167fb0 100644 --- a/apps/twofauth/docker-compose.yml +++ b/apps/twofauth/docker-compose.yml @@ -7,7 +7,7 @@ x-environment: &environment APP_DEBUG: false SITE_OWNER: ${ADMIN_MAIL} APP_KEY: ${KEY_HEX_16} - APP_URL: https://${APP_NAME}.${TWOFAUTH_DOMAIN:-${DOMAIN}} + APP_URL: https://${APP_NAME}.${DOMAIN} IS_DEMO_APP: false LOG_CHANNEL: daily LOG_LEVEL: error @@ -45,7 +45,7 @@ services: - 8000 labels: - "traefik.http.services.${APP_NAME}.loadbalancer.server.port=8000" - - "traefik.http.routers.${APP_NAME}.rule=Host(`${APP_NAME}.${TWOFAUTH_DOMAIN:-${DOMAIN}}`)" + - "traefik.http.routers.${APP_NAME}.rule=Host(`${APP_NAME}.${DOMAIN}`)" environment: *environment volumes: - ../../apps-data/${APP_NAME}/2fauth:/2fauth From 788f003e36ddfa2af017f5948841cafb1ac260bb Mon Sep 17 00:00:00 2001 From: Yuriy Kirillov Date: Sun, 23 Aug 2026 02:31:23 +0200 Subject: [PATCH 05/12] refactor: split rybbit's x-environment into vault vs. internal wiring x-environment mixed vault-sourced inputs (DOMAIN, KEY_HEX_32, DATABASE_PASSWORD, the optional DISABLE_*/MAPBOX_TOKEN) with fixed or APP_NAME-derived internal wiring (postgres/clickhouse hostnames, ports, per-app db/user names) in one flat list - no way to tell which env vars this app actually needs from its vault without reading every line. Split into x-vault-env/x-internal-env, merged at the point of use via multi-anchor <<: [*a, *b] (verified docker compose supports this). Verified the resolved config is byte-identical to before this split, aside from the split x- blocks themselves. Trying this on rybbit first before deciding whether to make it a catalog-wide convention. --- apps/rybbit/docker-compose.yml | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/apps/rybbit/docker-compose.yml b/apps/rybbit/docker-compose.yml index b2b90b4..ffe4064 100644 --- a/apps/rybbit/docker-compose.yml +++ b/apps/rybbit/docker-compose.yml @@ -2,22 +2,25 @@ include: - ../networks.yml - ../postgres-18.yml - ../clickhouse-26.5.yml -x-environment: &environment - NODE_ENV: production +# Vault interface - see vaults/*-rybbit.yml's env: mapping +x-vault-env: &vault-env BASE_URL: https://${APP_NAME}.${DOMAIN} BETTER_AUTH_SECRET: ${KEY_HEX_32} DISABLE_SIGNUP: ${DISABLE_SIGNUP:-false} DISABLE_TELEMETRY: ${DISABLE_TELEMETRY:-true} MAPBOX_TOKEN: ${MAPBOX_TOKEN:-} + POSTGRES_PASSWORD: ${DATABASE_PASSWORD} + CLICKHOUSE_PASSWORD: ${DATABASE_PASSWORD} +# Fixed / derived from APP_NAME - internal wiring, not vault-sourced +x-internal-env: &internal-env + NODE_ENV: production POSTGRES_HOST: postgres POSTGRES_PORT: 5432 POSTGRES_DB: ${APP_NAME} POSTGRES_USER: ${APP_NAME} - POSTGRES_PASSWORD: ${DATABASE_PASSWORD} CLICKHOUSE_HOST: http://clickhouse:8123 CLICKHOUSE_DB: ${APP_NAME} CLICKHOUSE_USER: ${APP_NAME} - CLICKHOUSE_PASSWORD: ${DATABASE_PASSWORD} services: client: image: ghcr.io/rybbit-io/rybbit-client @@ -43,7 +46,8 @@ services: - 3001 labels: - "traefik.http.services.${APP_NAME}-api.loadbalancer.server.port=3001" - environment: *environment + environment: + <<: [*vault-env, *internal-env] depends_on: clickhouse: condition: service_healthy From 38cc64a16dc4b27cc0551d02af4a79c603c54902 Mon Sep 17 00:00:00 2001 From: Yuriy Kirillov Date: Sun, 23 Aug 2026 02:32:02 +0200 Subject: [PATCH 06/12] style: drop redundant comments from rybbit's split env anchors x-vault-env/x-internal-env names are self-explanatory. --- apps/rybbit/docker-compose.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/apps/rybbit/docker-compose.yml b/apps/rybbit/docker-compose.yml index ffe4064..6cc2271 100644 --- a/apps/rybbit/docker-compose.yml +++ b/apps/rybbit/docker-compose.yml @@ -2,7 +2,6 @@ include: - ../networks.yml - ../postgres-18.yml - ../clickhouse-26.5.yml -# Vault interface - see vaults/*-rybbit.yml's env: mapping x-vault-env: &vault-env BASE_URL: https://${APP_NAME}.${DOMAIN} BETTER_AUTH_SECRET: ${KEY_HEX_32} @@ -11,7 +10,6 @@ x-vault-env: &vault-env MAPBOX_TOKEN: ${MAPBOX_TOKEN:-} POSTGRES_PASSWORD: ${DATABASE_PASSWORD} CLICKHOUSE_PASSWORD: ${DATABASE_PASSWORD} -# Fixed / derived from APP_NAME - internal wiring, not vault-sourced x-internal-env: &internal-env NODE_ENV: production POSTGRES_HOST: postgres From 7bb5bc1465e90f62eb583d302a2a60e21a2f26b8 Mon Sep 17 00:00:00 2001 From: Yuriy Kirillov Date: Sun, 23 Aug 2026 11:13:45 +0200 Subject: [PATCH 07/12] refactor: split remaining apps' env into x-vault-env/x-internal-env Applies the same split trialed on rybbit to every other app that mixes vault-sourced values with fixed/internal wiring: beszel-agent, codecov, gatus, glitchtip, semaphore, twofauth. traefik and homepage have only vault-sourced vars, so their single anchor is renamed to x-vault-env with no internal-env counterpart, per the "only split when there's actually a mix" rule. Documents the convention in AGENTS.md: when to split vs. use a single appropriately-named anchor, the classification rule (a var is vault-env if its value references any vault-sourced variable, even mixed with ${APP_NAME} or literal text), and updates the Field Ordering Rules' x-fields order and "environment via anchor" principle to match. Verified every changed app's resolved services: config against an isolated empty .env (no real .env files exist in any app directory anymore - see prior cleanup) - correct var counts and no unexpected keys in every case. --- AGENTS.md | 30 ++++++++++++++++++++++++++-- apps/beszel-agent/docker-compose.yml | 8 +++++--- apps/codecov/docker-compose.yml | 9 ++++++--- apps/gatus/docker-compose.yml | 12 ++++++----- apps/glitchtip/docker-compose.yml | 13 +++++++----- apps/homepage/docker-compose.yml | 4 ++-- apps/semaphore/docker-compose.yml | 16 ++++++++------- apps/traefik/docker-compose.yml | 4 ++-- apps/twofauth/docker-compose.yml | 22 ++++++++++---------- 9 files changed, 79 insertions(+), 39 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index b017305..e89913f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -125,6 +125,32 @@ Common shared variables a target's vaults map into one or more apps' `.env`: - `DATABASE_PASSWORD` - Shared database password - `KEY_HEX_{16,32,64}` - Encryption keys for various apps - `TIMEZONE` - System timezone +- `TELEGRAM_TOKEN`, `TELEGRAM_CHAT` - Shared Telegram bot for app-originated alerts + +### Vault-Sourced vs. Internal Environment + +An app's `environment:` mix two different kinds of values: ones that must come from its vault (secrets, domain, feature flags - anything a deploy actually configures) and ones that are fixed or purely derived from `${APP_NAME}` (service hostnames, ports, internal db/user names - values that never change across deploys). Keeping them in one flat `x-environment` block makes it impossible to tell, at a glance, what an app actually needs from its vault. + +When an app has both kinds, split into two anchors instead of one, declared in this order: + +```yaml +x-vault-env: &vault-env + BASE_URL: https://${APP_NAME}.${DOMAIN} + BETTER_AUTH_SECRET: ${KEY_HEX_32} +x-internal-env: &internal-env + NODE_ENV: production + POSTGRES_HOST: postgres +services: + backend: + environment: + <<: [*vault-env, *internal-env] +``` + +Classification rule: a variable goes in `x-vault-env` if its value references *any* vault-sourced variable, even mixed with `${APP_NAME}` or literal text (e.g. `https://${APP_NAME}.${DOMAIN}` is vault-env, because it can't resolve to anything meaningful without `DOMAIN`). It goes in `x-internal-env` if its value is a hardcoded literal or derived only from `${APP_NAME}` - it would be exactly the same regardless of which vault fed the deploy. + +If an app's env is entirely one kind, use a single anchor named for that kind (`x-vault-env` or `x-internal-env`) instead of the generic `x-environment` - don't split into two just to leave one empty. No section comments above the anchors - the names are meant to be self-explanatory. + +`docker compose`'s multi-anchor merge key (`<<: [*a, *b]`) is what makes this work; verified it resolves identically to a single flat block via `docker compose config`. ### Traefik Integration @@ -280,11 +306,11 @@ services: ### Key ordering principles: 1. **Include order**: networks.yml → database/cache templates (postgres/redis/mongodb/etc., pick a version) → others -2. **X-fields order**: x-image → x-environment → x-volumes (only if needed) +2. **X-fields order**: x-image → x-vault-env/x-internal-env (or x-environment if the app's env is entirely one kind - see "Vault-Sourced vs. Internal Environment" above) → x-volumes (only if needed) 3. **X-image for shared images** - if multiple services use the same image, use `x-image: &image` 4. **X-volumes for shared volumes** - if 2+ volumes repeat across services, extract them to `x-volumes: &volumes` and merge with unique ones 5. **Image before extends** - declare what image is used, then extend common config -6. **Environment via anchor** - always use `x-environment: &environment` pattern +6. **Environment via anchor** - always declare env vars in an anchor, referenced with `environment: *name` (or merged via `<<: [*vault-env, *internal-env]` when split) - never inline a service's `environment:` map directly 7. **Networks from extends** - `main`, `main-http`, and `api` profiles include `traefik` and `internal`; never add `databases` (it's only for DB admin tools) 8. **Depends_on as simple list** - use array format without `condition:`, healthchecks are in common.yml 9. **Depends_on order**: postgres → redis → mongo → app services diff --git a/apps/beszel-agent/docker-compose.yml b/apps/beszel-agent/docker-compose.yml index 5fe191f..3169fe0 100644 --- a/apps/beszel-agent/docker-compose.yml +++ b/apps/beszel-agent/docker-compose.yml @@ -1,7 +1,8 @@ -x-environment: &environment +x-vault-env: &vault-env + KEY: ${BESZEL_AGENT_PUBLIC_KEY} +x-internal-env: &internal-env PORT: 45876 HEALTHCHECK_CMD: "nc -z 127.0.0.1 $${PORT} || exit 1" - KEY: ${BESZEL_AGENT_PUBLIC_KEY} services: beszel-agent: image: "henrygd/beszel-agent:alpine" @@ -11,7 +12,8 @@ services: volumes: - /var/run/docker.sock:/var/run/docker.sock:ro - /home:/extra-filesystems/home:ro - environment: *environment + environment: + <<: [*vault-env, *internal-env] cap_add: - SYS_RAWIO # required for S.M.A.R.T. data - SYS_ADMIN # required for NVMe S.M.A.R.T. data diff --git a/apps/codecov/docker-compose.yml b/apps/codecov/docker-compose.yml index 6bd6cfc..60b9aa4 100644 --- a/apps/codecov/docker-compose.yml +++ b/apps/codecov/docker-compose.yml @@ -3,10 +3,11 @@ include: - ../redis-7.yml - ../postgres-17.yml - ../timescale-17.yml -x-environment: &environment +x-vault-env: &vault-env CODECOV_BASE_HOST: ${APP_NAME}.${DOMAIN} CODECOV_API_HOST: ${APP_NAME}.${DOMAIN} CODECOV_IA_HOST: ${APP_NAME}.${DOMAIN} +x-internal-env: &internal-env CODECOV_SCHEME: https RUN_ENV: ENTERPRISE x-volumes: &volumes @@ -31,7 +32,8 @@ services: extends: file: ../common.yml service: side - environment: *environment + environment: + <<: [*vault-env, *internal-env] volumes: *volumes expose: - "8080" @@ -54,7 +56,8 @@ services: - redis - postgres - timescale - environment: *environment + environment: + <<: [*vault-env, *internal-env] volumes: - ./codecov.yml:/config/codecov.yml - ../../apps-data/${APP_NAME}/archive:/archive diff --git a/apps/gatus/docker-compose.yml b/apps/gatus/docker-compose.yml index e7376ae..bfe0d2f 100644 --- a/apps/gatus/docker-compose.yml +++ b/apps/gatus/docker-compose.yml @@ -1,14 +1,15 @@ include: - ../networks.yml - ../postgres-17.yml -x-environment: &environment - GATUS_CONFIG_PATH: /config/yml +x-vault-env: &vault-env TZ: ${TIMEZONE} - POSTGRES_USER: ${APP_NAME} - POSTGRES_DB: ${APP_NAME} POSTGRES_PASSWORD: ${DATABASE_PASSWORD} TELEGRAM_TOKEN: ${TELEGRAM_TOKEN} TELEGRAM_ID: ${TELEGRAM_CHAT} +x-internal-env: &internal-env + GATUS_CONFIG_PATH: /config/yml + POSTGRES_USER: ${APP_NAME} + POSTGRES_DB: ${APP_NAME} services: gatus: image: twinproduction/gatus @@ -19,7 +20,8 @@ services: - 8080 labels: - "traefik.http.services.${APP_NAME}.loadbalancer.server.port=8080" - environment: *environment + environment: + <<: [*vault-env, *internal-env] volumes: - ../../apps-data/${APP_NAME}/config:/config - ./config/global.yml:/config/yml/global.yml diff --git a/apps/glitchtip/docker-compose.yml b/apps/glitchtip/docker-compose.yml index ce6d005..252df2f 100644 --- a/apps/glitchtip/docker-compose.yml +++ b/apps/glitchtip/docker-compose.yml @@ -3,14 +3,15 @@ include: - ../postgres-18.yml - ../redis-8.yml x-image: &image glitchtip/glitchtip -x-environment: &environment +x-vault-env: &vault-env DATABASE_URL: postgresql://${APP_NAME}:${DATABASE_PASSWORD}@postgres:5432/${APP_NAME} - REDIS_URL: redis://redis:6379 SECRET_KEY: ${KEY_HEX_64} - PORT: 8000 EMAIL_URL: ${GLITCHTIP_EMAIL_URL:-consolemail://} GLITCHTIP_DOMAIN: https://${APP_NAME}.${DOMAIN} DEFAULT_FROM_EMAIL: ${GLITCHTIP_DEFAULT_FROM_EMAIL:-noreply@${DOMAIN}} +x-internal-env: &internal-env + REDIS_URL: redis://redis:6379 + PORT: 8000 CELERY_WORKER_AUTOSCALE: "1,3" services: web: @@ -23,7 +24,8 @@ services: labels: - "traefik.http.services.${APP_NAME}.loadbalancer.server.port=8000" command: ["sh", "-c", "./bin/run-migrate.sh && ./bin/start.sh"] - environment: *environment + environment: + <<: [*vault-env, *internal-env] volumes: - ../../apps-data/${APP_NAME}/uploads:/code/uploads depends_on: @@ -35,7 +37,8 @@ services: file: ../common.yml service: side command: ["./bin/run-celery-with-beat.sh"] - environment: *environment + environment: + <<: [*vault-env, *internal-env] volumes: - ../../apps-data/${APP_NAME}/uploads:/code/uploads depends_on: diff --git a/apps/homepage/docker-compose.yml b/apps/homepage/docker-compose.yml index 7d3ccd3..fddfcc7 100644 --- a/apps/homepage/docker-compose.yml +++ b/apps/homepage/docker-compose.yml @@ -1,6 +1,6 @@ include: - ../networks.yml -x-environment: &environment +x-vault-env: &vault-env HOMEPAGE_ALLOWED_HOSTS: ${APP_NAME}.${DOMAIN} services: homepage: @@ -12,6 +12,6 @@ services: - 3000 labels: - "traefik.http.services.${APP_NAME}.loadbalancer.server.port=3000" - environment: *environment + environment: *vault-env volumes: - ../../apps-data/${APP_NAME}/config:/app/config diff --git a/apps/semaphore/docker-compose.yml b/apps/semaphore/docker-compose.yml index 303094d..c90df73 100644 --- a/apps/semaphore/docker-compose.yml +++ b/apps/semaphore/docker-compose.yml @@ -1,20 +1,21 @@ include: - ../networks.yml - ../postgres-17.yml -x-environment: &environment +x-vault-env: &vault-env SEMAPHORE_ACCESS_KEY_ENCRYPTION: ${KEY_HEX_16} + SEMAPHORE_DB_PASS: ${DATABASE_PASSWORD} + SEMAPHORE_ADMIN_PASSWORD: ${ADMIN_DEFAULT_PASS} + SEMAPHORE_ADMIN_EMAIL: ${ADMIN_MAIL} + SEMAPHORE_TELEGRAM_TOKEN: ${TELEGRAM_TOKEN} + SEMAPHORE_TELEGRAM_CHAT: ${TELEGRAM_CHAT} +x-internal-env: &internal-env SEMAPHORE_DB_DIALECT: postgres SEMAPHORE_DB_HOST: postgres SEMAPHORE_DB_NAME: ${APP_NAME} SEMAPHORE_DB_USER: ${APP_NAME} - SEMAPHORE_DB_PASS: ${DATABASE_PASSWORD} SEMAPHORE_ADMIN: admin - SEMAPHORE_ADMIN_PASSWORD: ${ADMIN_DEFAULT_PASS} SEMAPHORE_ADMIN_NAME: admin - SEMAPHORE_ADMIN_EMAIL: ${ADMIN_MAIL} SEMAPHORE_TELEGRAM_ALERT: true - SEMAPHORE_TELEGRAM_TOKEN: ${TELEGRAM_TOKEN} - SEMAPHORE_TELEGRAM_CHAT: ${TELEGRAM_CHAT} ANSIBLE_HOST_KEY_CHECKING: false services: semaphore: @@ -26,6 +27,7 @@ services: - 3000 labels: - "traefik.http.services.${APP_NAME}.loadbalancer.server.port=3000" - environment: *environment + environment: + <<: [*vault-env, *internal-env] depends_on: - postgres diff --git a/apps/traefik/docker-compose.yml b/apps/traefik/docker-compose.yml index 680bbc4..6de97c9 100644 --- a/apps/traefik/docker-compose.yml +++ b/apps/traefik/docker-compose.yml @@ -1,9 +1,9 @@ -x-environment: &environment +x-vault-env: &vault-env CF_DNS_API_TOKEN: ${CLOUDFLARE_DNS_API_TOKEN} services: traefik: image: traefik - environment: *environment + environment: *vault-env ports: - "${HTTP_PORT}:80" - "${HTTPS_PORT}:443" diff --git a/apps/twofauth/docker-compose.yml b/apps/twofauth/docker-compose.yml index 1167fb0..9acadf5 100644 --- a/apps/twofauth/docker-compose.yml +++ b/apps/twofauth/docker-compose.yml @@ -1,13 +1,19 @@ include: - ../networks.yml -x-environment: &environment +x-vault-env: &vault-env + SITE_OWNER: ${ADMIN_MAIL} + APP_KEY: ${KEY_HEX_16} + APP_URL: https://${APP_NAME}.${DOMAIN} + MAIL_HOST: ${SMTP_HOSTNAME} + MAIL_PORT: ${SMTP_PORT} + MAIL_USERNAME: ${SMTP_USERNAME} + MAIL_PASSWORD: ${SMTP_PASSWORD} + MAIL_FROM_ADDRESS: ${SMTP_FROM} +x-internal-env: &internal-env APP_NAME: 2FAuth APP_ENV: local APP_TIMEZONE: UTC APP_DEBUG: false - SITE_OWNER: ${ADMIN_MAIL} - APP_KEY: ${KEY_HEX_16} - APP_URL: https://${APP_NAME}.${DOMAIN} IS_DEMO_APP: false LOG_CHANNEL: daily LOG_LEVEL: error @@ -15,13 +21,8 @@ x-environment: &environment CACHE_DRIVER: file SESSION_DRIVER: file MAIL_MAILER: smtp - MAIL_HOST: ${SMTP_HOSTNAME} - MAIL_PORT: ${SMTP_PORT} - MAIL_USERNAME: ${SMTP_USERNAME} - MAIL_PASSWORD: ${SMTP_PASSWORD} MAIL_ENCRYPTION: true MAIL_FROM_NAME: ${APP_NAME} - MAIL_FROM_ADDRESS: ${SMTP_FROM} MAIL_VERIFY_SSL_PEER: false THROTTLE_API: 60 LOGIN_THROTTLE: 5 @@ -46,6 +47,7 @@ services: labels: - "traefik.http.services.${APP_NAME}.loadbalancer.server.port=8000" - "traefik.http.routers.${APP_NAME}.rule=Host(`${APP_NAME}.${DOMAIN}`)" - environment: *environment + environment: + <<: [*vault-env, *internal-env] volumes: - ../../apps-data/${APP_NAME}/2fauth:/2fauth From a37cbf44f37f55f218fa7d9a1a2c1e32ffa784ac Mon Sep 17 00:00:00 2001 From: Yuriy Kirillov Date: Sun, 23 Aug 2026 11:16:19 +0200 Subject: [PATCH 08/12] chore: drop dead HEALTHCHECK_CMD from beszel-agent The compose file already overrides the container's healthcheck explicitly (test: ["CMD", "/agent", "health"]) - Docker never consults the image's built-in HEALTHCHECK when one is set at the compose level, so this env var (presumably meant to feed that built-in check) was never actually read. Only app with this var. --- apps/beszel-agent/docker-compose.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/beszel-agent/docker-compose.yml b/apps/beszel-agent/docker-compose.yml index 3169fe0..9439439 100644 --- a/apps/beszel-agent/docker-compose.yml +++ b/apps/beszel-agent/docker-compose.yml @@ -2,7 +2,6 @@ x-vault-env: &vault-env KEY: ${BESZEL_AGENT_PUBLIC_KEY} x-internal-env: &internal-env PORT: 45876 - HEALTHCHECK_CMD: "nc -z 127.0.0.1 $${PORT} || exit 1" services: beszel-agent: image: "henrygd/beszel-agent:alpine" From 268064ea2fa1f4532949e57a51509498b20a4341 Mon Sep 17 00:00:00 2001 From: Yuriy Kirillov Date: Sun, 23 Aug 2026 11:21:44 +0200 Subject: [PATCH 09/12] refactor: extract beszel-agent's devices into x-vault-devices BESZEL_AGENT_DISK_1_DEVICE/_2_DEVICE are vault-configurable inputs (same {APPNAME}_{VAR} pattern as any other app-specific vault var), but they lived directly in devices:, invisible to the x-vault-env convention meant to make an app's vault interface visible at a glance. A YAML anchor isn't limited to holding a map - split it into x-vault-devices: &vault-devices (a list) and reference it directly with devices: *vault-devices. Verified duplicate /dev/null:/dev/null device entries (the default when neither var is set) don't break container startup - tested with both `docker run --device` and `docker compose up` directly; compose config even dedupes identical entries on its own. Documented the general pattern in AGENTS.md: x-vault-{field} for any non-environment compose field with vault-sourced values, not just x-vault-env. --- AGENTS.md | 2 ++ apps/beszel-agent/docker-compose.yml | 7 ++++--- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index e89913f..3447e65 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -152,6 +152,8 @@ If an app's env is entirely one kind, use a single anchor named for that kind (` `docker compose`'s multi-anchor merge key (`<<: [*a, *b]`) is what makes this work; verified it resolves identically to a single flat block via `docker compose config`. +The same split applies to non-`environment` fields too, whenever an app has vault-configurable values there - e.g. `apps/beszel-agent/docker-compose.yml`'s `devices:` list is vault-sourced (`BESZEL_AGENT_DISK_1_DEVICE`/`_2_DEVICE`, each defaulting to `/dev/null` when unset), so it's declared as `x-vault-devices: &vault-devices` and referenced with `devices: *vault-devices`. A YAML anchor isn't limited to a map - it can hold a list just as well. Name the anchor for the compose field it targets (`x-vault-{field}`) so it's still obvious at a glance which fields the app's vault interface actually touches. + ### Traefik Integration All apps use Traefik labels pattern: diff --git a/apps/beszel-agent/docker-compose.yml b/apps/beszel-agent/docker-compose.yml index 9439439..924434f 100644 --- a/apps/beszel-agent/docker-compose.yml +++ b/apps/beszel-agent/docker-compose.yml @@ -2,6 +2,9 @@ x-vault-env: &vault-env KEY: ${BESZEL_AGENT_PUBLIC_KEY} x-internal-env: &internal-env PORT: 45876 +x-vault-devices: &vault-devices + - ${BESZEL_AGENT_DISK_1_DEVICE:-/dev/null}:${BESZEL_AGENT_DISK_1_DEVICE:-/dev/null} + - ${BESZEL_AGENT_DISK_2_DEVICE:-/dev/null}:${BESZEL_AGENT_DISK_2_DEVICE:-/dev/null} services: beszel-agent: image: "henrygd/beszel-agent:alpine" @@ -16,9 +19,7 @@ services: cap_add: - SYS_RAWIO # required for S.M.A.R.T. data - SYS_ADMIN # required for NVMe S.M.A.R.T. data - devices: - - ${BESZEL_AGENT_DISK_1_DEVICE:-/dev/null}:${BESZEL_AGENT_DISK_1_DEVICE:-/dev/null} - - ${BESZEL_AGENT_DISK_2_DEVICE:-/dev/null}:${BESZEL_AGENT_DISK_2_DEVICE:-/dev/null} + devices: *vault-devices healthcheck: test: ["CMD", "/agent", "health"] start_period: 30s From 0c365c687e6cbeb4a0707d2eb0ff6e6366ba4f58 Mon Sep 17 00:00:00 2001 From: Yuriy Kirillov Date: Sun, 23 Aug 2026 11:24:18 +0200 Subject: [PATCH 10/12] style: group all x-vault-* anchors before x-internal-* anchors beszel-agent had x-internal-env sandwiched between x-vault-env and x-vault-devices. Vault-sourced anchors should stay grouped together regardless of which compose field they target, so the vault interface reads as one contiguous block at the top of the file. Documented in AGENTS.md's Field Ordering Rules. --- AGENTS.md | 2 +- apps/beszel-agent/docker-compose.yml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 3447e65..ed70f42 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -308,7 +308,7 @@ services: ### Key ordering principles: 1. **Include order**: networks.yml → database/cache templates (postgres/redis/mongodb/etc., pick a version) → others -2. **X-fields order**: x-image → x-vault-env/x-internal-env (or x-environment if the app's env is entirely one kind - see "Vault-Sourced vs. Internal Environment" above) → x-volumes (only if needed) +2. **X-fields order**: x-image → all `x-vault-*` anchors together → all `x-internal-*` anchors together → x-volumes (only if needed). Vault-sourced anchors are grouped first regardless of which compose field they target (e.g. `x-vault-env` then `x-vault-devices`, both before `x-internal-env`) - see "Vault-Sourced vs. Internal Environment" above 3. **X-image for shared images** - if multiple services use the same image, use `x-image: &image` 4. **X-volumes for shared volumes** - if 2+ volumes repeat across services, extract them to `x-volumes: &volumes` and merge with unique ones 5. **Image before extends** - declare what image is used, then extend common config diff --git a/apps/beszel-agent/docker-compose.yml b/apps/beszel-agent/docker-compose.yml index 924434f..eed045f 100644 --- a/apps/beszel-agent/docker-compose.yml +++ b/apps/beszel-agent/docker-compose.yml @@ -1,10 +1,10 @@ x-vault-env: &vault-env KEY: ${BESZEL_AGENT_PUBLIC_KEY} -x-internal-env: &internal-env - PORT: 45876 x-vault-devices: &vault-devices - ${BESZEL_AGENT_DISK_1_DEVICE:-/dev/null}:${BESZEL_AGENT_DISK_1_DEVICE:-/dev/null} - ${BESZEL_AGENT_DISK_2_DEVICE:-/dev/null}:${BESZEL_AGENT_DISK_2_DEVICE:-/dev/null} +x-internal-env: &internal-env + PORT: 45876 services: beszel-agent: image: "henrygd/beszel-agent:alpine" From 37a7fea4d5fbbee96671d572c2c41632ef6de483 Mon Sep 17 00:00:00 2001 From: Yuriy Kirillov Date: Sun, 23 Aug 2026 11:29:40 +0200 Subject: [PATCH 11/12] refactor: drop app-name prefixes from variables inside compose files Every compose/*.tpl file is already scoped to exactly one app, so a BESZEL_AGENT_/CODECOV_/GLITCHTIP_-style prefix inside it disambiguates nothing - it only ever mattered at the vault-to-GitHub-Secret mapping layer, where a flat namespace across all of a target's apps genuinely can collide (kept e.g. TRAEFIK_HTTP_PORT there for exactly that reason). Renamed every app-name-prefixed variable reference to its bare form: beszel-agent's PUBLIC_KEY/DISK_1_DEVICE/DISK_2_DEVICE, codecov.yml.tpl's LICENSE/ADMIN_GITHUB_USERNAME/GITHUB_CLIENT_ID/ GITHUB_CLIENT_SECRET/GITHUB_WEBHOOK_SECRET/GITHUB_APP_ID/S3_BUCKET, glitchtip's EMAIL_URL/DEFAULT_FROM_EMAIL. Rewrote AGENTS.md's "App-specific vs. shared variables" and README's matching paragraph: what used to be framed as a compose-file-level {APPNAME}_{VAR} vs {VAR} naming rule is now correctly framed as a vault-manifest-level judgment call (whether a GitHub Secret/Variable name needs its app spelled out to be legible in a flat per-target list) - the compose file itself never needs to know or care. Verified: full deploy/ test suite (48 tests), pre-commit, and `docker compose config` for all three changed apps against an isolated empty .env. --- AGENTS.md | 11 ++++++----- README.md | 2 +- apps/beszel-agent/docker-compose.yml | 6 +++--- apps/codecov/codecov.yml.tpl | 14 +++++++------- apps/glitchtip/docker-compose.yml | 4 ++-- 5 files changed, 19 insertions(+), 18 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ed70f42..e12bc9f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -111,12 +111,13 @@ When adding config for a new app: only reach for a template if the image has no ### App-specific vs. shared variables -Naming convention: +Inside a `docker-compose.yml`/`*.tpl` file, variable names are always bare - never prefixed with the app's own name. Each app already has its own compose file and its own generated `.env`, so an app-name prefix inside it would disambiguate nothing; `${PUBLIC_KEY}` in `apps/beszel-agent/docker-compose.yml` and `${DOMAIN}` shared across a dozen apps look exactly the same from inside the file, because the file is already scoped to one app. -- `{APPNAME}_{VAR}` — app-specific variable where `{APPNAME}` is the uppercased app directory with hyphens replaced by underscores, only that app receives it (e.g. `BESZEL_AGENT_PUBLIC_KEY`) -- `{VAR}` (bare, no prefix) — shared across apps (e.g. `DOMAIN`, `TIMEZONE`) — shared by convention (every vault that maps it feeds the same value to every app referencing it), not by any prefix enforcing it +What makes a variable "shared" vs. "app-specific" is a fact about the *vault*, not the compose file: whether multiple apps' vaults map the same variable name to the same GitHub Secret/Variable, or only one app's vault ever references it at all. -There's no per-app override mechanism (a bash-fallback `${APPNAME_VAR:-${VAR}}` pattern existed here before and was removed - nothing in the catalog ever used it). If an app genuinely needs to deviate from a shared value, give it its own app-specific variable name instead. +App-name prefixing happens on the *other* side of a vault manifest's `env:` mapping - the GitHub Secret/Variable name - and only as a judgment call when it helps a human scanning a flat list of a target's Secrets/Variables tell which app a value belongs to (e.g. `TRAEFIK_HTTP_PORT` keeps its prefix because a bare `HTTP_PORT` wouldn't self-document; `ADMIN_MAIL` doesn't need one because it doesn't need explaining). See "Vaults And Targets" in README for the manifest format. + +There's no per-app override mechanism (a bash-fallback `${APPNAME_VAR:-${VAR}}` pattern existed here before and was removed - nothing in the catalog ever used it). If an app genuinely needs a value another app also uses but with a different value, give it its own distinctly-named variable instead - not a namespaced variant of the same name. Common shared variables a target's vaults map into one or more apps' `.env`: @@ -152,7 +153,7 @@ If an app's env is entirely one kind, use a single anchor named for that kind (` `docker compose`'s multi-anchor merge key (`<<: [*a, *b]`) is what makes this work; verified it resolves identically to a single flat block via `docker compose config`. -The same split applies to non-`environment` fields too, whenever an app has vault-configurable values there - e.g. `apps/beszel-agent/docker-compose.yml`'s `devices:` list is vault-sourced (`BESZEL_AGENT_DISK_1_DEVICE`/`_2_DEVICE`, each defaulting to `/dev/null` when unset), so it's declared as `x-vault-devices: &vault-devices` and referenced with `devices: *vault-devices`. A YAML anchor isn't limited to a map - it can hold a list just as well. Name the anchor for the compose field it targets (`x-vault-{field}`) so it's still obvious at a glance which fields the app's vault interface actually touches. +The same split applies to non-`environment` fields too, whenever an app has vault-configurable values there - e.g. `apps/beszel-agent/docker-compose.yml`'s `devices:` list is vault-sourced (`DISK_1_DEVICE`/`DISK_2_DEVICE`, each defaulting to `/dev/null` when unset), so it's declared as `x-vault-devices: &vault-devices` and referenced with `devices: *vault-devices`. A YAML anchor isn't limited to a map - it can hold a list just as well. Name the anchor for the compose field it targets (`x-vault-{field}`) so it's still obvious at a glance which fields the app's vault interface actually touches. ### Traefik Integration diff --git a/README.md b/README.md index 2c04b5d..1710c4e 100644 --- a/README.md +++ b/README.md @@ -99,7 +99,7 @@ This catalog is itself published as its own release asset (`flightdeck-apps.zip` Every app's env comes from its own vault(s), declared in that target's manifest (see "Vaults And Targets" below). A vault declares the exact final variable names an app receives, mapped to GitHub Secret/Variable names - there is no server-side prefix filtering or shared root env file. Two apps' vaults can share a source secret (e.g. both mapping `DOMAIN`) without conflict, since each app ends up with its own separate `.env`. -**App-specific variables** (a value only one app needs) are prefixed with the uppercased app directory name, e.g. `BESZEL_AGENT_PUBLIC_KEY`. See `AGENTS.md` for the full naming convention. +Variable names inside a compose file are always bare, never prefixed with the app's own name - each compose file is already scoped to one app. Whether a name is "shared" or app-specific only matters on the vault side (whether more than one app's vault maps it). See `AGENTS.md` for the full naming convention. ### Network Architecture diff --git a/apps/beszel-agent/docker-compose.yml b/apps/beszel-agent/docker-compose.yml index eed045f..865d6c9 100644 --- a/apps/beszel-agent/docker-compose.yml +++ b/apps/beszel-agent/docker-compose.yml @@ -1,8 +1,8 @@ x-vault-env: &vault-env - KEY: ${BESZEL_AGENT_PUBLIC_KEY} + KEY: ${PUBLIC_KEY} x-vault-devices: &vault-devices - - ${BESZEL_AGENT_DISK_1_DEVICE:-/dev/null}:${BESZEL_AGENT_DISK_1_DEVICE:-/dev/null} - - ${BESZEL_AGENT_DISK_2_DEVICE:-/dev/null}:${BESZEL_AGENT_DISK_2_DEVICE:-/dev/null} + - ${DISK_1_DEVICE:-/dev/null}:${DISK_1_DEVICE:-/dev/null} + - ${DISK_2_DEVICE:-/dev/null}:${DISK_2_DEVICE:-/dev/null} x-internal-env: &internal-env PORT: 45876 services: diff --git a/apps/codecov/codecov.yml.tpl b/apps/codecov/codecov.yml.tpl index da308c6..f70399e 100644 --- a/apps/codecov/codecov.yml.tpl +++ b/apps/codecov/codecov.yml.tpl @@ -6,10 +6,10 @@ setup: # api_allowed_hosts: [] # this defaults to and is designed to work out of the box like this # Replace with your Codecov Enterprise License key. This is required for the containers to function. # https://docs.codecov.io/docs/configuration#section-enterprise-license - enterprise_license: ${CODECOV_LICENSE} + enterprise_license: ${LICENSE} admins: # https://docs.codecov.com/docs/configuration#instance-wide-admins - service: github - username: ${CODECOV_ADMIN_GITHUB_USERNAME} + username: ${ADMIN_GITHUB_USERNAME} http: cookie_secret: ${KEY_HEX_32} # Replace it with a random string cookies_domain: ${APP_NAME}.${DOMAIN} @@ -17,11 +17,11 @@ setup: enabled: true guest_access: "off" github: - client_id: ${CODECOV_GITHUB_CLIENT_ID} - client_secret: ${CODECOV_GITHUB_CLIENT_SECRET} - webhook_secret: ${CODECOV_GITHUB_WEBHOOK_SECRET} + client_id: ${GITHUB_CLIENT_ID} + client_secret: ${GITHUB_CLIENT_SECRET} + webhook_secret: ${GITHUB_WEBHOOK_SECRET} integration: - id: ${CODECOV_GITHUB_APP_ID} + id: ${GITHUB_APP_ID} pem: /config/key.pem services: redis_url: "redis://redis:6379" @@ -29,7 +29,7 @@ services: timeseries_database_url: "postgres://${APP_NAME}:${DATABASE_PASSWORD}@timescale:5432/${APP_NAME}" minio: host: ${S3_HOST} - bucket: ${CODECOV_S3_BUCKET} + bucket: ${S3_BUCKET} region: ${S3_REGION} verify_ssl: true port: 443 diff --git a/apps/glitchtip/docker-compose.yml b/apps/glitchtip/docker-compose.yml index 252df2f..c38b9a4 100644 --- a/apps/glitchtip/docker-compose.yml +++ b/apps/glitchtip/docker-compose.yml @@ -6,9 +6,9 @@ x-image: &image glitchtip/glitchtip x-vault-env: &vault-env DATABASE_URL: postgresql://${APP_NAME}:${DATABASE_PASSWORD}@postgres:5432/${APP_NAME} SECRET_KEY: ${KEY_HEX_64} - EMAIL_URL: ${GLITCHTIP_EMAIL_URL:-consolemail://} + EMAIL_URL: ${EMAIL_URL:-consolemail://} GLITCHTIP_DOMAIN: https://${APP_NAME}.${DOMAIN} - DEFAULT_FROM_EMAIL: ${GLITCHTIP_DEFAULT_FROM_EMAIL:-noreply@${DOMAIN}} + DEFAULT_FROM_EMAIL: ${DEFAULT_FROM_EMAIL:-noreply@${DOMAIN}} x-internal-env: &internal-env REDIS_URL: redis://redis:6379 PORT: 8000 From f26612678fe0c19ff1400704ef5835e15405bab9 Mon Sep 17 00:00:00 2001 From: Yuriy Kirillov Date: Sun, 23 Aug 2026 12:46:54 +0200 Subject: [PATCH 12/12] refactor: retire KEY_HEX_{16,32,64}, name keys by purpose not format Grouping keys by byte-length rather than role had a real consequence: twofauth and semaphore both drew from KEY_HEX_16, meaning two apps with completely unrelated encryption needs would silently share the same secret value if deployed together. Split by actual risk profile instead: - Session-signing keys (rybbit's BETTER_AUTH_SECRET, glitchtip's Django SECRET_KEY, codecov's cookie_secret) are safe to share - compromising one just forces re-authentication everywhere. One shared name and vault mapping: SESSION_KEY. - Encryption keys (twofauth's Laravel APP_KEY, semaphore's stored- credential encryption) must never be shared - compromising or rotating one can make already-stored ciphertext unrecoverable in a way that isn't reversible by re-login. Same semantic app-side name (ENCRYPTION_KEY) so the role is still obvious from the compose file, but each app's vault maps it to its own distinct, app- prefixed GitHub Secret (TWOFAUTH_ENCRYPTION_KEY, SEMAPHORE_ENCRYPTION_KEY) once either is actually deployed. Updates vaults/hawkeye-rybbit.yml (KEY_HEX_32 -> SESSION_KEY) since rybbit is the only one of these actually live. Documents the by-purpose-not-format naming principle in AGENTS.md. The live GH secret still needs the matching rename (KEY_HEX_32 -> SESSION_KEY) - that's outside git, done separately. --- AGENTS.md | 6 ++++-- apps/codecov/codecov.yml.tpl | 2 +- apps/glitchtip/docker-compose.yml | 2 +- apps/rybbit/docker-compose.yml | 2 +- apps/semaphore/docker-compose.yml | 2 +- apps/twofauth/docker-compose.yml | 2 +- deploy/tests/test_collisions.py | 4 ++-- vaults/hawkeye-rybbit.yml | 2 +- 8 files changed, 12 insertions(+), 10 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index e12bc9f..a4ee7ac 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -119,12 +119,14 @@ App-name prefixing happens on the *other* side of a vault manifest's `env:` mapp There's no per-app override mechanism (a bash-fallback `${APPNAME_VAR:-${VAR}}` pattern existed here before and was removed - nothing in the catalog ever used it). If an app genuinely needs a value another app also uses but with a different value, give it its own distinctly-named variable instead - not a namespaced variant of the same name. +Name a variable by what it *is*, never by its format (`SESSION_KEY`, not `KEY_HEX_32`) - a format-shaped name invites treating same-format secrets as interchangeable across apps when they aren't. This matters concretely for keys: a **session-signing key** (Better Auth, Django's `SECRET_KEY` - only ever used to sign/verify sessions and CSRF tokens) is safe to genuinely share across apps, since compromising it just forces every app's active sessions to re-authenticate - so it gets one shared name (`SESSION_KEY`) and one shared vault mapping. An **encryption key** (Laravel's `APP_KEY`, Semaphore's access-key encryption - used to encrypt stored data) must never be shared, since compromising or rotating it can make one app's already-stored ciphertext unrecoverable, and that risk shouldn't leak to a second app sharing the same key. Give each app's encryption key the same semantic app-side name (`ENCRYPTION_KEY`) so its role is still obvious from the compose file, but map it to a distinct, app-prefixed GitHub Secret per app (`TWOFAUTH_ENCRYPTION_KEY`, `SEMAPHORE_ENCRYPTION_KEY`) so the values themselves are never shared. + Common shared variables a target's vaults map into one or more apps' `.env`: - `DOMAIN` - Base domain for all services - `CERTIFICATE_RESOLVER` - SSL resolver (Cloudflare DNS or HTTP challenge) - `DATABASE_PASSWORD` - Shared database password -- `KEY_HEX_{16,32,64}` - Encryption keys for various apps +- `SESSION_KEY` - Shared session-signing key (safe to share - see above) - `TIMEZONE` - System timezone - `TELEGRAM_TOKEN`, `TELEGRAM_CHAT` - Shared Telegram bot for app-originated alerts @@ -137,7 +139,7 @@ When an app has both kinds, split into two anchors instead of one, declared in t ```yaml x-vault-env: &vault-env BASE_URL: https://${APP_NAME}.${DOMAIN} - BETTER_AUTH_SECRET: ${KEY_HEX_32} + BETTER_AUTH_SECRET: ${SESSION_KEY} x-internal-env: &internal-env NODE_ENV: production POSTGRES_HOST: postgres diff --git a/apps/codecov/codecov.yml.tpl b/apps/codecov/codecov.yml.tpl index f70399e..92de082 100644 --- a/apps/codecov/codecov.yml.tpl +++ b/apps/codecov/codecov.yml.tpl @@ -11,7 +11,7 @@ setup: - service: github username: ${ADMIN_GITHUB_USERNAME} http: - cookie_secret: ${KEY_HEX_32} # Replace it with a random string + cookie_secret: ${SESSION_KEY} # Replace it with a random string cookies_domain: ${APP_NAME}.${DOMAIN} timeseries: enabled: true diff --git a/apps/glitchtip/docker-compose.yml b/apps/glitchtip/docker-compose.yml index c38b9a4..d4fff11 100644 --- a/apps/glitchtip/docker-compose.yml +++ b/apps/glitchtip/docker-compose.yml @@ -5,7 +5,7 @@ include: x-image: &image glitchtip/glitchtip x-vault-env: &vault-env DATABASE_URL: postgresql://${APP_NAME}:${DATABASE_PASSWORD}@postgres:5432/${APP_NAME} - SECRET_KEY: ${KEY_HEX_64} + SECRET_KEY: ${SESSION_KEY} EMAIL_URL: ${EMAIL_URL:-consolemail://} GLITCHTIP_DOMAIN: https://${APP_NAME}.${DOMAIN} DEFAULT_FROM_EMAIL: ${DEFAULT_FROM_EMAIL:-noreply@${DOMAIN}} diff --git a/apps/rybbit/docker-compose.yml b/apps/rybbit/docker-compose.yml index 6cc2271..05d51d4 100644 --- a/apps/rybbit/docker-compose.yml +++ b/apps/rybbit/docker-compose.yml @@ -4,7 +4,7 @@ include: - ../clickhouse-26.5.yml x-vault-env: &vault-env BASE_URL: https://${APP_NAME}.${DOMAIN} - BETTER_AUTH_SECRET: ${KEY_HEX_32} + BETTER_AUTH_SECRET: ${SESSION_KEY} DISABLE_SIGNUP: ${DISABLE_SIGNUP:-false} DISABLE_TELEMETRY: ${DISABLE_TELEMETRY:-true} MAPBOX_TOKEN: ${MAPBOX_TOKEN:-} diff --git a/apps/semaphore/docker-compose.yml b/apps/semaphore/docker-compose.yml index c90df73..de0e682 100644 --- a/apps/semaphore/docker-compose.yml +++ b/apps/semaphore/docker-compose.yml @@ -2,7 +2,7 @@ include: - ../networks.yml - ../postgres-17.yml x-vault-env: &vault-env - SEMAPHORE_ACCESS_KEY_ENCRYPTION: ${KEY_HEX_16} + SEMAPHORE_ACCESS_KEY_ENCRYPTION: ${ENCRYPTION_KEY} SEMAPHORE_DB_PASS: ${DATABASE_PASSWORD} SEMAPHORE_ADMIN_PASSWORD: ${ADMIN_DEFAULT_PASS} SEMAPHORE_ADMIN_EMAIL: ${ADMIN_MAIL} diff --git a/apps/twofauth/docker-compose.yml b/apps/twofauth/docker-compose.yml index 9acadf5..3cf8e9e 100644 --- a/apps/twofauth/docker-compose.yml +++ b/apps/twofauth/docker-compose.yml @@ -2,7 +2,7 @@ include: - ../networks.yml x-vault-env: &vault-env SITE_OWNER: ${ADMIN_MAIL} - APP_KEY: ${KEY_HEX_16} + APP_KEY: ${ENCRYPTION_KEY} APP_URL: https://${APP_NAME}.${DOMAIN} MAIL_HOST: ${SMTP_HOSTNAME} MAIL_PORT: ${SMTP_PORT} diff --git a/deploy/tests/test_collisions.py b/deploy/tests/test_collisions.py index 50db97c..599cb6d 100644 --- a/deploy/tests/test_collisions.py +++ b/deploy/tests/test_collisions.py @@ -21,7 +21,7 @@ RYBBIT_ENV = """\ DATABASE_PASSWORD=ENC[AES256_GCM,data:Ij==,iv:xx==,tag:yy==,type:str] -KEY_HEX_32=ENC[AES256_GCM,data:Kl==,iv:xx==,tag:yy==,type:str] +SESSION_KEY=ENC[AES256_GCM,data:Kl==,iv:xx==,tag:yy==,type:str] sops_lastmodified=2026-08-19T00:00:00Z sops_version=3.13.1 """ @@ -59,7 +59,7 @@ def test_ignores_sops_metadata_keys(self): keys = collisions.extract_keys(traefik) | collisions.extract_keys(rybbit) self.assertFalse(any(key.startswith("sops_") for key in keys)) self.assertIn("HTTP_PORT", keys) - self.assertIn("KEY_HEX_32", keys) + self.assertIn("SESSION_KEY", keys) if __name__ == "__main__": diff --git a/vaults/hawkeye-rybbit.yml b/vaults/hawkeye-rybbit.yml index 4557bc7..c199b28 100644 --- a/vaults/hawkeye-rybbit.yml +++ b/vaults/hawkeye-rybbit.yml @@ -5,4 +5,4 @@ env: DOMAIN: DOMAIN CERTIFICATE_RESOLVER: CERTIFICATE_RESOLVER DATABASE_PASSWORD: DATABASE_PASSWORD - KEY_HEX_32: KEY_HEX_32 + SESSION_KEY: SESSION_KEY