4.01 Environment Variables
Overview
Environment variables let you keep configuration values out of your code. In a workflow you set
them with the env key, and you can read them both in your application and in the workflow file
itself.
Abstract
env can be added at three levels: workflow, job, and step. A variable is available only at
its own level and below — a workflow-level variable works everywhere, a job-level variable
works only inside that job. You read a variable in two ways: $VARIABLE (the shell reads it
while the step runs) or ${{ env.VARIABLE }} (GitHub reads it before the step runs). Both give
the same result in a normal Linux step.
Why It Matters in Production
The same app usually runs against different settings in different places: a test database in CI, a real one in production. Environment variables are how you change those settings without changing code.
They also keep workflow files readable. A port number written once as PORT: 3000 can be reused in
the app, in a wait-on URL, and in a health check — change it in one place and everything follows.
Key Concepts
The three levels
| Level | Where you put env |
Who can see it |
|---|---|---|
| Workflow | Top level, beside name and on |
Every job in the workflow |
| Job | Inside a job, beside runs-on |
Only that job's steps |
| Step | Inside a step, beside run |
Only that step |
A lower level wins. If a job defines a variable with the same name as a workflow-level one, the job's value is used inside that job.
Jobs do not share job-level variables
A variable defined inside the test job is invisible in the deploy job. If a later job
prints it, the output is simply blank — no error. This surprises people, so if a value is
needed in several jobs, define it at workflow level.
The two ways to read a variable
| Syntax | Who reads it | When |
|---|---|---|
$PORT |
The shell on the runner | While the step runs |
${{ env.PORT }} |
GitHub | Before the step runs |
The env context is one of GitHub's context objects, like github, steps, and needs.
The $VAR syntax depends on the shell
$PORT is bash syntax (the default on Linux and macOS). On Windows runners the default is
PowerShell, where you write $env:PORT. You can also set shell: on a step to pick a
different shell — then use that shell's syntax. ${{ env.PORT }} works the same everywhere,
because GitHub substitutes it before any shell sees it.
Common Use Cases
- Database name, host, username, and port for an app under test
- A port number reused by the app and by the workflow's own commands
- Feature flags or a
NODE_ENVvalue that differs between jobs - API base URLs that point at staging in one job and production in another
- Any value you would otherwise repeat in several places in the file
Example Configuration or Commands
Workflow-level and job-level variables in one file:
name: Deployment
on:
push:
branches:
- main
- dev
env:
MONGODB_DB_NAME: gha-demo # available to every job
jobs:
test:
env: # available only inside this job
MONGODB_CLUSTER_ADDRESS: cluster0.ntrwp.mongodb.net
MONGODB_USERNAME: ${{ secrets.MONGODB_USERNAME }}
MONGODB_PASSWORD: ${{ secrets.MONGODB_PASSWORD }}
PORT: 8080
runs-on: ubuntu-latest
steps:
- name: Get Code
uses: actions/checkout@v4
- name: Cache dependencies
uses: actions/cache@v4
with:
path: ~/.npm
key: npm-deps-${{ hashFiles('**/package-lock.json') }}
- name: Install dependencies
run: npm ci
- name: Run server
run: npm start & npx wait-on http://127.0.0.1:$PORT # shell reads $PORT
- name: Run tests
run: npm test
- name: Output information
run: echo "MONGODB_USERNAME: ${{ env.MONGODB_USERNAME }}"
deploy:
needs: test
runs-on: ubuntu-latest
steps:
- name: Output information
run: |
echo "MONGODB_USERNAME: ${{ env.MONGODB_USERNAME }}" # blank here
echo "MONGODB_DB_NAME: $MONGODB_DB_NAME" # works here
In the deploy job the username prints as empty, because it was defined at job level inside
test. The database name prints fine, because it was defined at workflow level.
The same value used twice
PORT is set once in env. The app reads it to decide which port to listen on, and the
workflow reads it in the wait-on URL so the test step waits for the right address. Change
PORT: 8080 to something else and both follow automatically.
GitHub also provides variables you never define yourself:
# Always available on the runner
echo "$GITHUB_REPOSITORY" # owner/repo
echo "$GITHUB_REF_NAME" # branch name
echo "$GITHUB_SHA" # commit SHA
echo "$GITHUB_WORKSPACE" # path to the checked-out code
echo "$RUNNER_OS" # Linux, Windows, or macOS
Best Practices
Best Practices
- Put a variable at the narrowest level that works. Workflow level only for values genuinely shared by every job.
- Use
UPPER_SNAKE_CASEnames so they are easy to spot. - Prefer
${{ env.VAR }}when you want one syntax that works on every runner; use$VARinside longer shell scripts where it reads more naturally. - Define a value once and reference it, instead of repeating the same number or URL.
- Quote shell variables:
"$PORT", not$PORT, so values containing spaces don't break. - Use
$GITHUB_ENVwhen a value has to be computed during the run. - Keep real credentials in secrets (covered next) and reference them from
env.
Security Best Practices
Security
- Never type a real password or API key into the workflow file. The file lives in your
repository, so anyone who can read the repo can read the value — and it stays in Git history
even after you delete it. Use
${{ secrets.NAME }}instead, as in the example above. - Usernames, cluster addresses, and hostnames are also worth keeping in secrets on a public repository; together they make an attack easier.
- Echoing a variable prints it into the run log. GitHub masks registered secrets, but it cannot mask a value you typed in as plain text.
- Define secret-backed variables at job or step level, so only the steps that need them can read them.
- Be careful echoing anything derived from a secret — a substring or encoded version is not masked.
- If a credential ever gets committed, rotate it. Removing the line is not enough.
Do and Don't
| ✅ Do | ❌ Don't |
|---|---|
| Set shared values at workflow level | Copy the same value into every job |
| Set job-specific values at job level | Expect one job's env to reach another |
Use ${{ secrets.X }} for credentials |
Paste a password into the YAML |
Quote shell variables: "$PORT" |
Leave them unquoted |
Use ${{ env.VAR }} for cross-platform steps |
Assume $VAR works on Windows |
Use $GITHUB_ENV for computed values |
Try to set env: from a script |
Name variables in UPPER_SNAKE_CASE |
Use vague names like VALUE |
Common Mistakes
Common Mistakes
- Expecting job-level variables elsewhere. Another job prints nothing — no error, just an empty line.
- Wrong indentation. Workflow-level
envaligns withnameandon; job-levelenvaligns withruns-on. - Using
$VARon a Windows runner. PowerShell needs$env:VAR. - Trying to use
envin a place expressions aren't allowed. For example,runs-oncannot read theenvcontext. - Referencing a variable in the same
envblock that defines it. It isn't available yet. - Setting a variable with
exportin one step. Each step is a fresh shell; use$GITHUB_ENV. - YAML line-break problems. A long
runvalue often needs|and its own indented line — exactly the syntax error the demo hit. - Hardcoding credentials. Works immediately, costs you later.
Breaks in production
A database password written directly into the workflow file of a public repository is exposed the moment you push. Deleting it afterwards doesn't help, because the old commit still contains it — the credential has to be rotated.
Troubleshooting
# Print one variable to check it arrived
echo "PORT is: $PORT"
# See every environment variable available in a step (never on a public repo with secrets)
printenv | sort
# Check which values GitHub resolved before the step ran
echo "${{ toJSON(env) }}"
| Symptom | Likely cause |
|---|---|
| Variable prints as empty | Defined in another job, or at a narrower level |
Variable prints literally as $PORT |
Wrong shell syntax, or quoting swallowed it |
| Works on Linux, empty on Windows | PowerShell needs $env:PORT |
| App can't connect to the database | Variable name in the workflow doesn't match the one the code reads |
| "Unrecognized named-value: 'env'" | Used in a key that doesn't accept the env context |
| Value set in one step missing in the next | Used export instead of $GITHUB_ENV |
Secret shows as *** in the log |
Expected — GitHub masks it |
Tip
When a value looks missing, add a temporary step that echoes it right where you need it. That tells you instantly whether the problem is scope (wrong level) or syntax (wrong way of reading it).
Quick Recap
envsets environment variables at workflow, job, or step level.- Narrower levels override wider ones.
- Workflow-level variables reach every job; job-level variables stay in their job.
- Read a variable with
$VAR(shell) or${{ env.VAR }}(GitHub expression). $VARis bash syntax; Windows PowerShell uses$env:VAR.- The
shell:key changes the shell, and therefore the syntax. $GITHUB_ENVcreates a variable during the run for later steps.- GitHub supplies built-in variables like
$GITHUB_SHAand$RUNNER_OS. - Credentials belong in secrets, never typed into the file.
Interview / Revision Notes
Quick revision
Q: Where can env be defined?
Workflow level, job level, and step level.
Q: What happens when the same name is defined at two levels? The narrower one wins.
Q: Can one job read another job's env?
No. Only workflow-level variables are shared.
Q: Two ways to read a variable in a run command?
$VAR (read by the shell) and ${{ env.VAR }} (read by GitHub before the step runs).
Q: What's the Windows equivalent of $VAR?
$env:VAR in PowerShell.
Q: How do you change the shell for a step?
The shell: key, for example shell: bash or shell: python.
Q: How do you create a variable while the workflow runs?
Write NAME=value to $GITHUB_ENV; later steps can read it.
Q: Why shouldn't passwords go in env as plain text?
The workflow file is in the repository and in Git history — use ${{ secrets.NAME }}.
This topic can be expanded into sub-pages: secrets and how they differ from variables, repository and organization configuration variables (
vars), environment-scoped values, default GitHub variables, and$GITHUB_ENVin depth.