CI/CDSecurityGitHub Actions Feb 2025 9 min read

Stop Hardcoding Environment Variables in CI/CD Pipelines

Hardcoding variables in pipeline config is a bomb waiting to go off at scale. Here's how to manage environment-specific configuration properly across GitHub Actions, AWS Secrets Manager, and Terraform.

The bomb hiding in your pipeline

Hardcoding environment variables in CI/CD pipelines is one of those shortcuts that feels harmless until it isn't. It works fine in development. Then you scale to multiple environments, rotate a credential, or onboard a new service — and suddenly you're tracking down hardcoded values scattered across a dozen pipeline files while an incident is in progress.

I've seen this pattern at multiple organisations. It's not a junior mistake — it's a velocity mistake. Someone needed to get something working fast, hardcoded a value, it worked, and the pattern spread. By the time the problems start, unpicking it is a significant piece of work.

Why hardcoding is a problem at scale

Configuration drift between environments

When environment-specific values are hardcoded in pipeline files rather than injected per environment, they get out of sync. The dev pipeline has a database host pointing somewhere different from staging. Someone updated staging manually but forgot dev. Now your staging tests pass and your dev tests fail for reasons that have nothing to do with your code.

Security exposure

Secrets in pipeline config files get committed to version control. Even in private repos, this is a serious risk — the secret is now in your git history permanently, accessible to everyone with repo access, and likely visible in pipeline logs. Rotating it requires a history rewrite, not just a variable update.

Scaling is a manual operation

Every new environment — a new region, a new client, a new deployment target — requires someone to find all the hardcoded values and update them manually. This is the kind of work that causes incidents because the person doing it will miss something.

Fix 1: Environment-specific variables in your CI/CD tool

Every modern CI/CD platform has a mechanism for environment-scoped variables. Use it. Non-secret configuration belongs here.

# GitHub Actions — environment variables scoped per environment
# Set these in GitHub → Settings → Environments → Variables

jobs:
  deploy:
    environment: production   # scopes vars to this environment
    steps:
    - name: Deploy
      env:
        DB_HOST: ${{{{ vars.DB_HOST }}}}          # non-secret config
        DB_PASSWORD: ${{{{ secrets.DB_PASSWORD }}}} # secret
        APP_ENV: ${{{{ vars.APP_ENV }}}}
      run: ./deploy.sh

GitHub Actions separates vars (non-sensitive, visible in logs) from secrets (masked, never logged). Use the right one for each type of value. Both are scoped per environment, so production, staging, and dev can have different values for the same variable name.

Fix 2: Secrets in a secrets manager — not in the pipeline

For anything sensitive — database passwords, API keys, certificates, tokens — the value should live in AWS Secrets Manager, HashiCorp Vault, or a similar system. The pipeline retrieves it at runtime. The secret never touches your pipeline configuration file or git history.

# Fetch secret from AWS Secrets Manager at pipeline runtime
- name: Fetch DB credentials
  run: |
    SECRET=$(aws secretsmanager get-secret-value       --secret-id prod/db/credentials       --query SecretString       --output text)
    DB_PASSWORD=$(echo $SECRET | python3 -c       "import sys,json; print(json.load(sys.stdin)['password'])")
    echo "DB_PASSWORD=$DB_PASSWORD" >> $GITHUB_ENV
    
# Or use the dedicated action
- name: Get secrets
  uses: aws-actions/aws-secretsmanager-get-secrets@v2
  with:
    secret-ids: prod/db/credentials
    parse-json-secrets: "true"
Always add to .gitignore: If your pipeline writes secrets to a .env file at runtime, that file must be in .gitignore. Generating it at runtime is safe. Committing it is not.

Fix 3: Terraform workspaces for environment management

If you're managing multiple environments with Terraform, workspaces let you use the same configuration across environments without duplicating files or hardcoding environment-specific values.

# variables.tf — define once, vary per workspace
variable "instance_type" {{
  default = {{
    "dev"     = "t3.small"
    "staging" = "t3.medium"
    "prod"    = "m5.large"
  }}
}}

# main.tf — reference the current workspace
resource "aws_instance" "app" {{
  instance_type = var.instance_type[terraform.workspace]
  # ...
}}

# Switch and apply per environment
terraform workspace select staging
terraform plan
terraform apply

No duplicated configuration files. No hardcoded environment names. The workspace is the single parameter that controls environment-specific behaviour.

Fix 4: Validate in non-production first, always

This sounds obvious. It isn't always practiced. Every pipeline configuration change — new variable, updated secret reference, modified environment scope — should run in a non-production environment before production. One misconfigured variable reference in a production deployment pipeline will cause more downtime than a week of feature development.

# Enforce promotion order in your pipeline
jobs:
  deploy-staging:
    environment: staging
    runs-on: ubuntu-latest
    steps:
      - run: ./deploy.sh

  deploy-prod:
    needs: deploy-staging   # prod only runs after staging succeeds
    environment: production
    runs-on: ubuntu-latest
    steps:
      - run: ./deploy.sh

The audit: finding what's already hardcoded

If you're inheriting a codebase with existing pipelines, this search will find most hardcoded values quickly:

# Search for common patterns of hardcoded values in pipeline files
grep -r "password\s*=\s*['"]" .github/workflows/
grep -r "secret\s*=\s*['"]" .github/workflows/
grep -r "token\s*=\s*['"]" .github/workflows/
grep -rE "[A-Z_]+=https?://" .github/workflows/

Anything that comes back from these searches is a candidate for moving to environment variables or secrets manager. Prioritise by sensitivity — credentials first, then URLs and hostnames, then non-sensitive configuration last.

← Back to all articles