Tech Story
As a platform engineer, I want all production secrets stored in GitHub environment-scoped secrets and written to the VPS only at deploy time, so that credentials are never committed to git, are isolated between staging and production, and can be rotated in under 10 minutes with a documented procedure.
ELI5 Context
Why not just put secrets in a .env file in the repo?
A .env file committed to git is visible to everyone with repo access — and in git history forever, even after deletion. More importantly, GitHub Actions logs can leak secrets if you're not careful. The correct pattern: secrets live in GitHub's encrypted secret store, get written to the VPS as a .env file at deploy time (never logged), and the .env file is never committed to git.
What is a GitHub Environment?
A GitHub Environment is a named deployment target (e.g. "staging", "production") with its own isolated secrets and optional protection rules. Secrets set on the production environment are only available to workflow jobs that declare environment: production. This means a staging deploy cannot accidentally use production database credentials.
What is secret rotation?
Changing a secret's value — for example, rotating the JWT secret or database password. Without a documented procedure, rotation is risky: you might update the secret in GitHub but forget to update it on the VPS, causing a deployment outage. With a documented procedure, rotation takes 10 minutes and never causes downtime.
Why chmod 600 on the .env file?
File permissions 600 means only the owner (the deploy user) can read or write it. Other users on the VPS — even if they SSH in separately — cannot read the file. This prevents secrets from leaking if the VPS is ever shared or another service is compromised.
Technical Elaboration
Step 1: Create GitHub Environments (manual, one-time)
In the GitHub repo → Settings → Environments:
-
Create environment named staging:
- No protection rules
- Add all secrets from the table below with staging-appropriate values
-
Create environment named production:
- Enable Required reviewers — add yourself
- Enable Prevent self-review: OFF (you're the only reviewer)
- Add all secrets from the table below with production-appropriate values
Required Secrets Per Environment
| Secret |
staging |
production |
How to generate |
DATABASE_HOST |
✅ |
✅ |
localhost (DB runs on same VPS) |
DATABASE_PORT |
✅ |
✅ |
5433 (prod) / 5434 (staging) |
DATABASE_USER |
✅ |
✅ |
Choose a username |
DATABASE_PASSWORD |
✅ |
✅ |
openssl rand -base64 32 |
DATABASE_NAME |
✅ |
✅ |
station / station_staging |
JWT_SECRET |
✅ |
✅ |
openssl rand -base64 48 (min 32 chars) |
REDIS_PASSWORD |
✅ |
✅ |
openssl rand -base64 24 |
VPS_HOST |
✅ |
✅ |
VPS public IP address |
VPS_USER |
✅ |
✅ |
deploy |
VPS_SSH_KEY |
✅ |
✅ |
Private key matching the deploy user's authorized_keys |
ALLOWED_ORIGIN |
✅ |
✅ |
https://staging.station.drdnt.org / https://station.drdnt.org |
FRONTEND_URL |
✅ |
✅ |
Same as ALLOWED_ORIGIN |
B2_ACCOUNT_ID |
✅ |
✅ |
From Backblaze B2 console |
B2_APPLICATION_KEY |
✅ |
✅ |
From Backblaze B2 console |
B2_BUCKET |
✅ |
✅ |
station-backups |
SENTRY_DSN |
✅ |
✅ |
From Sentry project settings |
LOGTAIL_SOURCE_TOKEN |
✅ |
✅ |
From Logtail source settings |
BACKUP_HEALTHCHECK_URL |
❌ |
✅ |
From healthchecks.io check detail page |
Step 2: Deploy-time .env injection
In .github/workflows/release.yml, add a "Write .env" step at the start of both deploy-staging and deploy-production jobs. Use a quoted heredoc to prevent shell expansion of secret values:
- name: Write .env to VPS
run: |
ssh ${{ secrets.VPS_USER }}@${{ secrets.VPS_HOST }} bash -s << 'SSHEOF'
cat > /opt/station/.env.production << 'ENVEOF'
DATABASE_HOST=${{ secrets.DATABASE_HOST }}
DATABASE_PORT=${{ secrets.DATABASE_PORT }}
DATABASE_USER=${{ secrets.DATABASE_USER }}
DATABASE_PASSWORD=${{ secrets.DATABASE_PASSWORD }}
DATABASE_NAME=${{ secrets.DATABASE_NAME }}
JWT_SECRET=${{ secrets.JWT_SECRET }}
REDIS_PASSWORD=${{ secrets.REDIS_PASSWORD }}
ALLOWED_ORIGIN=${{ secrets.ALLOWED_ORIGIN }}
FRONTEND_URL=${{ secrets.FRONTEND_URL }}
SENTRY_DSN=${{ secrets.SENTRY_DSN }}
SENTRY_RELEASE=${{ github.sha }}
NODE_ENV=production
PORT=3001
USE_REDIS_CACHE=true
ENVEOF
chmod 600 /opt/station/.env.production
SSHEOF
Security note: GitHub Actions automatically masks secret values in logs — they appear as ***. The heredoc approach ensures secrets are never echoed in the SSH session output.
Step 3: Verify .gitignore and .dockerignore
Confirm these entries exist:
.gitignore (repo root):
.env
.env.*
!.env.example
!.env.production.example
backend/.dockerignore and frontend/.dockerignore:
New file: infra/docs/secrets.md
Sections:
1. Secret inventory — the table above, with description and how-to-generate for each
2. Rotation procedure (generic)
1. Generate new secret value (openssl rand command or service console)
2. Update the secret in GitHub → Settings → Environments → [staging|production]
3. Push a release branch (or manually re-run the deploy workflow)
4. The deploy writes the new .env.production and restarts containers
5. Verify: hit GET /health — if 200, rotation succeeded
3. JWT_SECRET rotation (special case)
JWT_SECRET rotation invalidates all existing tokens — every logged-in user will be logged out.
1. Schedule a maintenance window (or accept the forced logout)
2. Generate new secret: openssl rand -base64 48
3. Update GitHub secret
4. Deploy (this writes the new secret and restarts the backend)
5. All existing access tokens are now invalid — users must log in again
6. Refresh tokens stored in Redis are also invalidated (they're signed with the old secret)
4. Database password rotation
1. Generate new password: openssl rand -base64 32
2. Update the PostgreSQL user password directly: docker exec station-postgres-1 psql -U postgres -c "ALTER USER stationuser PASSWORD 'newpassword';"
3. Update DATABASE_PASSWORD GitHub secret
4. Deploy (writes new .env, restarts backend — backend reconnects with new password)
5. Verify: GET /health returns 200 with database check passing
5. SSH key rotation
1. Generate new key pair: ssh-keygen -t ed25519 -f deploy_key_new
2. Add new public key to VPS: ssh-copy-id -i deploy_key_new.pub deploy@VPS_IP
3. Update VPS_SSH_KEY GitHub secret with new private key
4. Test deploy workflow succeeds
5. Remove old public key from VPS: edit ~/.ssh/authorized_keys on VPS
Definition of Done
Dependencies
Tech Story
As a platform engineer, I want all production secrets stored in GitHub environment-scoped secrets and written to the VPS only at deploy time, so that credentials are never committed to git, are isolated between staging and production, and can be rotated in under 10 minutes with a documented procedure.
ELI5 Context
Why not just put secrets in a
.envfile in the repo?A
.envfile committed to git is visible to everyone with repo access — and in git history forever, even after deletion. More importantly, GitHub Actions logs can leak secrets if you're not careful. The correct pattern: secrets live in GitHub's encrypted secret store, get written to the VPS as a.envfile at deploy time (never logged), and the.envfile is never committed to git.What is a GitHub Environment?
A GitHub Environment is a named deployment target (e.g. "staging", "production") with its own isolated secrets and optional protection rules. Secrets set on the
productionenvironment are only available to workflow jobs that declareenvironment: production. This means a staging deploy cannot accidentally use production database credentials.What is secret rotation?
Changing a secret's value — for example, rotating the JWT secret or database password. Without a documented procedure, rotation is risky: you might update the secret in GitHub but forget to update it on the VPS, causing a deployment outage. With a documented procedure, rotation takes 10 minutes and never causes downtime.
Why
chmod 600on the.envfile?File permissions
600means only the owner (thedeployuser) can read or write it. Other users on the VPS — even if they SSH in separately — cannot read the file. This prevents secrets from leaking if the VPS is ever shared or another service is compromised.Technical Elaboration
Step 1: Create GitHub Environments (manual, one-time)
In the GitHub repo → Settings → Environments:
Create environment named
staging:Create environment named
production:Required Secrets Per Environment
DATABASE_HOSTlocalhost(DB runs on same VPS)DATABASE_PORT5433(prod) /5434(staging)DATABASE_USERDATABASE_PASSWORDopenssl rand -base64 32DATABASE_NAMEstation/station_stagingJWT_SECRETopenssl rand -base64 48(min 32 chars)REDIS_PASSWORDopenssl rand -base64 24VPS_HOSTVPS_USERdeployVPS_SSH_KEYALLOWED_ORIGINhttps://staging.station.drdnt.org/https://station.drdnt.orgFRONTEND_URLB2_ACCOUNT_IDB2_APPLICATION_KEYB2_BUCKETstation-backupsSENTRY_DSNLOGTAIL_SOURCE_TOKENBACKUP_HEALTHCHECK_URLStep 2: Deploy-time .env injection
In
.github/workflows/release.yml, add a "Write .env" step at the start of bothdeploy-staginganddeploy-productionjobs. Use a quoted heredoc to prevent shell expansion of secret values:Security note: GitHub Actions automatically masks secret values in logs — they appear as
***. The heredoc approach ensures secrets are never echoed in the SSH session output.Step 3: Verify .gitignore and .dockerignore
Confirm these entries exist:
.gitignore(repo root):backend/.dockerignoreandfrontend/.dockerignore:New file:
infra/docs/secrets.mdSections:
1. Secret inventory — the table above, with description and how-to-generate for each
2. Rotation procedure (generic)
3. JWT_SECRET rotation (special case)
JWT_SECRET rotation invalidates all existing tokens — every logged-in user will be logged out.
4. Database password rotation
5. SSH key rotation
Definition of Done
stagingGitHub Environment created with all required secretsproductionGitHub Environment created with required reviewer and all secrets.github/workflows/release.ymlwrites.env.productionat the start of each deploy job.env.productionon VPS ischmod 600— verified by SSHing in and runningls -la /opt/station/.gitignoreexcludes all.env.*files except.exampletemplates.dockerignoreon backend and frontend excludes.env.*filesinfra/docs/secrets.mdwritten with secret inventory, generic rotation procedure, and JWT/DB/SSH special-case procedures.env.productionon VPS, run deploy workflow, confirm.env.productionis recreated correctlyDependencies