TL;DR —
secrets.*are masked,vars.*are plain config, and both can be scoped at org / repo / environment (most-specific wins). Always pass secrets throughenv:rather than interpolating them intorun:blocks, and pair production deploys with anenvironment:for required reviewers.
| Type | Syntax | Visible in logs | Use for |
|---|---|---|---|
| Secret | ${{ secrets.NAME }} |
❌ Masked | Passwords, tokens, keys |
| Variable | ${{ vars.NAME }} |
✅ Visible | Non-sensitive config values |
| Env var | ${{ env.NAME }} |
✅ Visible | Values set within the workflow |
Secrets are encrypted and never appear in logs. GitHub automatically masks them if they accidentally appear in output.
steps:
- name: Deploy
run: ./deploy.sh
env:
API_KEY: ${{ secrets.PROD_API_KEY }} # pass via env var — safest way
DATABASE_URL: ${{ secrets.DATABASE_URL }}Never interpolate secrets directly into
run:— always pass through an environment variable.
# ❌ Risk of exposure in shell history / process list
- run: curl -H "Authorization: ${{ secrets.TOKEN }}" https://api.example.com
# ✅ Safe
- run: curl -H "Authorization: $TOKEN" https://api.example.com
env:
TOKEN: ${{ secrets.TOKEN }}gh secret set MY_SECRET # prompts for value (no shell history)
gh secret set MY_SECRET --body "value"
gh secret set MY_SECRET --env staging # environment-scoped
gh secret set MY_SECRET --org myorg # org-scoped
gh secret list
gh secret list --env staging
gh secret delete MY_SECRETVariables store non-sensitive configuration. They are visible in logs.
steps:
- run: echo "Deploying to ${{ vars.DEPLOY_URL }}"
- run: ./deploy.sh --region ${{ vars.AWS_REGION }}gh variable set DEPLOY_URL --body "https://staging.example.com"
gh variable set DEPLOY_URL --env staging
gh variable listSecrets and variables can be set at three scopes. More specific scopes override broader ones.
Organization
└── Repository
└── Environment ← highest precedence
| Scope | Where to configure | Accessible from |
|---|---|---|
| Organization | Org Settings → Secrets & Variables | All repos (or selected repos) |
| Repository | Repo Settings → Secrets & Variables | Any workflow in the repo |
| Environment | Repo Settings → Environments → select env | Only jobs referencing that environment |
Environments add a deployment gate to a job — required reviewers must approve before the job runs.
jobs:
deploy-staging:
environment: staging # references the "staging" environment in Settings
runs-on: ubuntu-latest
steps:
- run: ./deploy.sh
env:
TOKEN: ${{ secrets.STAGING_TOKEN }} # environment-scoped secret
deploy-production:
needs: deploy-staging
environment: production # separate gate for prod
runs-on: ubuntu-latest
steps:
- run: ./deploy.sh
env:
TOKEN: ${{ secrets.PROD_TOKEN }}Settings → Environments → New environment (or edit existing)
| Setting | Description |
|---|---|
| Required reviewers | Named people or teams who must approve the deployment |
| Wait timer | Delay N minutes after trigger before running |
| Deployment branches | Restrict which branches can deploy to this environment |
| Secrets | Env-specific secrets (override repo-level) |
| Variables | Env-specific variables |
PR merged to main
│
▼
deploy-staging job starts immediately
│
▼
deploy-production job is PAUSED
│ ← GitHub sends notification to required reviewers
▼
Reviewer approves in the UI (or gh CLI)
│
▼
deploy-production job runs
# Approve a pending deployment via CLI
gh run view <run-id> # find the pending deployment
# UI approval is usually easier — GitHub sends an email/notificationEvery workflow gets an automatically provisioned GITHUB_TOKEN — no setup needed.
steps:
- uses: actions/checkout@v4
with:
token: ${{ secrets.GITHUB_TOKEN }} # often implicit
- name: Create a release
run: gh release create v1.0.0
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}Always restrict its permissions:
permissions:
contents: read # default — read repo contents
pull-requests: write # only add if the job posts PR comments
packages: write # only add if the job pushes to GitHub PackagesDefault permission (if permissions: is omitted): depends on repo settings. Best practice: always declare permissions: explicitly so the scope is visible in code.
jobs:
call-deploy:
uses: ./.github/workflows/deploy.yml
secrets:
DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
# or inherit all secrets:
secrets: inheritjobs:
deploy:
environment: ${{ github.ref_name == 'main' && 'production' || 'staging' }}
runs-on: ubuntu-latest