Skip to content

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 testing vs production
  • Requiring a reviewer's approval before a production deploy
  • Restricting a deploy job so only the main branch 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.

  1. Repository → Settings
  2. Secrets and variables → Actions
  3. New repository secret
  4. Enter a name (matching the variable name keeps things clear) and paste the value
  5. Save — the value is now write-only
  1. Repository → Settings → Environments
  2. New environment, name it (testing, staging, production)
  3. Add environment secrets inside it
  4. Optionally add protection rules
  5. Reference it from a job with environment: testing
- name: Deploy
  env:
    API_TOKEN: ${{ secrets.DEPLOY_TOKEN }}   # only this step can see it
  run: ./deploy.sh
jobs:
  test:
    environment: testing
    # uses the testing database credentials
  deploy:
    needs: test
    environment: production
    # same secret names, production values
# Set a repository secret
gh secret set MONGODB_PASSWORD

# Set an environment secret
gh secret set MONGODB_PASSWORD --env testing

# List names (values are never shown)
gh secret list
gh secret list --env 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 env or 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: where environment: 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: Testing does not match an environment named testing.
  • 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 an env variable.
  • 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> — not env:.
  • 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_TOKEN permissions.