Github Actions Managing Your Workflow Environments Created: 06 Apr 2026 Updated: 06 Apr 2026

Secrets and Configuration Variables

Workflows rarely run in isolation. They connect to cloud providers, package registries, notification services, and databases — all of which require credentials and configuration that changes between environments. GitHub provides two dedicated mechanisms for this: secrets for sensitive values and configuration variables (repository variables) for non-sensitive settings.

Why Not Just Use Environment Variables?

Environment variables defined with the env: key live inside the workflow file itself, which is committed to the repository. That makes them visible to anyone with read access to the repository. For an API key, a deploy token, or a webhook URL, this is unacceptable. Even for a non-sensitive value such as a Slack channel name or a deploy target region, hardcoding it in the workflow file means a team must open a pull request just to change a setting.

Secrets and configuration variables solve both problems:

Secrets

Encrypted at rest. Never printed in log output — GitHub automatically masks them. Accessed via the secrets context: ${{ secrets.SECRET_NAME }}.

Configuration variables (vars)

Stored in plain text, visible in the GitHub UI, suitable for non-sensitive settings. Accessed via the vars context: ${{ vars.VARIABLE_NAME }}. They can be changed in the UI without touching the workflow file.

Secrets vs Configuration Variables at a Glance

PropertySecretConfiguration Variable
Stored asEncryptedPlain text
Visible in GitHub UI after savingNo (write-only)Yes
Masked in run logsYes (automatically)No
Context expression${{ secrets.NAME }}${{ vars.NAME }}
Available scopesRepository, Environment, OrganizationRepository, Environment, Organization
Typical use casesAPI keys, tokens, passwords, webhook URLsTarget region, feature flags, channel names, log levels

Creating a Secret or Configuration Variable

Both types are created through the same part of the GitHub UI. The path is: Repository → Settings → Security → Secrets and variables → Actions. From there, choose either the Secrets tab or the Variables tab.

  1. Navigate to your repository on GitHub.
  2. Click Settings in the top navigation bar.
  3. In the left sidebar, under Security, click Secrets and variables.
  4. Click Actions.
  5. Select the Secrets or Variables tab.
  6. Click New repository secret or New repository variable.
  7. Enter a name (uppercase with underscores is the convention) and a value.
  8. Click Add secret or Add variable to save.

Organization-level secrets and variables follow the same steps but are reached via the Organization settings page and include an extra Repository access option that lets you scope them to all repositories, public repositories only, or a selected list.

After saving a secret its value cannot be read back — not even by a repository admin. It can only be overwritten. Configuration variable values remain readable in the UI.

Accessing Secrets and Variables in a Workflow

Consider a web application release pipeline that deploys to a cloud platform and posts a Slack notification when complete. Three categories of data are needed:

  1. DEPLOY_API_KEY — credential to authenticate with the cloud provider (secret)
  2. SLACK_WEBHOOK_URL — URL that grants permission to post messages (secret)
  3. DEPLOY_TARGET — staging or production (configuration variable)
  4. SLACK_CHANNEL — which channel to post to (configuration variable)

Referencing a configuration variable

jobs:
deploy:
name: Deploy to ${{ vars.DEPLOY_TARGET }}
runs-on: ubuntu-latest
steps:
- name: Log target
run: echo "Deploying to ${{ vars.DEPLOY_TARGET }}"

The vars context makes the value available anywhere a context expression is supported: job names, if: conditions, run: scripts, and with: inputs to actions.

Referencing a secret

steps:
- name: Authenticate and deploy
env:
DEPLOY_API_KEY: ${{ secrets.DEPLOY_API_KEY }}
run: |
./scripts/deploy.sh --app storefront-api

The secret is mapped into the step's environment using env:. Assigning it at step scope — rather than job or workflow scope — limits its exposure to only the step that actually needs it. The value is masked in all log output regardless of scope.

Configuration Variables as Feature Flags

One practical pattern is using a configuration variable as a runtime toggle. Because variables can be changed in the GitHub UI without opening a pull request, they are well-suited to enabling or disabling optional workflow jobs on demand.

jobs:
audit:
name: Release Audit
needs: deploy
if: ${{ vars.AUDIT_ON_RELEASE == 'true' }}
runs-on: ubuntu-latest
steps:
- name: Run compliance check
env:
AUDIT_TOKEN: ${{ secrets.AUDIT_TOKEN }}
AUDIT_LEVEL: ${{ vars.AUDIT_LEVEL }}
run: ./scripts/audit.sh --level "$AUDIT_LEVEL"

Set AUDIT_ON_RELEASE to false in the Variables UI and the entire audit job is skipped on the next push — no code change required. Notice that AUDIT_LEVEL is a variable (the log level is not sensitive) while AUDIT_TOKEN is a secret (it authenticates with an external service).

Combining Secrets, Variables, and Environment Variables

All three mechanisms can coexist in the same workflow. A common pattern is to bridge a configuration variable into a local environment variable so that shell scripts can reference it with the simpler $NAME syntax instead of the full expression syntax.

env:
APP_NAME: storefront-api # hardcoded — same in every environment
DEPLOY_REGION: ${{ vars.DEPLOY_REGION }} # pulled from config variable

jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Show context
run: |
echo "App: $APP_NAME"
echo "Region: $DEPLOY_REGION"
echo "Actor: $GITHUB_ACTOR" # default variable — always available

The key distinction between the three types when they appear together:

TypeWhere it livesSurvives workflow edits?Masked in logs?
Inline env var (env:)Workflow file (git)Yes, until editedNo
Configuration variable (vars)GitHub UIYes, independentlyNo
Secret (secrets)GitHub (encrypted)Yes, independentlyYes

Scope: Repository, Environment, and Organization

Both secrets and configuration variables can be defined at three scopes. The narrowest scope always takes precedence when names collide.

Repository

Available to all workflows in one repository. The most common scope for project-specific credentials such as a container registry password.

Environment

Tied to a named deployment environment (for example, staging or production). A job must declare environment: staging to gain access. This allows different secrets per environment — for instance, different API keys for staging and production — without duplicating job definitions.

Organization

Shared across multiple repositories in an organization. Ideal for company-wide bot tokens or a central Slack webhook that all teams use for release notifications. Repository access can be limited to all, public-only, or selected repositories.

Connecting a job to an environment

jobs:
deploy-production:
runs-on: ubuntu-latest
environment: production # grants access to environment-scoped secrets and vars
steps:
- name: Deploy
env:
PROD_DB_PASSWORD: ${{ secrets.DB_PASSWORD }} # environment secret, not repo secret
run: ./scripts/deploy.sh --env production

Secret Masking in Practice

GitHub scans every line of log output for values that match a registered secret and replaces them with ***. This means an accidental echo of the secret value is safe — but it also means a secret that has been split across multiple concatenations may not be fully masked.

steps:
- name: Demonstrate masking
env:
WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
run: |
echo "Webhook is: $WEBHOOK_URL"
# Log output shows: Webhook is: ***
Masking protects against accidental exposure, but it is not a substitute for good secret hygiene. Never construct a secret by concatenating smaller pieces in a run: script — the fragments may appear unmasked.

Key Takeaways

  1. Use secrets for any value that authenticates or grants access: API keys, tokens, passwords, webhook URLs.
  2. Use configuration variables for non-sensitive settings that need to change per environment or be adjusted without a code change.
  3. Access secrets with ${{ secrets.NAME }} and configuration variables with ${{ vars.NAME }}.
  4. Prefer step-level env: injection for secrets to limit their exposure to the job steps that actually need them.
  5. Configuration variables work as feature flags: toggle a job on or off from the GitHub UI with no pull request required.
  6. Environment-scoped secrets allow different credentials per deployment target (staging vs. production) within the same workflow.
  7. GitHub automatically masks secret values in all log output — but never intentionally split or echo secrets in ways that could defeat masking.

Complete Working Example

The following is the full workflow.yaml for this topic. It demonstrates a three-job release pipeline (deploy, notify, audit) that uses secrets, configuration variables, and environment variables together.

name: Secrets and Configuration Variables - Web App Release

# Trigger on push to main so that every release candidate goes through
# the full deploy and notification pipeline automatically.
on:
push:
branches:
- main

# Workflow-level env var: shared across all jobs so every step knows the app name
# without hardcoding it. APP_NAME is not sensitive, so env var (not secret) is fine.
env:
APP_NAME: storefront-api

jobs:
# ---------------------------------------------------------------
# deploy: Pushes the built artifact to the cloud target defined
# by the DEPLOY_TARGET configuration variable. The actual API key
# used to authenticate is stored as a secret so it never appears
# in plain text anywhere in the workflow log.
# ---------------------------------------------------------------
deploy:
name: Deploy to ${{ vars.DEPLOY_TARGET }}
# vars.ENABLE_DEPLOY is a toggle variable — set it to 'false' in the
# GitHub UI to pause deployments without editing workflow code.
if: ${{ vars.ENABLE_DEPLOY == 'true' }}
runs-on: ubuntu-latest

# Job-level env var: scope DEPLOY_REGION to this job only because
# subsequent jobs (notify) do not need to know the deployment region.
env:
DEPLOY_REGION: ${{ vars.DEPLOY_REGION }}

steps:
# Check out source so the deploy script bundled in the repo is available.
- name: Checkout source
uses: actions/checkout@v4

# Print the resolved target and region so the log shows where we are
# deploying WITHOUT printing the secret itself.
- name: Log deployment context
run: |
echo "Deploying $APP_NAME to target: ${{ vars.DEPLOY_TARGET }}"
echo "Region: $DEPLOY_REGION"
echo "Triggered by: $GITHUB_ACTOR"
echo "Commit: ${GITHUB_SHA:0:7}"

# The DEPLOY_API_KEY secret is injected at step scope only.
# GitHub masks it in all log output automatically, so it cannot
# accidentally leak even if a run command echoes environment variables.
- name: Authenticate and deploy
env:
DEPLOY_API_KEY: ${{ secrets.DEPLOY_API_KEY }}
run: |
echo "Authenticating with deploy service..."
# The key is masked in the log — GitHub replaces its value with ***
./scripts/deploy.sh \
--target "${{ vars.DEPLOY_TARGET }}" \
--region "$DEPLOY_REGION" \
--app "$APP_NAME"

# Record the short SHA as a workflow output so the notify job can reference it.
- name: Set release tag
id: tag
run: echo "sha_short=${GITHUB_SHA:0:7}" >> "$GITHUB_OUTPUT"

# ---------------------------------------------------------------
# notify: Posts a Slack message after a successful deployment.
# The webhook URL is a secret; the channel name is a config variable
# because it is not sensitive and may need to change per environment.
# ---------------------------------------------------------------
notify:
name: Notify Slack
needs: deploy # only runs if deploy succeeds — no point notifying on failure here
runs-on: ubuntu-latest

steps:
# SLACK_WEBHOOK_URL is a secret: the URL itself grants posting rights,
# so it must never appear in logs or be stored as a plain variable.
# SLACK_CHANNEL is a config variable: it is not sensitive and may differ
# between staging and production without changing workflow code.
- name: Post release notification
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
SLACK_CHANNEL: ${{ vars.SLACK_CHANNEL }}
run: |
curl -s -X POST "$SLACK_WEBHOOK_URL" \
-H "Content-Type: application/json" \
-d "{
\"channel\": \"$SLACK_CHANNEL\",
\"text\": \"$APP_NAME deployed to ${{ vars.DEPLOY_TARGET }} by $GITHUB_ACTOR\"
}"

# ---------------------------------------------------------------
# audit: Runs only when the config variable AUDIT_ON_RELEASE is
# set to 'true'. Demonstrates using a variable as a feature flag
# to opt jobs in or out without editing the workflow file.
# ---------------------------------------------------------------
audit:
name: Release Audit
needs: deploy
# Feature-flag pattern: set vars.AUDIT_ON_RELEASE to 'true' in the UI
# to enable audit runs without committing any workflow changes.
if: ${{ vars.AUDIT_ON_RELEASE == 'true' }}
runs-on: ubuntu-latest

steps:
- name: Checkout source
uses: actions/checkout@v4

# AUDIT_TOKEN is a secret because it authenticates with an external
# compliance service — treat any credential as a secret, never a variable.
- name: Run compliance check
env:
AUDIT_TOKEN: ${{ secrets.AUDIT_TOKEN }}
AUDIT_LEVEL: ${{ vars.AUDIT_LEVEL }} # INFO / WARN / ERROR — not sensitive
run: |
echo "Audit level: $AUDIT_LEVEL"
echo "Running audit for $APP_NAME at commit ${GITHUB_SHA:0:7}"
./scripts/audit.sh --level "$AUDIT_LEVEL"
Share this lesson: