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
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:
- Define the variable — Decide where it should live: as a workflow-level
env, a job-levelenv, or a step-levelenv. If it’s a secret, store it in your CI provider’s secrets store (e.g., GitHub Secrets) and reference it withsecrets.VARIABLE_NAME. - Reference the variable — Use the
$VARIABLE_NAMEsyntax from a shell step (this is$VARIABLE_NAMEin bash,$env:VARIABLE_NAMEin PowerShell). In GitHub Actions, you can also use the${{ env.VARIABLE_NAME }}expression syntax inside workflow YAML. - Override per environment — Use the same workflow for different environments by loading values from environment-specific secrets or variables (e.g.,
database-prodvsdatabase-staging). - 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_orSTAGING_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_ENVor 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
$VARin a PowerShell runner — PowerShell requires$env:VARsyntax, 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
- Use GitHub Actions expression syntax
${{ env.VAR }}instead of the shell$VARfor dynamic values like environment names or matrix parameters. - Use the
GITHUB_ENVfile to persist a variable across steps in a job, useful for computed values like a shortened commit SHA. - 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_URLvariable 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=productionto 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
envscopes 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:
$VARfor bash,$env:VARfor PowerShell, and${{ env.VAR }}in GitHub Actions expressions. - Persist dynamic variables across steps with the
GITHUB_ENVfile when needed. - Review logs for accidental secret exposure and use
::add-mask::to manually redact if necessary.
Keep learning
Related tutorials, quizzes, and articles for this topic.
Discussion
Questions, corrections, and tips help everyone reading this page.
0 comments
Add a comment
No comments yet — start the thread.