ADR 0028 Keep each environment s secrets in its GitHub environment - AnnabiGihed/RaidManager GitHub Wiki
Generated from
docs/adr/0028-keep-the-test-secrets-in-a-github-environment.md. Edit that file in the repository: changes made in the wiki are overwritten.
- Status: Proposed
- Date: 2026-10-03
- Deciders: Gihed Annabi
- Amended: 2026-10-03, by #386, while still Proposed, for the dev, test and production environments (owner decisions on #369, #409 and #386)
The first version kept one test environment's secrets. The owner then decided on three environments on the shared server (#369), PostgreSQL with one instance each (ADR-0029), one deploy key per environment, and release tags for test and production (#386). The amendment applies the same store to all three.
Locally, every secret lives in the Aspire AppHost's user secrets (README, "Sign in with Discord locally"). Release
v0.3 deploys RaidManager to the shared OVH server, with Docker Compose files that aspire publish generates from the
AppHost, through a GitHub Actions workflow that copies images over SSH
(ADR-0027). There are three environments (#369):
| Environment | Deployed | Website |
|---|---|---|
| Dev | on every merge to main
|
raidmanager-dev.pivotsoftwares.com |
| Test | on every release, with dev | raidmanager-test.pivotsoftwares.com |
| Production | when a release is tested and confirmed | raidmanager.pivotsoftwares.com |
Spike #103 asked where deployed secrets are kept, how they reach the containers, how the deploy workflow signs in to the server, and how each secret is rotated. The server is one VPS with no cloud identity service in front of it. The secrets in scope, for each environment:
| Secret | Used by | Source locally |
|---|---|---|
| Discord OAuth client secret (and the client id, sensitive but not secret) | Website | AppHost parameters discord-client-secret, discord-client-id
|
| Discord bot token | API (ADR-0022), later the bot | AppHost parameter discord-bot-token
|
| Website key (ADR-0011) | Website and API | AppHost parameter website-service-key, generated |
| Database password | PostgreSQL, API | AppHost parameter postgres-password, generated by Aspire |
| Pivot.Framework feed token | Image builds | Repository secret PIVOT_PACKAGES_TOKEN
|
| Deploy credential for the server | Deploy workflow | None yet |
-
Each environment's secrets are environment secrets of its own GitHub environment:
dev,testandproduction(owner decision on #103, extended on #369). GitHub encrypts them, nobody can read them back, and only a workflow job that targets that environment receives them. Values that aren't secret are environment variables, so they stay readable. -
What may deploy (owner decision on #386), enforced by each environment's deployment rule:
GitHub environment Accepts devthe mainbranch onlytesttags matching v*onlyproductiontags matching v*only; how a tag is confirmed for production is spike #432's decisionA pull request's workflow never runs from
mainor a release tag, so it never receives a deployed secret. -
The owner enters every value, on their own machine, with
gh secret set <name> --env <environment>, which reads the value from the prompt and not from the command line. The website key and the database password are random values the owner generates there, for example withopenssl rand -base64 48. No value passes through the repository, an issue or chat, and the agent never handles one. -
The same names in every environment, after the keys that
aspire publishwrites into the.envtemplate, so one workflow serves all three:AppHost parameter Name in each environment Kind discord-client-idDISCORD_CLIENT_IDvariable discord-client-secretDISCORD_CLIENT_SECRETsecret discord-bot-tokenDISCORD_BOT_TOKENsecret website-service-keyWEBSITE_SERVICE_KEYsecret postgres-passwordPOSTGRES_PASSWORDsecret none (deploy workflow) DEPLOY_SSH_KEYsecret none (deploy workflow) DEPLOY_HOST,DEPLOY_SSH_KNOWN_HOSTSvariables If the generated template names a key differently, #389 follows the template and corrects this table.
- Each environment is its own Compose project in its own folder:
/opt/apps/raidmanager/dev,/testand/production. At each deployment, the workflow (#389) writes that folder's.envfrom its GitHub environment, then runsdocker compose up -d, which passes the values to the containers as environment variables. - The values travel only through the SSH connection's input: the job puts them in
env:and pipes them toumask 077; cat > .env.new; mv .env.new .envon the server. They never appear in a command line, a${{ }}expression insiderun:, the runner's disk or the job's log, where GitHub also masks them. - The files are owned by
deployand readable bydeployonly (mode 600). Members of thedockergroup can see a container's environment withdocker inspect, but that group is root-equivalent on the server anyway, and onlydeployand the owner'subuntuaccount belong to it. - Because the workflow rewrites the file at every deployment, a rebuilt server gets its secrets back from the next deployment of each environment.
A VPS has no cloud identity service, so the OpenID Connect federation the spike asked about has nothing to trust it. The workflow signs in with keys instead (owner decision on #386):
- The owner generates three Ed25519 key pairs, one per environment, used only by the workflow. Each private key is
that environment's
DEPLOY_SSH_KEYsecret. Each public key goes intodeploy'sauthorized_keyswith therestrictoption, which turns off forwarding, terminals and agent use, and a comment naming its environment (provision.sh <key file>, once per key). The owner's personal key stays separate. - A leaked key is replaced alone, and the server's log shows which environment signed in. The dev key, used on every merge, is the most exposed.
- The workflow checks the server's identity against
DEPLOY_SSH_KNOWN_HOSTSwithStrictHostKeyChecking=yes. The owner records it after comparing the host key's fingerprint with the one the server shows in its own console. - Every key gives as much power as the
dockergroup, which is root on the server, the other applications and production included. That's why each environment admits onlymainor release tags.
PIVOT_PACKAGES_TOKEN stays a repository secret. The workflow restores packages on the runner before the images are
built, so the token never enters an image layer, a Compose file or the server.
| Secret | Who rotates it | When | How |
|---|---|---|---|
| Discord client secret | Owner | Yearly, and on any suspicion | Reset it in that environment's Discord application, update the secret, deploy. |
| Discord bot token | Owner | Yearly, and on any suspicion | Reset it in that application, update the secret, deploy. |
| Website key | Owner | Yearly, and on any suspicion | Generate a new value, update the secret, deploy. |
| Database password | Owner | Yearly, and on any suspicion | See below. |
| Deploy SSH key | Owner | Yearly, and on any suspicion | Add the new public key, update that environment's DEPLOY_SSH_KEY, deploy, then remove the old public key. |
| Feed token | Owner | Before it expires | As the README describes for the repository secret. |
- Website key: one deployment restarts the website and the API together. Until both run with the new key, a website request can get a 401 for a few seconds. Dev and test accept that; production needs the API to accept the previous key as well during a short overlap, which the production work (#433) decides before real users arrive.
-
Database password: PostgreSQL applies
POSTGRES_PASSWORDonly when it creates its data volume, so changing the secret alone breaks the API's connection. The owner first changes the password inside PostgreSQL, in that environment's container, with thepsqlcommand\password postgres, which asks for it without showing it, then updates the secret and deploys. - After any leak, rotate the secret at once and deploy. A leaked deploy key means rotating every secret of every environment, since it reaches the whole server.
-
Each environment has its own Discord application (owner decisions on #103 and #369), with its own client secret and bot token: rotating one never breaks another or the local run, and their bots never answer the same events.
Environment Discord application Redirect Dev RaidManager Dev https://raidmanager-dev.pivotsoftwares.com/signin-discordTest RaidManager Test https://raidmanager-test.pivotsoftwares.com/signin-discordProduction RaidManager https://raidmanager.pivotsoftwares.com/signin-discord -
No value is shared between environments, the deploy keys included.
Local development stays on the AppHost's user secrets and the local Discord application. It reads no remote store, so a run on a laptop needs no network credential and can't reach a deployed secret.
- #386 (under #153): the owner creates the three GitHub environments with their rules, the three Discord applications and bots, and the three deploy key pairs; enters the values above; and installs the public keys on the server. The agent gives the steps one at a time and checks the result by names only.
- #388 (under #375): each environment's settings and its Discord redirect.
-
#389 (under #376): the deploy job that targets
dev, pins the host key and writes.envas described. -
#427 and #433: the release deployments to
testandproduction.
Positive
- No new service or account: the store is the GitHub plan the repository already uses (GitHub Pro gives private repositories environments, their secrets and deployment rules).
- One place to change a secret, and one deployment to apply it; a rebuilt server needs no manual secret setup.
- GitHub itself refuses a deployed secret to anything but
main(dev) or a release tag (test and production).
Negative
- The secrets sit in two places: GitHub, and the
.envfiles on the server, readable by root anddeploy. - Whoever controls
main, the release tags or the workflow files controls the secrets, so the branch protection and review gates of ADR-0004 to ADR-0009 protect them too. GitHub Pro has no required reviewers for private repositories, so production's confirmation can't rely on them (#432). - Three environments mean three sets of values, three Discord applications and three keys to keep.
- Website key rotation causes a short burst of 401s, and the database password needs a manual step.
Residual risk
- A stolen deploy key gives root on the shared server: every environment's secrets and data, production's included, and the owner's other applications. Rotating everything is the remedy; moving production to its own server later (owner decision on #369) narrows it.
- The website's Data Protection keys (ADR-0027) aren't encrypted at rest on their volume. That is accepted for dev and test, and is for the production work (#433) to revisit.
-
An
.envfile kept by hand on the server: fewer GitHub secrets, but the server and its backup hold the only copies, and every rotation needs an SSH session. - An external vault (Infisical, Bitwarden Secrets Manager, HashiCorp Vault): better audit and rotation tools, but a further service and a further credential to protect.
- One deploy key shared by the three environments: simpler, but a leak means replacing it everywhere, and the server can't tell which environment signed in (owner decision on #386).
-
mainfor all three environments: simpler rules, but only the workflow's code, not GitHub, would stop a non-release commit from reaching production (owner decision on #386). -
Compose secrets mounted as files instead of environment variables: they keep values out of
docker inspect, but the Compose fileaspire publishgenerates uses environment variables, and on this server the people who can rundocker inspectcan read the files too.