Git, CI/CD & GitOpsXXXVIII · CI ArchitectureCI Architecture
Environment variables and config — secrets, env, vars, and the scopes that bind them
What you'll learn
- Distinguish secrets from env from vars and explain when each is appropriate
- Use $GITHUB_ENV to pass values between steps in the same job
- Use $GITHUB_OUTPUT to pass values from a step to the job and the workflow
- Identify which scopes (workflow, job, environment, step) each mechanism supports
- Apply masking and redaction rules to prevent accidental secret leakage in logs
Prerequisites
Verified against Git 2.55.x teaching target; 2.40+ minimum · GitHub Actions continuous service; Aug 2026 documentation baseline · Argo CD v3.5.x teaching target; v3.0+ minimum · Flux v2.9.x · Sigstore Cosign v3.1.x · SLSA v1.2 · OCI Distribution Specification v1.1 · Git LFS v3.7.1 · Kubernetes (cross-course target) 1.36.x
A CI job has three distinct configuration mechanisms, and they are not interchangeable. Secrets are sensitive values injected into the runner’s environment and masked in logs. Env values are non-sensitive key/value pairs declared per step or per job. Vars (workflow variables) are non-sensitive context values available to expressions but not injected as environment variables. Treating a secret as a var, or a var as an env, is a configuration mistake with predictable consequences: a secret declared as a var is logged in cleartext; a var declared as a secret is unavailable to expressions.
The three mechanisms
flowchart TB
subgraph SECRETS["Secrets (encrypted at rest, masked in logs)"]
S1["Repository secret"]
S2["Organisation secret"]
S3["Environment secret"]
S4["OIDC token (short-lived)"]
end
subgraph ENV["Env (per-step or per-job key/value)"]
E1["env: block in workflow YAML"]
E2["env: block in step"]
E3["$GITHUB_ENV writes"]
end
subgraph VARS["Vars (context, expressions only)"]
V1["vars. context\n(repo, org)"]
V2["github. context\n(event metadata)"]
V3["$GITHUB_OUTPUT writes"]
end
- Secrets. Sensitive values stored encrypted in the control
plane and injected into the runner’s environment when the
job starts. Logged values that match a secret’s contents are
masked automatically (replaced with
***). Examples: cloud access keys, API tokens, deploy credentials, signing keys. - Env. Key/value pairs declared in the workflow file or
step configuration, set in the runner’s environment for the
duration of the step (or job). Not encrypted, not masked.
Examples:
TF_LOG: INFO,AWS_REGION: us-east-1. - Vars. Non-sensitive values available to expressions
(
${ vars.MY_VAR }) and to the runner via thevarscontext. Not injected as environment variables.
The rule: if the value would damage you if it appeared in a build log, it is a secret. If it would be useful in a log but is not sensitive, it is an env. If it is needed for conditional logic in the workflow file itself, it is a var.
Passing values between steps: $GITHUB_ENV
Inside a single job, steps need a way to communicate. The
mechanism is the file $GITHUB_ENV:
# Inside a step's run block
echo "DEPLOY_ENV=production" >> "$GITHUB_ENV"
echo "BUILD_NUMBER=42" >> "$GITHUB_ENV"
The next step in the same job sees DEPLOY_ENV and
BUILD_NUMBER as environment variables. $GITHUB_ENV is the
runner-documented mechanism for persisting variables across
steps within one job:
jobs:
configure:
runs-on: ubuntu-latest
steps:
- id: detect
run: |
if [ "${ github.ref }" = "refs/heads/main" ]; then
echo "DEPLOY_ENV=production" >> "$GITHUB_ENV"
else
echo "DEPLOY_ENV=staging" >> "$GITHUB_ENV"
fi
- run: echo "Deploying to $DEPLOY_ENV"
env:
DEPLOY_ENV: ${ steps.detect.outputs.DEPLOY_ENV }
The first step writes to $GITHUB_ENV. The second step reads
DEPLOY_ENV either as an environment variable (the runner
exports it at step boundaries) or explicitly via env:.
Variables written to $GITHUB_ENV persist for the lifetime
of the job.
Returning values to the job: $GITHUB_OUTPUT
A step that produces a value other steps (or the workflow
itself) need to consume uses $GITHUB_OUTPUT:
# Inside a step's run block
echo "image_tag=$(git describe --tags --always)" >> "$GITHUB_OUTPUT"
The next step accesses the value via
${ steps.step_id.outputs.image_tag }:
steps:
- id: tag
run: echo "image_tag=$(git describe --tags --always)" >> "$GITHUB_OUTPUT"
- run: echo "Built image with tag ${ steps.tag.outputs.image_tag }"
The distinction from $GITHUB_ENV:
$GITHUB_ENVis for environment variables consumed by shell commands. The value is a string the runner exports.$GITHUB_OUTPUTis for step outputs consumed by expressions like${ steps.x.outputs.y }. The value becomes part of the step’s structured output.
Both files disappear when the job ends.
The scopes
flowchart LR
WF["Workflow"]
JOB["Job"]
STEP["Step"]
ENVSC["Environment\n(named target)"]
SEC["Secret"] -. scopes .-> WF
SEC -. scopes .-> JOB
SEC -. scopes .-> ENVSC
VAR["Var"] -. scopes .-> WF
VAR -. scopes .-> JOB
ENVV["Env"] -. scopes .-> STEP
ENVV -. scopes .-> JOB
- Secrets can be scoped to the workflow (all jobs), the
job (declared in the job’s
secrets:block), or the environment (available only to jobs that target a named environment). Environment-scoped secrets are the production default. - Vars are scoped to the workflow or the repository; available to all jobs but only to expressions.
- Env is scoped to the step (declared in the step’s
env:block) or the job (declared in the job’senv:block, available to every step in the job).
A secret declared at the workflow level is available to every job, including jobs that process untrusted input. The fix is to scope the secret to the job or to the environment.
Masking and redaction
Secrets are masked in build logs automatically: any occurrence
of the secret’s value in a logged string is replaced with
***. The masking is a string match; transformed forms
(base64, URL-encoded, split across echo statements) bypass it.
# Inside the runner shell, confirm what was injected
env
# This prints every environment variable the job received.
# Secrets appear with their actual values; non-secrets appear
# normally. The env command is the canonical way to debug
# "why is my secret empty in step 3".
The env command is also the canonical way an attacker
exfiltrates secrets. Any step that runs env in a job that
holds secrets has full visibility into the job’s secrets.
Production discipline
- Default secrets to environment scope. Workflow-level secrets are a legacy convenience; environment-level secrets are the production default.
- Use
$GITHUB_ENVfor cross-step variables; use$GITHUB_OUTPUTfor structured outputs. Different consumers; do not use one where the other fits. - Never run
envin a job that holds untrusted input and secrets. The command prints every secret. - Verify what crossed boundary 1 with
envat the start of every debugging session. “Why is my secret empty” is answered byenv | grep SECRET_NAME.
Cross-course references
- Linux for Production Sysadmins - Part XXXIV (ConfigMgmt) applies the same scope discipline to package build environment variables.
- Ansible for Production Sysadmins - Part XXXVII (RepoArch) applies the pattern to AWX credentials (machine-scoped, inventory-scoped).
- Terraform for Production Sysadmins - Parts IX-XII (State) apply the pattern to Terraform Cloud workspace variables.
Quiz
Knowledge check · 4 questions
Q1. A step runs `echo "image_tag=v1.2.3" >> $GITHUB_OUTPUT`. Where can the next step in the same job read `image_tag`?
Q2. A secret declared at the workflow level is available to every job in the workflow, including jobs that run on pull_request from a fork.
Q3. Name the three configuration mechanisms in a CI job and identify which one is appropriate for a value that must not appear in cleartext in build logs.
Q4. Diagnose why a secret leaked into a public build log despite being declared as a workflow secret, and recommend a scope-based redesign.
Team T's deploy workflow holds AWS_SECRET_ACCESS_KEY as a repository secret. The workflow has three jobs: `lint`, `plan`, and `apply`. The lint job runs on every push, including pushes to feature branches from forks. A developer accidentally runs `env | grep AWS` in the lint step while debugging. The output, including AWS_SECRET_ACCESS_KEY, is captured in the public build log of a fork PR.
Passing score: 75%. Answers are checked in this browser.