GitHub Actions provides three distinct mechanisms for passing configuration into workflows: repository secrets, environment secrets, and configuration variables. Secrets are encrypted at rest, masked in logs, and never exposed to forks. Configuration variables are plaintext and suited for non-sensitive values like region names or feature flags. Choosing the wrong type leaks credentials or creates unnecessary friction. This guide covers when to use each, how to reference them in YAML, and the mistakes that trip up even experienced developers.
What are the three types of workflow configuration?
GitHub exposes configuration at two scopes — repository and environment — across two categories: secrets (encrypted) and variables (plaintext).
| Type | Scope | Encrypted | Access syntax |
|---|---|---|---|
| Repository secret | All jobs in the repo | Yes | ${{ secrets.NAME }} |
| Environment secret | Jobs targeting that environment | Yes | ${{ secrets.NAME }} |
| Repository variable | All jobs in the repo | No | ${{ vars.NAME }} |
| Environment variable | Jobs targeting that environment | No | ${{ vars.NAME }} |
Environment secrets and variables override repository-level values of the same name when a job specifies an environment. This lets you keep a default at the repo level and override per deployment target.
How do you access secrets in workflow YAML?
Secrets are available via the ${{ secrets.NAME }} expression context. You can use them in env blocks, step inputs, or with parameters.
name: Deploy
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/checkout@v4
- name: Deploy to production
env:
API_KEY: ${{ secrets.API_KEY }}
DATABASE_URL: ${{ secrets.DATABASE_URL }}
run: |
echo "Deploying with API key (masked in logs)"
./deploy.shWhen a job declares environment: production, GitHub resolves secrets from the production environment first, falling back to repository secrets. Environment-level secrets can also require manual approval before the job runs.
How do env and vars differ?
The ${{ env.NAME }} context reads workflow-level or job-level environment variables set with the env key. The ${{ vars.NAME }} context reads configuration variables defined in the repository or environment settings UI.
env:
NODE_ENV: production # workflow-level env
REGION: ${{ vars.REGION }} # from Settings > Variables
jobs:
build:
runs-on: ubuntu-latest
env:
CI: "true" # job-level env
steps:
- name: Show values
run: |
echo "NODE_ENV=$NODE_ENV" # shell env var
echo "REGION=${{ vars.REGION }}" # config variable
echo "CI=${{ env.CI }}" # workflow env context
echo "DEPLOY_URL=${{ vars.DEPLOY_URL }}" # config variableKey distinction: ${{ env.NAME }} reads from env: blocks you define in the YAML. ${{ vars.NAME }} reads from the GitHub UI settings. They are not interchangeable.
Node.js workflow with secrets
name: Node.js CI
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm test
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY }}
deploy:
needs: test
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/checkout@v4
- run: pnpm install --frozen-lockfile
- run: pnpm build
- run: pnpm deploy
env:
DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}Python workflow with secrets
name: Python CI
on: [push]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install -r requirements.txt
- run: pytest
env:
SECRET_KEY: ${{ secrets.DJANGO_SECRET_KEY }}
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}Docker workflow with secrets
name: Docker Build & Push
on:
push:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@v6
with:
push: true
tags: ghcr.io/${{ github.repository }}:latest
build-args: |
APP_VERSION=${{ vars.APP_VERSION }}
SENTRY_DSN=${{ vars.SENTRY_DSN }}Notice how GITHUB_TOKEN is referenced as a secret even though you never create it manually. GitHub automatically provisions this token for every workflow run with permissions scoped to the repository.
What security guarantees do secrets provide?
- Encrypted at rest — secrets are encrypted using a libsodium sealed box before they reach GitHub's servers.
- Masked in logs — if a secret value appears in stdout or stderr, GitHub replaces it with
***. This works for the exact value only; substrings or transformed versions will not be masked. - Not available in fork PRs — pull requests from forks do not receive repository or environment secrets. This prevents malicious PRs from exfiltrating credentials.
- GITHUB_TOKEN — automatically generated per workflow run with permissions scoped to the repo. Expires after the job completes. Prefer this over personal access tokens for repo operations.
- Environment protection rules — environment secrets can require manual reviewer approval, branch restrictions, or a wait timer before they are exposed to a job.
For multi-line secrets (SSH keys, certificates), encode them to Base64 before storing. Use the Base64 encoder or the CLI:
base64 -w 0 < private_key.pem | gh secret set SSH_PRIVATE_KEYThen decode inside the workflow:
- name: Write SSH key
run: echo "${{ secrets.SSH_PRIVATE_KEY }}" | base64 -d > ~/.ssh/id_rsa && chmod 600 ~/.ssh/id_rsaWhat are the most common mistakes?
# WRONG — reads from the env: block, not from GitHub settings
env:
MY_SECRET: ${{ env.API_KEY }}
# RIGHT — reads the encrypted secret from GitHub
env:
MY_SECRET: ${{ secrets.API_KEY }}
# WRONG — reads secrets context for a non-sensitive config value
region: ${{ secrets.AWS_REGION }}
# RIGHT — use vars for non-sensitive configuration
region: ${{ vars.AWS_REGION }}Secret and variable names are case-insensitive in the GitHub UI but must match exactly when referenced in YAML. By convention, always use UPPER_SNAKE_CASE. A mismatch like secrets.Api_Key vs the stored API_KEY resolves to an empty string with no error.
# WRONG — never compare a secret directly; it leaks timing information
if: ${{ secrets.DEPLOY_KEY == 'expected-value' }}
# RIGHT — check if it exists (non-empty)
if: ${{ secrets.DEPLOY_KEY != '' }}GitHub masks the exact secret value, but echo with string manipulation (reversing, splitting, base64-encoding the secret) can bypass masking. Never echo secrets, even in debug mode.
# WRONG — environment secrets are NOT available without declaring the environment
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- run: echo ${{ secrets.PROD_DB_URL }} # empty string
# RIGHT — declare the environment to access its secrets
jobs:
deploy:
runs-on: ubuntu-latest
environment: production
steps:
- run: echo ${{ secrets.PROD_DB_URL }} # resolvedWhen should you use which type?
| Use case | Type | Why |
|---|---|---|
| API keys, tokens, passwords | secrets | Encrypted and masked in logs |
| Per-environment credentials | environment secrets | Scoped + approval gates |
| Region, app name, feature flags | vars | Plaintext, visible, easy to audit |
| Build-time constants in YAML | env: | Defined inline, no UI setup needed |
| Repo-scoped operations | GITHUB_TOKEN | Auto-provisioned, scoped, ephemeral |
Rule of thumb: if the value would be a problem in a public log, use a secret. If it is configuration that anyone on the team should be able to read and change, use a variable.
FAQ
Can I use secrets in reusable workflows?
Yes. Pass them explicitly with secrets: inherit or map individual secrets in the secrets: block of the caller workflow. Reusable workflows do not automatically inherit secrets unless you opt in.
What is the maximum size of a secret?
Individual secrets are limited to 48 KB. For larger payloads (like a service account JSON), Base64-encode the file and store the encoded string, or encrypt it with GPG and store the passphrase as the secret.
Do organization-level secrets override repository secrets?
No. Repository secrets take precedence over organization secrets of the same name. Environment secrets take precedence over both.
How do I rotate a secret?
Update the value in Settings and re-run your workflow. There is no versioning — the new value takes effect immediately for all subsequent runs. For zero-downtime rotation, store both old and new values under different names during the transition.
For a complete walkthrough of workflow syntax, triggers, and CI/CD pipelines, see the GitHub Actions guide.