Skip to main content
RunBook Academy

Git, CI/CD & GitOpsXXXVIII · CI ArchitectureCI Architecture

Environment variables and config — secrets, env, vars, and the scopes that bind them

Foundation⏱ ~22 mingit

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

Not yet marked complete on this device.

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 the vars context. 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_ENV is for environment variables consumed by shell commands. The value is a string the runner exports.
  • $GITHUB_OUTPUT is 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’s env: 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

  1. Default secrets to environment scope. Workflow-level secrets are a legacy convenience; environment-level secrets are the production default.
  2. Use $GITHUB_ENV for cross-step variables; use $GITHUB_OUTPUT for structured outputs. Different consumers; do not use one where the other fits.
  3. Never run env in a job that holds untrusted input and secrets. The command prints every secret.
  4. Verify what crossed boundary 1 with env at the start of every debugging session. “Why is my secret empty” is answered by env | 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

  1. Q1. A step runs `echo "image_tag=v1.2.3" >> $GITHUB_OUTPUT`. Where can the next step in the same job read `image_tag`?

  2. 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.

  3. 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.

  4. 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.