env.dev

GitHub Actions: Secrets vs Environment Variables

When to use repository secrets, environment secrets, and configuration variables in GitHub Actions. Includes workflow examples for Node.js, Python, and Docker.

Last updated:

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).

TypeScopeEncryptedAccess syntax
Repository secretAll jobs in the repoYes${{ secrets.NAME }}
Environment secretJobs targeting that environmentYes${{ secrets.NAME }}
Repository variableAll jobs in the repoNo${{ vars.NAME }}
Environment variableJobs targeting that environmentNo${{ 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.

yaml
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.sh

When 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.

yaml
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 variable

Key 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

yaml
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

yaml
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

yaml
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:

bash
base64 -w 0 < private_key.pem | gh secret set SSH_PRIVATE_KEY

Then decode inside the workflow:

yaml
- name: Write SSH key
  run: echo "${{ secrets.SSH_PRIVATE_KEY }}" | base64 -d > ~/.ssh/id_rsa && chmod 600 ~/.ssh/id_rsa

What are the most common mistakes?

Using the wrong expression context:
yaml
# 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 }}
Case sensitivity:

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.

Secrets in conditionals:
yaml
# 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 != '' }}
Echoing secrets for debugging:

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.

Forgetting the environment key:
yaml
# 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 }}  # resolved

When should you use which type?

Use caseTypeWhy
API keys, tokens, passwordssecretsEncrypted and masked in logs
Per-environment credentialsenvironment secretsScoped + approval gates
Region, app name, feature flagsvarsPlaintext, visible, easy to audit
Build-time constants in YAMLenv:Defined inline, no UI setup needed
Repo-scoped operationsGITHUB_TOKENAuto-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.

Frequently Asked Questions

What is the difference between secrets and variables in GitHub Actions?

Secrets are encrypted and masked in logs — use them for API keys, tokens, and passwords. Variables (vars) are stored in plain text and visible in logs — use them for non-sensitive configuration like environment names, feature flags, and URLs.

Can GitHub Actions secrets be accessed in pull requests from forks?

No. For security, repository secrets are not available to workflows triggered by pull requests from forks. This prevents malicious PRs from exfiltrating secrets. Use the pull_request_target event if you need fork access with secrets, but be very careful about what code runs.

How do I use a multi-line secret in GitHub Actions?

Base64-encode the value before storing it as a secret, then decode it in your workflow: echo "${{ secrets.MY_CERT }}" | base64 --decode > cert.pem. This avoids issues with newlines and special characters.

Was this helpful?

Stay up to date

Get notified about new guides, tools, and cheatsheets.