Environment variables let you inject configuration values into a workflow without hard-coding them. GitHub Actions supports three distinct scopes — workflow, job, and step — plus a rich set of built-in default variables provided automatically on every runner.
What Is an Environment Variable?
An environment variable is a named value stored in the shell environment of the process running a step. Every program launched inside a step can read these values through standard OS mechanisms ($VAR on Linux/macOS, %VAR% on Windows).
In GitHub Actions you declare environment variables with an env: block. Values are then available in two ways:
- As plain shell variables inside
run: scripts: $VAR_NAME - Through the
env context anywhere in the YAML: ${{ env.VAR_NAME }}
Scopes: Workflow, Job, and Step
You can write an env: block at three different levels. Each level determines how widely the variable is visible:
| Scope | Where you write env: | Who can read it |
| Workflow | Top of the YAML file, before jobs: | Every job and every step in the file |
| Job | Inside a jobs.<id>: block | All steps inside that specific job |
| Step | Inside a single steps[*]: entry | That one step only |
When the same name exists at multiple scopes, the narrowest scope wins: step overrides job, job overrides workflow.
Minimal Example — Node.js Build Pipeline
env:
APP_NAME: my-api # workflow-level: visible in every job
jobs:
build:
runs-on: ubuntu-latest
env:
NODE_ENV: production # job-level: visible only in this job
steps:
- name: Install packages
env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }} # step-level: this step only
run: |
echo "Installing $APP_NAME in $NODE_ENV mode"
npm install
- name: Smoke test (override NODE_ENV)
env:
NODE_ENV: test # step scope overrides the job scope value
run: echo "Running under $NODE_ENV"
APP_NAME is readable everywhere. NODE_ENV defaults to production for the whole job, but the last step overrides it with test — the step scope takes precedence.
Custom vs. Default Environment Variables
Variables you write in an env: block are custom environment variables. GitHub also provides default environment variables automatically — you never declare them yourself.
Custom environment variables
Defined by you. Any valid identifier. Useful for app names, target branches, feature flags, and other configuration that should not be secrets.
Default environment variables
Injected by GitHub at job start. Always prefixed with GITHUB_ or RUNNER_. Available examples:
GITHUB_ACTOR — the user who triggered the workflowGITHUB_SHA — full commit hash of the triggering eventGITHUB_REF_NAME — branch or tag name (e.g. main)GITHUB_SERVER_URL — base URL of your GitHub instanceRUNNER_OS — operating system of the runner (Linux, Windows, macOS)RUNNER_ARCH — CPU architecture (X64, ARM64, …)
Default Variable Lifetime
There is a critical timing difference between default environment variables and context expressions:
Default environment variables (GITHUB_*, RUNNER_*) are injected when a job starts on the runner. Context expressions (${{ github.* }}, ${{ runner.* }}) are resolved before the job is dispatched to a runner, during workflow evaluation.
Practical consequence: you cannot use $GITHUB_SHA inside an if: condition (the runner hasn't started yet), but ${{ github.sha }} works perfectly there because it is evaluated earlier.
| Feature | Default env variable ($GITHUB_SHA) | Context expression (${{ github.sha }}) |
Available in if: | No | Yes |
Available in run: | Yes | Yes |
| Evaluated | On the runner at job start | During workflow processing |
Using Default Variables to Tag a Docker Image
Default variables can be combined to produce useful runtime values. A common pattern is tagging a Docker image with the exact commit SHA so every build is uniquely traceable back to its source code:
jobs:
build-image:
runs-on: ubuntu-latest
env:
APP_NAME: my-api # custom: the image name
steps:
- name: Compute image tag
run: |
IMAGE_TAG="docker.io/myorg/$APP_NAME:$GITHUB_SHA"
echo "Pushing $IMAGE_TAG"
# docker push $IMAGE_TAG # would run in a real pipeline
Sample output:
Pushing docker.io/myorg/my-api:a3f8c21d94e0...
You can also compose a link to the current workflow run for Slack notifications or PR comments:
- name: Print run link
run: echo "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID"
The exact same link using context expressions (useful when the value is needed before the runner starts):
- name: Print run link (context version)
run: echo "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
Cross-Platform Steps with RUNNER_OS
When a workflow targets multiple operating systems, you can gate steps using the runner.os context property. Pair it with the RUNNER_ARCH default variable to log full platform details:
jobs:
health-check:
runs-on: ubuntu-latest
steps:
- name: Unix health check
if: runner.os != 'Windows' # context property — safe in if:
run: |
echo "Running Unix check on $RUNNER_OS" # env variable on the runner
- name: Windows health check
if: runner.os == 'Windows'
run: echo "Running Windows check on $RUNNER_OS"
- name: Log architecture
run: echo "Architecture: $RUNNER_ARCH"
Output on an ubuntu-latest runner:
Running Unix check on Linux
Architecture: X64
Setting Default Shell and Working Directory
Use a defaults.run block to set a consistent shell and working directory for all steps. This saves you from repeating options on every run: entry. Job-level defaults override workflow-level defaults.
on:
push:
# Workflow-level defaults — every job uses bash unless overridden
defaults:
run:
shell: bash
jobs:
build:
runs-on: ubuntu-latest
# This job inherits bash from the workflow-level defaults
steps:
- run: echo "Running with bash"
lint:
runs-on: ubuntu-latest
# Override for this job only — sh is lighter for simple lint scripts
defaults:
run:
shell: sh
steps:
- run: echo "Running with sh"
Repository, Organization, and Environment Variables
Variables declared in a workflow YAML file are local to that file. When you need the same value across many workflows, use these GitHub-managed mechanisms instead:
Secrets
Encrypted values for sensitive data (API keys, tokens, passwords). Defined at the repository, environment, or organization level in GitHub Settings. Accessed with ${{ secrets.MY_SECRET }}.
Configuration Variables (vars)
Plain-text, non-secret values shared across workflows. Defined in GitHub Settings alongside secrets. Accessed with ${{ vars.MY_VAR }}. Useful for things like region names, Docker registry URLs, or feature flags.
Both are covered in detail in the Secrets and Configuration Variables topic.
Key Takeaways
- Use
env: at the workflow, job, or step level — narrowest scope wins. - Step scope overrides job scope, which overrides workflow scope.
- Default variables (
GITHUB_*, RUNNER_*) are injected at job start on the runner. - Use context expressions (
${{ github.* }}) in if: conditions; use env variables inside run: scripts. defaults.run sets a consistent shell and working directory; job-level overrides workflow-level.- For values shared across workflows, use repository/organization secrets or configuration variables.
Complete Working Example
The following is a complete, runnable GitHub Actions workflow that demonstrates every concept covered in this topic. Each significant line includes a comment explaining why it is written that way.
# This workflow simulates a minimal Node.js build and deploy pipeline
# to demonstrate environment variable scopes and default variables.
name: Environment Variables - Node.js Deploy Pipeline
# Trigger on every push to main so the pipeline exercises the env variable setup.
on:
push:
branches:
- main
# APP_NAME is defined once at the top so every job shares the same value.
# Updating it in one place propagates the change everywhere automatically.
env:
APP_NAME: my-api
# Set bash as the pipeline-wide default shell so scripts behave consistently
# regardless of which job or runner executes them.
defaults:
run:
shell: bash
jobs:
# ---------------------------------------------------------------
# Job 1: build — install dependencies and produce a Docker image tag
# ---------------------------------------------------------------
build:
runs-on: ubuntu-latest
# NODE_ENV controls how npm resolves packages (no devDependencies in production).
# Setting it at job level avoids repeating it in every step.
env:
NODE_ENV: production
steps:
- name: Check out source code
uses: actions/checkout@v4
# Checkout is required so the runner has access to package.json and source files.
- name: Install npm packages
# NPM_TOKEN is placed at step level, not job or workflow level,
# to limit the window in which the secret exists in the environment.
env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
run: |
echo "Installing packages for $APP_NAME in $NODE_ENV mode"
# npm ci --prefer-offline # would run in a real project
- name: Generate Docker image tag from commit SHA
run: |
# GITHUB_SHA is a default variable injected by the runner at job start.
# Tagging images with the exact commit SHA makes every build uniquely traceable.
IMAGE_TAG="docker.io/myorg/$APP_NAME:$GITHUB_SHA"
echo "Image tag: $IMAGE_TAG"
- name: Demonstrate scope override (QA hotfix scenario)
env:
# This step redefines NODE_ENV, overriding the job-level value.
# Step scope always wins — useful when one step needs different behaviour.
NODE_ENV: test
run: |
echo "Running under NODE_ENV=$NODE_ENV for quick smoke test"
# ---------------------------------------------------------------
# Job 2: report — print run metadata using default variables
# ---------------------------------------------------------------
report:
runs-on: ubuntu-latest
needs: build # wait for build to succeed before printing the summary
steps:
- name: Print pipeline metadata
run: |
# GITHUB_ACTOR — the user account that triggered the push.
# Useful for audit logs, Slack announcements, or email notifications.
echo "Triggered by : $GITHUB_ACTOR"
# GITHUB_REF_NAME — the branch or tag that started the workflow, e.g. main.
# Tells reviewers exactly which branch produced this run.
echo "Branch : $GITHUB_REF_NAME"
# GITHUB_SHA — full commit hash; sliced to 7 chars for readability in logs.
echo "Commit : ${GITHUB_SHA:0:7}"
- name: Print direct link to this run
run: |
# Composing the run URL lets teammates jump straight to this execution
# from a Slack message, PR comment, or deployment dashboard.
echo "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID"
- name: Same URL built with context expressions
run: |
# Context expressions (${{ ... }}) are resolved before the runner starts.
# Inside a run: block the result is identical to env variable concatenation.
echo "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
# ---------------------------------------------------------------
# Job 3: health-check — cross-platform conditional steps
# ---------------------------------------------------------------
health-check:
runs-on: ubuntu-latest
steps:
- name: Unix health check
# runner.os is a context property resolved during workflow processing,
# so it is safe to use in if: conditions (before the runner starts).
if: runner.os != 'Windows'
run: |
# $RUNNER_OS is the equivalent default env variable available on the runner.
echo "Running Unix health check on $RUNNER_OS"
- name: Print runner architecture
run: |
# RUNNER_ARCH reveals whether the runner is X64, ARM64, etc.
# Critical when compiling native binaries for a specific target architecture.
echo "Architecture: $RUNNER_ARCH"
# ---------------------------------------------------------------
# Job 4: lint — override the default shell at job level
# ---------------------------------------------------------------
lint:
runs-on: ubuntu-latest
# Override the workflow-level bash default with sh for this job.
# sh is a lighter POSIX shell — more than enough for simple lint commands
# and faster to start than bash.
defaults:
run:
shell: sh
steps:
- uses: actions/checkout@v4
- name: Run linter in sh
run: echo "Linting $APP_NAME with sh shell"
Next topic: Secrets and Configuration Variables
Link Interceptor Active
Environment variables let you pass configuration values into workflow steps without hard-coding them. GitHub Actions supports three distinct scopes — workflow, job, and step — plus a rich set of built-in default variables provided automatically on every runner.
What Is an Environment Variable?
An environment variable is a named value stored in the shell environment of the process running a step. Every program that runs inside a step can read these variables through standard OS mechanisms (e.g. $VAR on Linux/macOS, %VAR% on Windows CMD).
In GitHub Actions you declare environment variables using an env: mapping. The values are then available in the env context (as ${{ env.VAR_NAME }}) and as plain shell variables (as $VAR_NAME) inside run: blocks.
Scopes: Workflow, Job, and Step
You can define env: at three different places in a workflow file, each with a different scope:
| Scope | Where you write env: | Accessible in |
| Workflow | Top level of the YAML file | All jobs and all steps in this workflow |
| Job | Inside a specific jobs.<job-id>: block | All steps in that job only |
| Step | Inside a specific steps[*]: entry | That single step only |
When the same variable name exists at multiple scopes, the narrowest scope wins: step overrides job, job overrides workflow.
Minimal Example
# workflow level — available everywhere in this file
env:
PIPE: cicd
jobs:
build:
# job level — overrides workflow level for this job
env:
STAGE: dev
steps:
- name: create item with token
# step level — only visible inside this step
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: echo "Pipeline=$PIPE Stage=$STAGE"
Custom vs. Default Environment Variables
Variables you define with env: are called custom environment variables. GitHub Actions also provides a large set of default environment variables automatically — you never have to declare them yourself.
Custom environment variables
Defined by you in env: blocks. Names can be anything you choose.
Default environment variables
Provided by GitHub. Always prefixed with GITHUB_ or RUNNER_. Examples:
GITHUB_WORKFLOW — name of the currently running workflowGITHUB_RUN_ID — unique numeric ID for this runGITHUB_SERVER_URL — base URL of the GitHub serverGITHUB_REPOSITORY — owner/repo of this repositoryRUNNER_OS — operating system of the runner (Linux, Windows, macOS)
Default Variable Lifetime
An important distinction between default environment variables and contexts:
Default environment variables (GITHUB_*, RUNNER_*) exist only on the runner — they are injected when a job starts executing. Context expressions (${{ github.* }}, ${{ runner.* }}) are resolved before the job reaches a runner, during workflow processing.
This means you cannot use $GITHUB_SHA in an if: condition (evaluated before the runner), but ${{ github.sha }} works fine there.
Combining Default Variables: Building a Run URL
Default variables can be combined to produce useful runtime values. The following job prints the direct URL to the current workflow run:
jobs:
report-url:
runs-on: ubuntu-latest
steps:
- run: echo $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID
Sample output:
https://github.com/gwstudent2/greetings-ci/actions/runs/4744932978
The equivalent written with context expressions produces the same result:
jobs:
report-url:
runs-on: ubuntu-latest
steps:
- run: echo ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
Using RUNNER_OS in a Conditional Step
RUNNER_OS (env variable) and runner.os (context property) both return the OS of the current runner. You can use either in a conditional step:
report-os:
runs-on: ubuntu-latest
steps:
- name: check-os
if: runner.os != 'Windows'
run: echo "The runner's operating system is $RUNNER_OS."
Output on an ubuntu-latest runner:
The runner's operating system is Linux.
Setting Default Shell and Working Directory
You can set default values for shell and working-directory at the workflow or job level using a defaults.run block. Job-level defaults override workflow-level defaults.
on:
push:
# Workflow-level defaults — apply to every job unless overridden
defaults:
run:
shell: bash
working-directory: workdir
jobs:
test:
runs-on: ubuntu-latest
# Job-level defaults — override the workflow defaults for this job only
defaults:
run:
shell: sh
working-directory: test
steps:
- uses: actions/checkout@v3
- run: echo "in test"
Repository, Organization, and Environment Variables
Environment variables defined inside a workflow file are local to that workflow. For values shared across multiple workflows, GitHub provides two mechanisms:
Secrets
Encrypted values for sensitive data (tokens, passwords, API keys). Defined at the repository, environment, or organization level. Accessed as ${{ secrets.MY_SECRET }}.
Configuration Variables (vars)
Plain-text, non-sensitive values shared across workflows. Defined at the repository, environment, or organization level. Accessed as ${{ vars.MY_VAR }}.
These are covered in detail in the Secrets and Configuration Variables lesson.
Key Takeaways
- Use
env: at the workflow, job, or step level to define custom variables. - Narrower scope wins: step > job > workflow.
- Default variables (
GITHUB_*, RUNNER_*) are injected automatically on the runner. - Default variables exist only on the runner; context expressions are resolved earlier.
- Use
defaults.run to set a consistent shell and working directory. - For cross-workflow sharing, use repository/organization secrets or configuration variables.
Complete Working Example
The following is a real, runnable GitHub Actions workflow that demonstrates every concept covered in this lesson. Each line is annotated with a comment explaining why it is there, not just what it does.
# Lesson 07 — Environment Variables
# This workflow demonstrates custom environment variables at three scopes
# (workflow, job, step), default environment variables, defaults.run, and
# how to compose a run URL from built-in GITHUB_* variables.
name: Lesson 07 - Environment Variables Demo
# Trigger on every push to any branch so we can observe the variables in action.
on:
push:
# Workflow-level custom environment variable.
# Everything in this file can read PIPE via $PIPE or ${{ env.PIPE }}.
env:
PIPE: cicd
# Set a default shell and working-directory for all steps in every job.
# Individual jobs can override these with their own defaults.run block.
defaults:
run:
shell: bash
jobs:
# ------------------------------------------------------------------
# Job 1: show all three scopes of custom environment variables
# ------------------------------------------------------------------
show-custom-vars:
runs-on: ubuntu-latest
# Job-level custom environment variable.
# Only steps within this job can read STAGE.
env:
STAGE: dev
steps:
- name: Print workflow-level and job-level vars
run: |
# $PIPE comes from the workflow-level env block.
# $STAGE comes from the job-level env block.
# Both are visible here because this step is inside the job.
echo "Pipeline : $PIPE"
echo "Stage : $STAGE"
- name: Print a step-level variable (GITHUB_TOKEN example)
# Step-level env — only this step can read GITHUB_TOKEN.
# We define it here rather than at the workflow level to limit
# the token's exposure to only the step that actually needs it.
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
echo "Token is set: ${{ env.GITHUB_TOKEN != '' }}"
- name: Demonstrate scope override
env:
# This step redefines STAGE, overriding the job-level value.
# Step scope always wins over job scope, which wins over workflow scope.
STAGE: production
run: |
echo "Overridden stage: $STAGE"
# ------------------------------------------------------------------
# Job 2: use built-in default environment variables
# ------------------------------------------------------------------
show-default-vars:
runs-on: ubuntu-latest
steps:
- name: Print default GITHUB_* variables
run: |
# GITHUB_WORKFLOW — name of this workflow as declared in the 'name:' field above.
echo "Workflow name : $GITHUB_WORKFLOW"
# GITHUB_RUN_ID — unique integer ID for this specific run.
# Useful for linking back to artifacts or logs.
echo "Run ID : $GITHUB_RUN_ID"
# GITHUB_REPOSITORY — owner/repo format; needed when calling the GitHub REST API.
echo "Repository : $GITHUB_REPOSITORY"
# RUNNER_OS — the OS type of the machine executing this job.
# Useful for conditional logic in cross-platform workflows.
echo "Runner OS : $RUNNER_OS"
- name: Build the URL of this workflow run
run: |
# Combine three default variables to form the direct URL to this run.
# Anyone reading the logs can click this link to navigate straight to the run.
echo "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID"
- name: Same URL using context expressions instead
run: |
# Context expressions (${{ ... }}) are resolved before the runner starts,
# but inside a 'run:' block the result is the same as the env variable approach.
echo "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
# ------------------------------------------------------------------
# Job 3: conditional step using runner.os context
# ------------------------------------------------------------------
show-os-conditional:
runs-on: ubuntu-latest
steps:
- name: Report OS (skip on Windows)
# runner.os context property — evaluated during workflow processing.
# We skip this step entirely when the runner is Windows to avoid
# a bash-style echo that Windows CMD would not understand.
if: runner.os != 'Windows'
run: |
# $RUNNER_OS is the equivalent default env variable, available on the runner.
echo "The runner's operating system is $RUNNER_OS."
# ------------------------------------------------------------------
# Job 4: defaults.run override at job level
# ------------------------------------------------------------------
show-defaults-override:
runs-on: ubuntu-latest
# Override the workflow-level default shell for this job only.
# Using 'sh' instead of 'bash' is lighter and sufficient for simple echo commands.
defaults:
run:
shell: sh
steps:
- name: Echo inside sh shell
run: echo "This step runs in sh, not bash"
Next topic: Secrets and Configuration Variables — storing sensitive and shared values securely at the repository, environment, and organization level.