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.

Context

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

Decision

The store: one GitHub environment per environment

  • Each environment's secrets are environment secrets of its own GitHub environment: dev, test and production (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
    dev the main branch only
    test tags matching v* only
    production tags matching v* only; how a tag is confirmed for production is spike #432's decision

    A pull request's workflow never runs from main or 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 with openssl 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 publish writes into the .env template, so one workflow serves all three:

    AppHost parameter Name in each environment Kind
    discord-client-id DISCORD_CLIENT_ID variable
    discord-client-secret DISCORD_CLIENT_SECRET secret
    discord-bot-token DISCORD_BOT_TOKEN secret
    website-service-key WEBSITE_SERVICE_KEY secret
    postgres-password POSTGRES_PASSWORD secret
    none (deploy workflow) DEPLOY_SSH_KEY secret
    none (deploy workflow) DEPLOY_HOST, DEPLOY_SSH_KNOWN_HOSTS variables

    If the generated template names a key differently, #389 follows the template and corrects this table.

Delivery: the deploy workflow writes each environment's .env file

  • Each environment is its own Compose project in its own folder: /opt/apps/raidmanager/dev, /test and /production. At each deployment, the workflow (#389) writes that folder's .env from its GitHub environment, then runs docker 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 to umask 077; cat > .env.new; mv .env.new .env on the server. They never appear in a command line, a ${{ }} expression inside run:, the runner's disk or the job's log, where GitHub also masks them.
  • The files are owned by deploy and readable by deploy only (mode 600). Members of the docker group can see a container's environment with docker inspect, but that group is root-equivalent on the server anyway, and only deploy and the owner's ubuntu account 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.

Deploy authentication: one SSH key per 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_KEY secret. Each public key goes into deploy's authorized_keys with the restrict option, 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_HOSTS with StrictHostKeyChecking=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 docker group, which is root on the server, the other applications and production included. That's why each environment admits only main or release tags.

The feed token stays on the runner

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.

Rotation

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_PASSWORD only 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 the psql command \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.

Environments

  • 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-discord
    Test RaidManager Test https://raidmanager-test.pivotsoftwares.com/signin-discord
    Production RaidManager https://raidmanager.pivotsoftwares.com/signin-discord
  • No value is shared between environments, the deploy keys included.

Local development

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.

Follow-up work

  • #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 .env as described.
  • #427 and #433: the release deployments to test and production.

Consequences

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 .env files on the server, readable by root and deploy.
  • 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.

Alternatives considered

  • An .env file 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).
  • main for 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 file aspire publish generates uses environment variables, and on this server the people who can run docker inspect can read the files too.
⚠️ **GitHub.com Fallback** ⚠️