log in
consulting hosting industries the daily tools about contact

GitHub Actions Secrets Are Not Enough for Multi-Env CI

Environment-scoped secrets in GitHub Actions feel like the answer until you actually run multi-environment CI at scale. Here's what bites you.

I spent two days last spring untangling a secrets mess on a project that had grown from one environment to four. Everything looked correct in the GitHub UI. The deployments were failing in ways that made no sense until I realized we had three different values for the same secret name living in three different scopes, and GitHub was silently resolving to the wrong one. That experience changed how I set up CI secrets on every project since.

What GitHub Actually Gives You

GitHub Actions has three tiers of secrets:

  • Repository secrets — available to all workflows in the repo
  • Environment secrets — scoped to a named environment (production, staging, etc.) and only injected when a job targets that environment
  • Organization secrets — shared across repos, with optional repo allowlists

On paper, environment secrets sound like exactly what you want. You define a production environment, put your production database URL in there, and only jobs that declare environment: production get access. Clean separation. Principle of least privilege. Ship it.

The problem shows up the moment your repo has more than two environments and more than one person managing them.

The Actual Problem: Sprawl Plus Opacity

Here's a real shape of a problem I've hit more than once. A client has four environments: development, staging, uat, and production. Each needs its own database credentials, API keys for third-party services, and a deployment token. That's conservatively 10–15 secrets per environment, so 40–60 secrets spread across four environment scopes plus whatever leaked up to the repository level "just to make something work."

Now try to answer any of these questions:

  • Which environments have STRIPE_SECRET_KEY defined?
  • Is the staging Stripe key the test key or the live key?
  • Who added DATABASE_URL to the repository scope six months ago and why?
  • When a new developer needs to add a secret for a new integration, where does it go?

GitHub gives you no audit trail for secret values (correct, by design), but it also gives you almost no tooling for answering these structural questions. You click around in the UI. You guess. You find out you guessed wrong when a deploy writes to the wrong database.

The other killer is secret name collisions across scopes. If API_KEY exists at both the repository level and the staging environment level, GitHub resolves to the environment secret when the job targets that environment — but this behavior is not always obvious to the person writing the workflow. I've seen senior engineers get this wrong.

What the Workflow Looks Like (and Where It Goes Wrong)

A typical multi-environment workflow setup looks like this:

# .github/workflows/deploy.yml
name: Deploy

on:
  push:
    branches: [main, staging, develop]

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: ${{ github.ref_name == 'main' && 'production' || github.ref_name == 'staging' && 'staging' || 'development' }}
    steps:
      - uses: actions/checkout@v4

      - name: Deploy
        env:
          DATABASE_URL: ${{ secrets.DATABASE_URL }}
          STRIPE_KEY: ${{ secrets.STRIPE_KEY }}
          DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
        run: ./scripts/deploy.sh

This looks reasonable. The environment: key is dynamic, so the right environment secrets should get injected. But here's what bites you:

The ternary expression in environment: is evaluated as a string. If github.ref_name is something unexpected — a feature branch, a tag, a dependabot branch — the expression can resolve to 'false' (literally the string false), which GitHub will treat as an environment named false. That environment doesn't exist, so it falls back to repository-level secrets. You just deployed with production-scope secrets to a feature branch environment. Or you deployed with no secrets and got a cryptic error.

Split the job. Be explicit:

jobs:
  deploy-production:
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    environment: production
    steps:
      - uses: actions/checkout@v4
      - name: Deploy
        env:
          DATABASE_URL: ${{ secrets.DATABASE_URL }}
          STRIPE_KEY: ${{ secrets.STRIPE_KEY }}
        run: ./scripts/deploy.sh

  deploy-staging:
    if: github.ref == 'refs/heads/staging'
    runs-on: ubuntu-latest
    environment: staging
    steps:
      - uses: actions/checkout@v4
      - name: Deploy
        env:
          DATABASE_URL: ${{ secrets.DATABASE_URL }}
          STRIPE_KEY: ${{ secrets.STRIPE_KEY }}
        run: ./scripts/deploy.sh

Verbose? Yes. Ambiguous? No.

What to Do Instead: Centralize in a Secrets Manager

For any project with more than two environments or more than two people touching CI, I stop relying on GitHub secrets as the source of truth and start treating them as a thin delivery layer for one secret: the credentials to fetch everything else.

My current preferred stack is AWS Secrets Manager (or Parameter Store for simpler stuff) with an IAM role assumed via OIDC. GitHub's OIDC provider support means you don't need a long-lived AWS_ACCESS_KEY_ID sitting in GitHub at all.

The setup:

# In GitHub environment secrets: nothing except maybe a role ARN
# All real secrets live in AWS Secrets Manager under paths like:
# /myapp/production/database_url
# /myapp/staging/stripe_key

jobs:
  deploy-production:
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    environment: production
    permissions:
      id-token: write
      contents: read
    steps:
      - uses: actions/checkout@v4

      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789:role/github-actions-production
          aws-region: us-west-2

      - name: Fetch secrets
        run: |
          DATABASE_URL=$(aws secretsmanager get-secret-value \
            --secret-id /myapp/production/database_url \
            --query SecretString --output text)
          echo "DATABASE_URL=$DATABASE_URL" >> $GITHUB_ENV

      - name: Deploy
        run: ./scripts/deploy.sh

The IAM role is constrained by trust policy to only be assumable by workflows running on a specific branch or environment, and the role's permissions are scoped to only the secret paths for that environment. GitHub itself never holds the secret values. Rotation happens in one place. Audit logs come from CloudTrail, which actually tells you something useful.

If you're not in AWS, HashiCorp Vault with the JWT auth method gives you the same pattern. Doppler has native GitHub Actions support and is worth looking at for smaller teams that don't want to manage their own secrets infrastructure.

The Gotchas That Will Get You

Secret masking is per-workflow-run, not per-job. If a secret value appears in a log line before the masking kicks in — say, in an error message from a dependency install — it's in the log. GitHub masks values it knows about, but it only knows about secrets you've explicitly referenced with ${{ secrets.FOO }}. Secrets you fetch dynamically (like from AWS) won't be auto-masked. You have to add them manually:

- name: Mask fetched secret
  run: echo "::add-mask::$DATABASE_URL"

Do this immediately after fetching. Before you echo anything, before you run anything.

Environment protection rules don't help as much as you think. Required reviewers on the production environment sounds great until you're doing 20 deploys a day and your reviewer is in a different timezone. Plan for this before you're blocked at 4pm on a Friday.

Dependabot can't access environment secrets. By design. If your dependency update PRs need secrets to pass CI, you either use repository secrets (expanding scope) or you restructure your workflow so the secret-needing steps don't run on Dependabot's context. This is annoying to discover mid-migration.

Stale secrets accumulate. I've inherited repos with 40+ secrets in GitHub, half of which nobody knows the purpose of. Document your secrets. I keep a docs/secrets.md that lists every secret name, which environments it belongs to, what it's for, and when it was last rotated. Not the values — just the metadata. It's the kind of thing that saves three hours of confusion six months later.

When I'd Use GitHub Secrets Natively (and When I Wouldn't)

For a simple two-environment setup — staging and production, two or three developers, a handful of secrets — GitHub's built-in environment secrets are fine. The overhead of standing up a secrets manager isn't worth it.

Once any of these are true, I reach for a centralized secrets manager:

  • More than two deployment environments
  • More than three or four people touching the repo
  • Any secret that needs to be shared across multiple repos (org secrets work but they're even harder to audit)
  • Compliance requirements that need an actual audit trail of secret access
  • Secrets that rotate frequently (GitHub has no rotation automation)

For the healthcare and biotech clients I work with, the audit trail requirement alone makes centralized secrets management non-optional. HIPAA doesn't care that GitHub's UI shows you who added a secret — it cares that you can prove who accessed it and when.

The Bottom Line

GitHub environment secrets are a convenience feature, not a secrets management strategy. They're fine until they're not, and "not" usually arrives quietly — a misconfigured scope, a leaked value in a log, a mystery deployment that hit the wrong database. Treat GitHub secrets as the last mile of delivery for one thing: the credential that gets you into your real secrets store. Everything else should live somewhere with proper audit logs, rotation, and access controls you can actually explain to a client or an auditor.

Related

Need help shipping something like this? Get in touch.