CI/CD Course
Environment Variables & Configs
In this lesson
Environment variables and configuration are the non-sensitive, per-environment values that tell an application how to behave when it runs — which database to connect to, which API endpoint to call, what log level to use, whether a feature is enabled. As established in Lesson 23, these are distinct from secrets: they carry no inherent risk if seen, but they still vary between environments and must be injected into the application at runtime rather than hardcoded into the build. The pattern for doing this correctly is one of the most consequential decisions in CI/CD design, because getting it wrong means either rebuilding artifacts per environment or hardcoding values that make the artifact untestable.
The Twelve-Factor App — Config as the Design Standard
The Twelve-Factor App methodology, published by Heroku engineers and widely adopted across the industry, defines a set of principles for building software that deploys cleanly across environments. Factor III — Config — states a simple test: could the codebase be made open source without exposing any credentials or environment-specific values? If the answer is no, configuration has leaked into the code.
The Twelve-Factor approach requires that all configuration be stored in environment variables — not in config files checked into version control, not in code constants, not in build-time flags. The application reads its configuration from the environment at runtime. The same artifact deployed to staging and production behaves differently because the environment variables differ, not because the artifact was built differently. This is the mechanism that makes "build once, deploy many" work in practice.
The Rental Car Analogy
A rental car is the same model regardless of who drives it — the same engine, the same controls, the same performance. The driver adjusts the mirrors and seat position at the start of each journey: personal settings injected at runtime, not baked into the car at the factory. An application with properly externalised configuration is the rental car. The artifact is the factory-built car. The environment variables are the mirror and seat adjustments — applied at runtime, different for every driver, never requiring the factory to produce a custom vehicle per person.
Environment Variables in GitHub Actions — Scoping and Precedence
GitHub Actions provides multiple levels at which environment variables can be defined, each with a different scope. Understanding the precedence order — which level wins when the same variable is defined at multiple levels — is essential for building pipelines that behave predictably across jobs and steps.
Environment Variable Scopes — Highest to Lowest Precedence
env: block. Only available within that step. Highest precedence — overrides job and workflow level definitions. Used for step-specific values and for injecting secrets that should not be available to other steps.env: block. Available to all steps within that job only. Used for job-specific configuration like the target environment name or test suite identifier.env: block. Available to all jobs and steps in the workflow. Used for values shared across the entire pipeline — Node version, registry URL, application name.GITHUB_SHA, GITHUB_REF, GITHUB_REPOSITORY, and other context values. Always available, cannot be overridden. Used for traceability and dynamic tagging.Config File Strategies — When Files Are Appropriate
Not all configuration is best delivered as environment variables. Structured configuration — complex nested objects, lists of values, multi-line connection strings — is often more readable and maintainable as a configuration file. The challenge is doing this without committing environment-specific values to version control.
Three patterns handle this well in CI/CD pipelines. The first is template substitution: commit a config template with placeholder variables (${DB_HOST}, ${LOG_LEVEL}), and use a tool like envsubst or sed in the pipeline to generate the final config file by substituting environment variables at deploy time. The second is config generation: the pipeline generates the config file from a script that reads environment variables and writes structured output. The third is a dedicated config service: AWS AppConfig, HashiCorp Consul, or Azure App Configuration — the application fetches its config at startup from a versioned, audited service rather than from files on disk.
Repository Variables — Config That Is Not Secret
GitHub Actions supports repository variables — a separate store from secrets, for values that vary per environment but do not need encryption. Repository variables are visible in the GitHub UI, can be read in workflow logs, and are intended for non-sensitive configuration: the name of the staging cluster, the ECR repository URL, the deployment region, the last known-good deployment SHA used for rollback.
Using repository variables for non-sensitive configuration and secrets for sensitive values keeps the secret store focused on what actually needs protection. It also makes debugging easier: when a deployment fails because the wrong cluster name was used, a variable is visible and auditable; a secret with the same value would be masked and invisible in logs, making the misconfiguration harder to identify.
Config and Variables in a Pipeline — GitHub Actions
env: # Workflow-level — shared across all jobs
NODE_VERSION: '20'
APP_NAME: my-service
REGISTRY: ghcr.io
jobs:
deploy-staging:
runs-on: ubuntu-latest
environment: staging
env: # Job-level — only available in this job
DEPLOY_ENV: staging
CLUSTER: ${{ vars.STAGING_CLUSTER }} # Repository variable — visible, not encrypted
steps:
- uses: actions/checkout@v4
- name: Generate config file from template
run: |
export DB_HOST=${{ vars.STAGING_DB_HOST }} # Non-sensitive config from vars
export LOG_LEVEL=debug
envsubst < config/app.template.yaml > config/app.yaml
# Substitutes ${DB_HOST} and ${LOG_LEVEL} in the template
- name: Deploy
env:
DB_PASSWORD: ${{ secrets.DB_PASSWORD }} # Sensitive — secret, step-scoped only
run: |
./deploy.sh \
--env "$DEPLOY_ENV" \
--cluster "$CLUSTER" \
--config config/app.yaml \
--db-password "$DB_PASSWORD"
What just happened?
Three distinct layers of configuration are in use simultaneously. Workflow-level env vars carry shared values across all jobs. Job-level env vars carry deployment-specific values. Repository variables carry non-sensitive per-environment config that is visible and auditable. The database password is the only encrypted secret — injected at the step level only, masked in logs, never exposed beyond the one step that needs it. Template substitution generates the config file at deploy time from non-sensitive variables, keeping no environment-specific files in version control.
Warning: Committing Environment-Specific Config Files Breaks the Build-Once Principle
A repository that contains config/staging.yaml and config/production.yaml checked into version control has embedded environment knowledge into the codebase. Every time a staging endpoint changes, a version control change is required. Every time a new environment is added, a new config file must be created and merged. More critically: if the artifact is built with the config file baked in at build time, a separate artifact is required per environment — breaking build-once entirely. Config that varies per environment must be injected at runtime, not embedded at build time, regardless of whether it is stored as environment variables, repository variables, or generated from a template.
Key Takeaways from This Lesson
Teacher's Note
Run the Twelve-Factor test on your repository right now — grep for any hardcoded hostnames, URLs, or environment names in application code. Every match is a value that should be an environment variable instead.
Practice Questions
Answer in your own words — then check against the expected answer.
1. What is the name of the methodology — published by Heroku engineers and widely adopted across the industry — whose third factor states that all configuration must be stored in environment variables, with the test being whether the codebase could be open-sourced without exposing any environment-specific values?
2. What GitHub Actions feature provides a separate store from encrypted secrets — for non-sensitive, per-environment values like cluster names and deployment regions — that are visible in the GitHub UI and readable in workflow logs?
3. What config generation technique involves committing a config file with placeholder variables like ${DB_HOST} to version control, then using a tool like envsubst in the pipeline to produce the final config file by replacing placeholders with environment variable values at deploy time?
Lesson Quiz
1. A build pipeline reads a config/production.yaml file and embeds its values into the compiled artifact at build time. A separate build reads config/staging.yaml for the staging artifact. What fundamental CI/CD principle does this violate?
2. A deploy job needs the name of the Kubernetes cluster to deploy to. The cluster name varies between staging and production but contains no sensitive information. A developer asks whether to store it as a GitHub Secret or a repository variable. What is the correct guidance?
3. A pipeline job has five steps. Only the third step needs the database password. At which scope should the secret be injected to minimise its exposure within the job?
Up Next · Lesson 25
Secure CI/CD Pipelines
A pipeline that builds and deploys software is also an attack surface. Lesson 25 covers how to harden the pipeline itself — from runner security to supply chain integrity to protecting the deployment path.