Use environment variables

Learn to use environment variables in CI/CD pipelines with GitHub Actions. This lesson explains why environment variables matter, how to define and reference them, and how to handle secrets securely. Includes a hands-on walkthrough, troubleshooting tips, and what to study next.

Focus: use environment variables in pipelines

Sponsored

You’ve just pushed a commit, and the pipeline kicks off. Everything runs smoothly — until you realize your test suite needs a database URL, your deploy step needs an API key, and your Slack notification needs a webhook URL. Hardcoding those values into your workflow files is a security nightmare and a maintenance trap. In this lesson, you’ll learn how to use environment variables in pipelines to keep your configuration flexible, secure, and centralized.

The problem this lesson solves

When you first start writing CI/CD pipelines, it’s tempting to hardcode configuration values directly into your workflow files. For example, you might write npm test with a database connection string embedded in the command, or put an API key directly in a script step. This works for a demo, but it creates three serious problems:

  • Security leaks — Secrets committed to a repository can be exposed to anyone with read access. Even private repos can be compromised through forks or misconfigured permissions.
  • Inflexibility — You want the same pipeline to run on different environments (staging, production) with different settings. Hardcoding means duplicating workflows or editing code for each environment.
  • Maintenance overhead — When a value changes (like a database hostname), you have to update every workflow file and every script that references it. That’s error-prone and time-consuming.

Environment variables solve this by separating configuration from code. They let you define values once, reference them anywhere in your pipeline, and keep secrets out of your repository.

Core concept / mental model

Think of environment variables as a key-value store that your pipeline can read at runtime. When a job starts, the runner (like GitHub Actions, GitLab CI, or Jenkins) loads a set of variables into the environment of every process it runs. Your workflow steps can then access those values by name, just like you would in a local shell.

Here’s the mental model:

  • Variables are like sticky notes on a whiteboard. Anyone in the room can read them, but they’re not secret.
  • Secrets are like notes locked in a safe. Only people with the combination can see them, and the safe never shows its contents in logs.
  • The pipeline is the room where the whiteboard and safe live. Every step runs in the same room, so it can read any sticky note or open the safe — if it has the right credentials.

In GitHub Actions, environment variables can be set at three levels:

Level Where defined Scope Example
Workflow env: key in the workflow file Entire workflow, including all jobs env: NODE_ENV: production
Job env: under a job That job only env: DB_URL: ${{ vars.DB_URL }}
Step env: under a step That step only env: API_KEY: ${{ secrets.API_KEY }}

This scoping helps you control exactly which parts of your pipeline see which values.

How it works step by step

Setting and using environment variables in a pipeline follows a straightforward pattern. Here’s the logical sequence:

  1. Define the variable — Decide where it should live: as a workflow-level env, a job-level env, or a step-level env. If it’s a secret, store it in your CI provider’s secrets store (e.g., GitHub Secrets) and reference it with secrets.VARIABLE_NAME.
  2. Reference the variable — Use the $VARIABLE_NAME syntax from a shell step (this is $VARIABLE_NAME in bash, $env:VARIABLE_NAME in PowerShell). In GitHub Actions, you can also use the ${{ env.VARIABLE_NAME }} expression syntax inside workflow YAML.
  3. Override per environment — Use the same workflow for different environments by loading values from environment-specific secrets or variables (e.g., database-prod vs database-staging).
  4. Keep secrets safe — Never print secrets to logs. Use masking features (GitHub Actions automatically masks secrets) and avoid echoing them.

The cause-and-effect chain is: define → inject → consume. The pipeline runner injects the variables into the process environment, and any subprocess (like npm, python, or bash) can read them.

Hands-on walkthrough

Let’s walk through a practical example using GitHub Actions. We’ll build a simple workflow that runs tests and deploys, using environment variables for configuration and a secret for an API key.

Step 1: Set up a repository with a workflow file

Create a new repo (or use an existing one) and add a workflow file at .github/workflows/ci.yml. Here’s a minimal workflow that uses environment variables:

name: CI

on: [push]

env:
  NODE_ENV: production
  APP_NAME: my-app

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Run tests
        run: |
          echo "Running tests for $APP_NAME in $NODE_ENV mode"
          # Your test command goes here
          npm test
        env:
          DB_URL: postgres://localhost:5432/mydb

When this workflow runs, the test job has access to NODE_ENV, APP_NAME, and DB_URL. In your npm test script, you can read process.env.DB_URL.

Step 2: Add a secret and use it

Secrets are stored in your repository’s Settings → Secrets and variables → Actions. Add a secret named API_KEY. Then reference it in a deploy step:

  deploy:
    needs: test
    runs-on: ubuntu-latest
    steps:
      - name: Deploy with API key
        run: |
          curl -X POST https://api.example.com/deploy \
            -H "Authorization: Bearer $API_KEY"
        env:
          API_KEY: ${{ secrets.API_KEY }}

GitHub Actions automatically masks the secret value in logs, so $API_KEY won’t appear in the output. You can verify this by observing that the log shows *** instead of the real key.

Step 3: Test it locally (simulate)

You don’t need a full CI runner to practice. Use a local shell to simulate:

export DB_URL="postgresql://localhost:5432/demo"
export NODE_ENV="test"

# In your Node.js app
node -e "console.log('Connecting to', process.env.DB_URL, 'in', process.env.NODE_ENV)"

This helps you understand how variables flow into your processes before you push to GitHub.

Pro tip: Always prefix secret names with a clear label like PROD_ or STAGING_ so you don’t accidentally use the wrong environment’s credentials.

Compare options / when to choose what

Different CI systems have similar but slightly different syntax. Here’s a quick comparison:

Provider Syntax in shell Expression for dynamic values Secrets storage
GitHub Actions $VAR ${{ env.VAR }} and ${{ secrets.VAR }} Settings → Secrets
GitLab CI $VAR $VAR in YAML (with variables: keyword) Settings → CI/CD → Variables
Jenkins env.VAR in Groovy ${env.VAR} Jenkinsfile credentials
CircleCI $VAR $VAR in executors: Project Settings → Environment Variables

When to use workflow-level vs job-level vs step-level:

  • Workflow-level: Use for global settings like NODE_ENV or app name that never change across jobs.
  • Job-level: Use for values that apply to all steps in a job, like a database URL that’s shared by test scripts.
  • Step-level: Use for values that only one step needs, like an API key for a deploy step.

When not to use environment variables: - If a value is needed only inside a single script and is not reused, consider passing it as a command-line argument instead. - If a value is truly constant and public (like the GitHub repo name), you can hardcode it, but keep that consistent with your team’s conventions.

Troubleshooting & edge cases

Even with the best setup, you’ll hit issues. Here are common problems and how to fix them.

Secret not masked in custom scripts

GitHub Actions masks secrets automatically only when they appear in step output. If you echo a secret inside a custom script (e.g., echo $API_KEY), it might be printed. Fix: Always avoid echoing secrets. If you must debug, use ::add-mask:: to manually mask a value.

Variable not found in a step

You defined a workflow-level env but your step can’t see it. Fix: Check the YAML indentation. The env: key must be at the correct level. For example, step-level env: must be under the step, not the job.

PowerShell syntax differences

If your runner is Windows (PowerShell), $VAR works differently. Use $env:VAR instead. Example:

Write-Host "Connecting to $env:DB_URL"

Secret values with special characters

If a secret contains $, backticks, or quotes, they might be interpreted by the shell. Fix: Use ${{ secrets.VAR }} in env: to let GitHub Actions inject it safely as a literal string.

Environment variable set in one step not available in next

Each step runs in a separate process. Setting export FOO=bar in one step does not persist to the next. Fix: Use the workflow-level env or the GITHUB_ENV file to persist a variable.

- name: Set variable
  run: echo "MY_VAR=hello" >> "$GITHUB_ENV"
- name: Use variable
  run: echo $MY_VAR

What you learned & what's next

In this lesson, you learned how to use environment variables in pipelines to keep configuration dynamic, secure, and maintainable. You now know:

  • Why hardcoding is dangerous
  • The mental model of variables vs secrets
  • How to define variables at workflow, job, and step levels
  • How to reference them in shell and expressions
  • How to store and use secrets without leaking them
  • How to troubleshoot common pitfalls

You also completed a hands-on walkthrough that you can adapt to your own projects.

Next in the CI/CD foundations track, we’ll dive into caching dependencies — how to speed up your pipelines by reusing downloaded packages and build artifacts. You’ll apply your new environment variable skills to configure cache keys and paths. Stay tuned.

Practice recap

Create a new GitHub repository and add the workflow we built. Store a dummy API key as a secret, then push a commit and inspect the logs to confirm the secret is masked. Modify the workflow to use GITHUB_ENV to persist a custom variable and see it appear in a later step.

Common mistakes

  • Hardcoding secrets directly in the workflow file — even in a private repo, this can leak via forks or accidental commits.
  • Using $VAR in a PowerShell runner — PowerShell requires $env:VAR syntax, so your variable silently resolves to empty.
  • Forgetting that env: at workflow level is not accessible from inside a step's run command unless you reference it with ${{ env.VAR }} — a common YAML scoping pitfall.

Variations

  1. Use GitHub Actions expression syntax ${{ env.VAR }} instead of the shell $VAR for dynamic values like environment names or matrix parameters.
  2. Use the GITHUB_ENV file to persist a variable across steps in a job, useful for computed values like a shortened commit SHA.
  3. Leverage environment-specific variables (e.g., vars.PROD_DB_URL) instead of multiple workflow files when supporting multiple deployment environments.

Real-world use cases

  • A CI pipeline runs tests against a staging database using a DB_URL variable that changes per environment, without editing code.
  • A deployment job sends a Slack notification using a secret webhook URL stored in repository secrets, keeping it out of logs.
  • A build workflow uses a matrix of Node versions and an environment variable NODE_ENV=production to run dependency installation with production flags.

Key takeaways

  • Separate configuration from code using environment variables to keep pipelines flexible and secure.
  • Use workflow-level, job-level, and step-level env scopes to control where values are visible.
  • Always store sensitive values as secrets, never in workflow files, and rely on your CI provider's masking.
  • Remember shell-specific syntax: $VAR for bash, $env:VAR for PowerShell, and ${{ env.VAR }} in GitHub Actions expressions.
  • Persist dynamic variables across steps with the GITHUB_ENV file when needed.
  • Review logs for accidental secret exposure and use ::add-mask:: to manually redact if necessary.

Sponsored

Sponsored

Discussion

Questions, corrections, and tips help everyone reading this page.

0 comments

Add a comment

Shown publicly with your comment.

Be constructive · max 4,000 characters

No comments yet — start the thread.

Related tutorials, quizzes, and articles for this topic.