Skip to content

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_ENV value 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.

- name: Run one command
  env:
    GREETING: hello
  run: echo "$GREETING"
env:
  NODE_ENV: production        # workflow level

jobs:
  test:
    env:
      NODE_ENV: test          # wins inside this job
# Linux / macOS (bash)
- run: echo "$PORT"

# Windows (PowerShell)
- run: echo "$env:PORT"

# Works on any runner and any shell
- run: echo "${{ env.PORT }}"
- name: Run with bash on Windows
  shell: bash
  run: echo "$PORT"

- name: Run Python
  shell: python
  run: |
    import os
    print(os.environ['PORT'])
# Writing to $GITHUB_ENV makes the value available to LATER steps
- name: Set a value
  run: echo "BUILD_ID=$(date +%s)" >> "$GITHUB_ENV"
- name: Use it
  run: echo "$BUILD_ID"

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_CASE names so they are easy to spot.
  • Prefer ${{ env.VAR }} when you want one syntax that works on every runner; use $VAR inside 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_ENV when 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 env aligns with name and on; job-level env aligns with runs-on.
  • Using $VAR on a Windows runner. PowerShell needs $env:VAR.
  • Trying to use env in a place expressions aren't allowed. For example, runs-on cannot read the env context.
  • Referencing a variable in the same env block that defines it. It isn't available yet.
  • Setting a variable with export in one step. Each step is a fresh shell; use $GITHUB_ENV.
  • YAML line-break problems. A long run value 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

  • env sets 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).
  • $VAR is bash syntax; Windows PowerShell uses $env:VAR.
  • The shell: key changes the shell, and therefore the syntax.
  • $GITHUB_ENV creates a variable during the run for later steps.
  • GitHub supplies built-in variables like $GITHUB_SHA and $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_ENV in depth.