Skip to content

GitHub Actions Environment Variables and Configuration Variables

Lesson 8 of 11Beginner11 min readGitHub Actions & CI/CD · Actions FundamentalsVerified: GitHub Actions variables documentation, August 2026

Workflows deal with two kinds of value, and conflating them is a security problem rather than a style one.

Variables are configuration: a region, a log level, an environment name. Visible in logs, stored in plain text, fine for anyone to read.

Secrets are credentials: tokens, keys, passwords. Masked in logs, encrypted at rest, and covered in Secrets.

This lesson is about the first. The distinction is the last section, because it is the one that matters most.

env:
APP_NAME: my-service # every job, every step
jobs:
build:
runs-on: ubuntu-latest
env:
BUILD_MODE: release # every step in this job
steps:
- run: echo "$APP_NAME in $BUILD_MODE"
- run: echo "$LOG_LEVEL"
env:
LOG_LEVEL: debug # this step only

Narrower scopes win. A LOG_LEVEL set at all three levels resolves to the step’s value inside that step, the job’s elsewhere in the job, and the workflow’s elsewhere.

Precedence, most specific first:

  1. Step env
  2. Job env
  3. Workflow env
  4. Default environment variables set by the runner

Every runner has variables describing the run. The ones used constantly:

VariableContains
GITHUB_REPOSITORYowner/repo
GITHUB_SHAThe commit SHA being built
GITHUB_REFThe full ref — refs/heads/main
GITHUB_REF_NAMEThe short name — main
GITHUB_EVENT_NAMEWhat triggered this — push, pull_request
GITHUB_ACTORWho triggered it
GITHUB_RUN_IDThis run’s identifier
GITHUB_WORKSPACEWhere the repository is checked out
RUNNER_OSLinux, Windows, macOS
RUNNER_TEMPA temporary directory cleaned up afterwards
- run: |
echo "Building $GITHUB_REPOSITORY at $GITHUB_SHA"
echo "Triggered by $GITHUB_EVENT_NAME on $GITHUB_REF_NAME"

Most also have a context equivalent — github.sha, github.ref_name. The difference matters: the environment variable is read by the shell at runtime, the context is substituted by GitHub before the shell runs.

Each run: step is a separate shell process, so this does not work:

- run: export VERSION=1.4.0
- run: echo "$VERSION" # empty

$GITHUB_ENV is a file the runner reads after each step. Appending to it sets a variable for subsequent steps:

- run: echo "VERSION=1.4.0" >> "$GITHUB_ENV"
- run: echo "Version is $VERSION" # 1.4.0

Not for the step that set it — only later ones.

Multiline values need a delimiter, and the delimiter must not appear in the value:

- run: |
{
echo "NOTES<<MGA_EOF"
cat CHANGELOG.md
echo "MGA_EOF"
} >> "$GITHUB_ENV"

There is also $GITHUB_PATH, which prepends a directory to PATH for later steps:

- run: echo "$HOME/.local/bin" >> "$GITHUB_PATH"

Beyond env, GitHub stores variables at repository, organisation and environment level, read through the vars context.

jobs:
deploy:
runs-on: ubuntu-latest
environment: production
steps:
- run: ./deploy.sh
env:
REGION: ${{ vars.AWS_REGION }}
CLUSTER: ${{ vars.CLUSTER_NAME }}

Managed like secrets, without the encryption:

Terminal window
gh variable set AWS_REGION --body "eu-west-1"
gh variable set CLUSTER_NAME --body "prod-cluster" --env production
gh variable list
gh variable list --env production

The scopes resolve most-specific-first: environment, then repository, then organisation. An AWS_REGION defined at organisation level and overridden for the production environment resolves to the environment’s value in a job targeting it.

That layering is what makes vars more useful than env for anything differing per environment — one workflow, values that change with the target, no conditionals.

The decision, stated plainly:

VariableSecret
StoredPlain textEncrypted
Visible in the interfaceYes, value shownNo, name only
Masked in logsNoYes
Readable backYesNo
ForConfigurationCredentials

If disclosure would matter, it is a secret. If it would not, it is a variable.

A region name is not a secret. A database password is. An internal hostname sits between, and the conservative answer is usually right.

Variable syntax differs by shell, which matters in a matrix spanning operating systems.

- shell: bash
run: echo "$APP_NAME"
- shell: pwsh
run: Write-Output $env:APP_NAME
- shell: cmd
run: echo %APP_NAME%

Two ways to avoid the problem: set shell: bash everywhere, which works on GitHub-hosted Windows runners; or use the context form, which GitHub substitutes before any shell sees it:

- run: echo "${{ env.APP_NAME }}" # same everywhere

The context form has its own caveat — it is substitution, not variable reference — so it must not be used for untrusted values.

Variables attached to a deployment environment are the mechanism that lets one workflow deploy to several targets without conditionals.

jobs:
deploy:
runs-on: ubuntu-latest
environment: ${{ inputs.environment }}
steps:
- run: ./deploy.sh
env:
REGION: ${{ vars.REGION }}
CLUSTER: ${{ vars.CLUSTER_NAME }}
REPLICAS: ${{ vars.REPLICA_COUNT }}
Terminal window
gh variable set REGION --body "eu-west-1" --env staging
gh variable set REGION --body "eu-west-2" --env production
gh variable set REPLICA_COUNT --body "2" --env staging
gh variable set REPLICA_COUNT --body "12" --env production

One job definition, values resolved by the environment it targets. Compare with the alternative — a chain of if: conditions selecting values per branch — which duplicates the deployment logic once per environment and drifts.

This is the strongest argument for environments beyond their protection rules: they are a namespace for configuration as well as a gate.

Matrix values are available through the matrix context and are frequently combined with env:

strategy:
matrix:
include:
- target: staging
region: eu-west-1
- target: production
region: eu-west-2
steps:
- run: ./deploy.sh
env:
TARGET: ${{ matrix.target }}
REGION: ${{ matrix.region }}

include with several keys per entry is how a matrix carries related values rather than just a list — covered in Matrix Builds.

Small habits that prevent real confusion.

Prefix by scope where it helps. CI_LOG_LEVEL reads differently from APP_LOG_LEVEL, and in a workflow that sets both, the distinction matters.

Avoid names the runner already uses. Anything starting GITHUB_ or RUNNER_ is reserved territory. Setting GITHUB_TOKEN in an env: block shadows the real one in ways that are hard to trace.

Use UPPER_SNAKE_CASE. Conventional for environment variables and consistent with what the runner provides.

Do not encode secrets in names. PROD_DB_PASSWORD_2 as a variable name tells a reader more than you intended about what exists.

A summary, since there are now four mechanisms.

NeedUse
A constant for the whole workflowWorkflow env
A value only one job needsJob env
A value only one step needsStep env
A value computed by a step, needed later$GITHUB_ENV
Configuration differing per environmentEnvironment vars
Configuration shared across repositoriesOrganisation vars
Anything whose disclosure would matterA secret
A value another job needsA job output

The two most common mistakes are using a secret for configuration — losing visibility for no benefit — and using workflow env for something that differs per environment, which forces conditionals that environment-scoped vars would remove.

export between steps. Each step is a new shell; use $GITHUB_ENV.

Expecting $GITHUB_ENV to affect the current step. It applies to later ones.

A secret in a variable. Plain text, unmasked, visible.

Multiline without a delimiter. Breaks the file format.

Shell-specific syntax in a cross-platform matrix. Set shell: bash or use the context form.

Writing unvalidated event data to $GITHUB_ENV. Lets untrusted input define later variables.

env where vars belongs. Values differing per environment want environment-scoped variables.

Variables fail quietly — an empty value rather than an error — so a systematic approach beats guessing.

- name: Dump environment
run: env | sort

That prints everything the shell can see, including the defaults GitHub set. It is the fastest way to answer “is my variable there at all?”.

The checks, in order:

  1. Is the name spelled identically? Environment variables are case-sensitive on Linux and macOS.
  2. Is it in scope? A step-level env: does not reach the next step.
  3. Was it set with export in a previous step? That does not persist; use $GITHUB_ENV.
  4. Is it shadowed? A narrower scope wins, and a step-level value overrides one from $GITHUB_ENV.
  5. For vars, is the scope right? An environment variable is invisible to a job that does not declare that environment.
  6. For vars, is it defined at all? An undefined vars.SOMETHING is empty, not an error.

Item 5 is the most common vars problem: the variable exists, the job does not declare environment:, and the reference resolves to nothing.

A reusable workflow does not inherit the caller’s env block. Values must be passed as inputs:

# Caller
jobs:
deploy:
uses: ./.github/workflows/deploy.yml
with:
log-level: debug
region: eu-west-1
# Called
on:
workflow_call:
inputs:
log-level: { type: string, required: false, default: info }
region: { type: string, required: true }
jobs:
run:
runs-on: ubuntu-latest
env:
LOG_LEVEL: ${{ inputs.log-level }}
REGION: ${{ inputs.region }}
steps:
- run: ./deploy.sh

Organisation and repository vars, by contrast, are visible to a called workflow, because they belong to the repository rather than to the calling workflow. That asymmetry catches people: env does not cross the boundary, vars does.

  1. Set the same variable name at workflow, job and step level, and print it from each scope.
  2. Try passing a value between steps with export; confirm it is empty.
  3. Do it with $GITHUB_ENV, and confirm it is empty in the setting step and populated afterwards.
  4. Append a multiline value using a heredoc delimiter.
  5. Create a repository variable with gh variable set and read it through vars.
  6. Print env on a Linux and a Windows runner in the same matrix and compare the syntax needed.

Beyond the GITHUB_* values, the runner exposes several directories worth knowing:

VariableContains
GITHUB_WORKSPACEWhere the repository is checked out
RUNNER_TEMPScratch space, cleaned up after the job
RUNNER_TOOL_CACHEPre-installed language versions
GITHUB_ACTION_PATHAn action’s own directory, inside that action

RUNNER_TEMP is the correct place for anything transient — a downloaded archive, an intermediate file. Writing to the workspace instead pollutes the checkout, which matters if a later step inspects git status or uploads the directory.

GITHUB_ACTION_PATH is how a composite action references files that ship with it:

- shell: bash
run: "${GITHUB_ACTION_PATH}/scripts/setup.sh"

Without it, a relative path resolves against the caller’s working directory rather than the action’s own — which works while developing an action locally and breaks as soon as anyone else uses it.

  • env applies at workflow, job and step scope, narrowest winning.
  • Default variables describe the run; most have a context equivalent evaluated at a different time.
  • $GITHUB_ENV passes values to later steps; $GITHUB_PATH extends PATH.
  • vars holds configuration at environment, repository and organisation scope, resolving most-specific-first.
  • Variables are plain text and unmasked; secrets are neither.
  • Shell syntax for variables differs by platform, and the context form sidesteps it.

$GITHUB_ENV is one of several files the runner reads between steps. Knowing all of them is worthwhile because each solves a different problem.

FileEffect
$GITHUB_ENVSets environment variables for later steps
$GITHUB_PATHPrepends a directory to PATH for later steps
$GITHUB_OUTPUTPublishes a named output from this step
$GITHUB_STEP_SUMMARYAppends Markdown to the run’s summary page
- run: |
echo "VERSION=1.4.0" >> "$GITHUB_ENV"
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
echo "sha=$(git rev-parse --short HEAD)" >> "$GITHUB_OUTPUT"
echo "### Built version 1.4.0" >> "$GITHUB_STEP_SUMMARY"

All four are append-only files, and all four apply to subsequent steps rather than the current one. That last property is consistent and is worth internalising once rather than rediscovering per file.

$GITHUB_PATH is the correct way to make an installed tool available. Modifying PATH with export does not survive the step, and writing to $GITHUB_ENV with a PATH= line replaces the whole variable rather than extending it — which breaks everything else on the runner.

Precedence is easier to trust once you have watched it resolve.

env:
STAGE: workflow
jobs:
demo:
runs-on: ubuntu-latest
env:
STAGE: job
steps:
- run: echo "1: $STAGE" # job
- run: echo "2: $STAGE" # step
env:
STAGE: step
- run: echo "STAGE=file" >> "$GITHUB_ENV"
- run: echo "3: $STAGE" # file
- run: echo "4: $STAGE" # step — overrides the file
env:
STAGE: step-again

Output:

1: job
2: step
3: file
4: step-again

The rule that emerges: a value written to $GITHUB_ENV behaves as if it were set at job level for subsequent steps — it overrides the workflow level and is itself overridden by a step-level env:.

Variables in expressions versus in the shell

Section titled “Variables in expressions versus in the shell”

The same value is reachable two ways, and they resolve at different times:

- run: echo "context: ${{ env.APP_NAME }}" # substituted by GitHub
- run: echo "shell: $APP_NAME" # read by the shell

Both print the same thing for a trusted value. They differ in three ways that matter:

Timing. The context form is substituted before the shell runs; the shell form is resolved during execution. A variable set by an earlier command in the same step is visible to the shell form and not to the context form.

Portability. The context form is identical on every platform; the shell form needs $VAR, $env:VAR or %VAR% depending on the shell.

Safety. The context form injects text into the command. For anything untrusted, only the shell form — reading from env: — is safe.

The practical rule: use the shell form by default, and reach for the context form only where the value must be interpolated into something that is not a shell command, such as an action input.

For a repository deploying to two environments, the layout that avoids conditionals entirely:

Organisation variables DOCKER_REGISTRY, DEFAULT_TIMEZONE
Repository variables SERVICE_NAME, HEALTHCHECK_PATH
Environment: staging REGION=eu-west-1, REPLICAS=2, LOG_LEVEL=debug
Environment: production REGION=eu-west-2, REPLICAS=12, LOG_LEVEL=info
Environment secrets per-environment credentials, gated by protection rules
Workflow env build flags that never vary

One deployment job reads vars.REGION and gets the right value because it declared environment: production. No if: chains, no duplicated job definitions, and adding a third environment means adding variables rather than editing the workflow.

That separation — configuration in the platform, logic in the workflow — is what keeps a deployment pipeline maintainable as environments multiply.

Three storage mechanisms, one question each.

env — does this value belong in the workflow file, where anyone reading it can see it? If yes, it is env.

vars — does it differ by environment or repository, and would you rather change it without editing the workflow? Then it is a variable.

secrets — would disclosure matter? Then it is a secret, and the next question is whether OIDC could remove the need for it entirely.

GitHub Actions Security ChecklistAudit your workflows against the failure modes that actually cause incidents. Free and complete.

The CI starter template — least-privilege permissions, pinned actions, correct checkout — is in the Professional Toolkit.