4.02 Secrets and Environments
Overview
Some values must never appear in the workflow file. Secrets store them safely in GitHub. Environments let you group secrets and add rules about which jobs may use them.
Abstract
A secret is an encrypted value you store in GitHub's settings instead of your YAML. You read it
with ${{ secrets.NAME }}, usually by assigning it to an environment variable. Once saved, a
secret can be updated but never viewed again — not even by you. GitHub also hides secret values
in run logs, printing *** instead. Secrets can live at organization, repository, or
environment level. An environment is a named group (like testing or production) that a
job targets with the environment: key; it supplies its own secrets and can enforce protection
rules such as required reviewers or allowed branches.
Why It Matters in Production
Anything written in a workflow file is in your repository, visible to everyone who can read it, and preserved in Git history forever. A database password pasted into YAML is leaked the moment you push, and deleting the line later does not undo it.
Environments matter once a pipeline touches more than one place. Testing and production usually need the same secret names with different values, and production usually needs a human to approve before anything runs. Environments give you both without duplicating the workflow.
Key Concepts
Secrets
| Point | Detail |
|---|---|
| Where to add | Settings → Secrets and variables → Actions |
| Levels | Organization, repository, environment |
| After saving | Can be updated or removed, never viewed |
| How to read | ${{ secrets.NAME }} |
| In logs | Automatically masked as *** |
| Fork pull requests | Secrets are not passed to them |
Environments
| Point | Detail |
|---|---|
| Where to add | Settings → Environments |
| How a job uses one | environment: testing inside the job |
| What it provides | Environment-specific secrets and variables |
| Protection rules | Required reviewers, wait timer, deployment branch rules |
| Availability | Public repositories on the free plan, or paid plans for private repos |
environment is not env
Two similar-looking keys that do completely different things. env: defines environment
variables. environment: names a GitHub environment the job should target.
Which secret wins
If an environment defines a secret with the same name as a repository secret, the environment's value is used for jobs targeting that environment. That is the whole point — same name, different value per environment.
Common Use Cases
- Database usernames and passwords
- Cloud provider credentials and API keys
- Deployment tokens for a hosting provider
- Different database credentials for
testingvsproduction - Requiring a reviewer's approval before a production deploy
- Restricting a deploy job so only the
mainbranch can run it
Example Configuration or Commands
Credentials come from secrets; the job targets the testing environment:
name: Deployment
on:
push:
branches:
- main
- dev
env:
MONGODB_DB_NAME: gha-demo
jobs:
test:
environment: testing # use this environment's secrets and rules
env:
MONGODB_CLUSTER_ADDRESS: cluster0.ntrwp.mongodb.net
MONGODB_USERNAME: ${{ secrets.MONGODB_USERNAME }}
MONGODB_PASSWORD: ${{ secrets.MONGODB_PASSWORD }}
PORT: 8080
runs-on: ubuntu-latest
steps:
- name: Get Code
uses: actions/checkout@v4
- name: Install dependencies
run: npm ci
- name: Run server
run: npm start & npx wait-on http://127.0.0.1:$PORT
- name: Run tests
run: npm test
- name: Output information
run: echo "MONGODB_USERNAME: ${{ env.MONGODB_USERNAME }}"
That last step prints MONGODB_USERNAME: ***. GitHub recognizes the value as a secret and hides it,
so you cannot leak it into the log by accident.
- Repository → Settings
- Secrets and variables → Actions
- New repository secret
- Enter a name (matching the variable name keeps things clear) and paste the value
- Save — the value is now write-only
- Repository → Settings → Environments
- New environment, name it (
testing,staging,production) - Add environment secrets inside it
- Optionally add protection rules
- Reference it from a job with
environment: testing
Protection rules in action
On the testing environment you can add a deployment branch rule allowing only main.
After that, pushing to dev still triggers the workflow, but the test job does not run — the
log says the branch is not allowed to deploy to that environment, and the run fails. The rule
lives in GitHub's settings, so the workflow file cannot override it.
The three protection rules available:
| Rule | What it does |
|---|---|
| Required reviewers | A named person or team must approve before the job starts |
| Wait timer | The job pauses for a set number of minutes first |
| Deployment branch rule | Only runs triggered from listed branches may use the environment |
Best Practices
Best Practices
- Put every credential in a secret, even for a throwaway test database. Habits are what save you on the production one.
- Name secrets the same as the variables they fill —
MONGODB_PASSWORD→MONGODB_PASSWORD. - Use environments when the same secret name needs different values per stage.
- Assign secrets at job or step level so only the parts that need them can read them.
- Protect production with required reviewers and a deployment branch rule.
- Rotate secrets periodically, and immediately if one may have been exposed.
- Prefer short-lived cloud credentials (OIDC) over long-lived keys where your provider supports it.
- Keep non-sensitive config in
envor repository variables; secrets are for things that must stay hidden.
Security Best Practices
Security
- Masking is a safety net, not a guarantee. GitHub hides exact matches, so a transformed value — base64-encoded, split in half, uppercased — can still appear in the log.
- Anyone with collaborator (write) access can use repository secrets in a workflow, and could write a workflow that exfiltrates them. Keep write access tight.
- Secrets are not passed to workflows triggered by pull requests from forks. That is deliberate protection, not a bug to work around.
- Never pass a secret into an output or an artifact — neither is masked or protected.
- Be careful adding third-party actions to a job that holds secrets; the action runs with full access to that job's environment.
- Don't print environment dumps (
printenv,toJSON(env)) in a job holding secrets. - If a secret ever reaches a log or a commit, rotate it. Deleting the log or commit is not enough.
- Deployment branch rules are enforced by GitHub, so they hold even if someone edits the workflow file — use them for anything that matters.
Do and Don't
| ✅ Do | ❌ Don't |
|---|---|
| Store credentials as secrets | Paste them into the workflow file |
Read them with ${{ secrets.NAME }} |
Try to view a saved secret (you can't) |
| Use environments for per-stage values | Invent DB_PASS_TEST, DB_PASS_PROD secret names |
Use environment: for environments |
Confuse it with env: |
| Add reviewers on production | Let any push deploy to production |
| Rotate an exposed secret | Assume deleting the commit fixed it |
| Scope secrets to the step that needs them | Expose them to every step in the workflow |
Common Mistakes
Common Mistakes
- Typo in the secret name.
${{ secrets.MONGODB_USERNAE }}resolves to an empty string, so the app fails to connect with no obvious cause. - Using
env:whereenvironment:was meant, or the reverse. - Expecting to read a secret back. Once saved it is write-only; if you forgot the value, get it from the source system and set it again.
- Thinking
***in the log means something broke. That is masking working correctly. - Wondering why secrets are empty in a fork PR. They are deliberately withheld.
- Environment name mismatch.
environment: Testingdoes not match an environment namedtesting. - Looking for environments on a private free-plan repo. They require a public repo or a paid plan.
- Leaving old repository secrets behind after moving to environment secrets — confusing, and still usable.
- Testing a branch rule and reading the failure as a bug. A blocked job failing is the rule doing its job.
Breaks in production
A password committed in a workflow file on a public repository is scraped by automated bots within minutes. Even after you remove it, the old commit still holds it — the only real fix is changing the password at the database.
Troubleshooting
# Which secrets exist? (names only — values are never retrievable)
gh secret list
gh secret list --env testing
# Which environments exist?
gh api repos/OWNER/REPO/environments --jq '.environments[].name'
# Did a job get blocked by a protection rule?
gh run view <run-id> --log | grep -i "not allowed to deploy"
| Symptom | Likely cause |
|---|---|
| App can't authenticate | Secret name typo, or the secret was never created |
Value prints as *** |
Expected — masking is working |
| Secret empty in a PR run | PR came from a fork; secrets are withheld |
| "Branch is not allowed to deploy to …" | A deployment branch rule blocked the job |
| Job stuck as "Waiting" | Required reviewers or a wait timer on the environment |
| Environment secret ignored | environment: missing from the job, or the name doesn't match |
| No Environments option in settings | Private repository on the free plan |
Tip
When a secret seems missing, don't echo it — check the spelling in the workflow against the name in Settings, and confirm the job targets the right environment. The most common cause by far is a one-character difference.
Quick Recap
- Secrets are encrypted values stored in GitHub instead of in your workflow file.
- Add them under Settings → Secrets and variables → Actions.
- They can be stored at organization, repository, or environment level.
- Once saved, a secret can be updated but never viewed.
- Read a secret with
${{ secrets.NAME }}, usually assigned to anenvvariable. - GitHub masks secret values in logs, showing
***. - Secrets are not available to pull requests from forks.
- An environment is a named group of secrets plus rules, attached to the repository.
- A job targets one with
environment: <name>— notenv:. - Environment secrets override repository secrets for jobs using that environment.
- Protection rules: required reviewers, wait timer, and deployment branch rules.
- A blocked branch makes the job fail without running.
Interview / Revision Notes
Quick revision
Q: Why not keep credentials in the workflow file? It lives in the repository and in Git history, readable by anyone with access.
Q: How do you read a secret?
${{ secrets.NAME }}.
Q: Can you view a secret after saving it? No — only update or delete it.
Q: What happens if you print one?
GitHub masks it as ***.
Q: At what levels can secrets be stored? Organization, repository, and environment.
Q: What is a GitHub environment? A named group attached to a repository that supplies its own secrets and protection rules to jobs that target it.
Q: Which key attaches a job to one?
environment: — distinct from env:.
Q: Why use environments instead of more secret names? Same secret names, different values per stage, selected by the job.
Q: Name the three protection rules. Required reviewers, wait timer, deployment branch rules.
Q: Are secrets available in fork pull requests? No.
Q: Who can use repository secrets? Anyone with collaborator access, via a workflow.
This topic can be expanded into sub-pages: organization secrets and sharing policies, repository and environment variables (non-secret config), OIDC for cloud credentials, deployment approvals and environment history, and
GITHUB_TOKENpermissions.