Skip to content
Latchkey LogoLatchkey home

GitHub Actions environment secrets empty in a job

GitHub Actions environment secrets empty almost always means the job never named the environment the secret lives in. Repository and organization secrets are read once for the whole run, environment secrets are read per job, and a job with no environment: key skips that second read entirely.

A run timeline showing when each scope of secret is read and which jobs can see it
Two reads, not one. Quoted from GitHub's secrets reference: "environment secrets are read when a job referencing the environment starts".

What this error means

There is no error to search for, which is the difficulty. The expression ${{ secrets.DEPLOY_KEY }} resolves to an empty string, GitHub prints nothing, and the first thing that notices is whatever you handed it to: a curl that comes back 401, an SDK that reports a missing credential, a shell variable that is set but zero bytes long. Repository secrets in the same job work, which is what sends people looking for a typo in the name. The tell is in the job definition rather than the step: the job has no environment: key, or it has one that names a different environment from the one the secret was saved on. The same failure is often searched for as environment secrets empty without environment: on the job, which names the cause rather than the symptom, and lands here.

Two reads, at two different moments

GitHub documents the timing in one sentence, and it explains everything on this page: "Organization and repository secrets are read when a workflow run is queued, and environment secrets are read when a job referencing the environment starts." The first read is per run and covers every job in it. The second is per job and only happens for a job that references the environment.

The scoping rule is stated just as plainly: "Secrets stored in an environment are only available to workflow jobs that reference the environment." Variables follow the same rule from the same page, with one extra restriction worth knowing: "Variables stored in an environment are only available to workflow jobs that reference the environment. These variables are only accessible using the vars context." A value you saved as an environment variable will never appear under secrets, however correct the name is.

Where the value livesWhen it is readWhich jobs can read itContext
OrganizationWhen the run is queuedEvery job in the runsecrets
RepositoryWhen the run is queuedEvery job in the runsecrets
Environment secretWhen a job referencing the environment startsOnly jobs with that environment:secrets
Environment variableWhen a job referencing the environment startsOnly jobs with that environment:vars

Common causes

The job has no environment key

The common one. Environment secrets are only read for a job that references the environment, so a job without the key never triggers that read and every environment secret resolves to an empty string. Repository secrets in the same job keep working, which makes it look like one secret is broken rather than one scope.

The value was saved at a different scope from the one the job uses

A secret saved on the production environment is not a repository secret and is not visible to a job that names staging. The names are identical in the expression, so nothing reads as wrong. Check where the value was actually stored before you check anything in the workflow file.

It is an environment variable, read through the wrong context

Variables and secrets are stored side by side in the same settings page and are read through different contexts. An environment variable is only accessible through vars, so ${{ secrets.NAME }} for a variable is always empty no matter which environment the job names.

The repository is private on a plan that does not include environment secrets

GitHub limits environment secrets on private repositories to the paid plans. The settings page still lets you create the environment and save the secret, so there is nothing visibly wrong, and the job reads an empty string. Worth checking early on a repository that was recently made private.

How to fix it

Find which scope the value is stored at

  1. Open the repository settings and look under Secrets and variables for Actions.
  2. Check the repository tab and each environment tab; the same name can exist in more than one place with different values.
  3. Check whether it is under secrets or under variables, because the second is only readable through the vars context.

Name the environment on the job that needs it

The key goes on the job, not on the step and not on the workflow. Every step in that job then sees the environment's secrets and variables, and no other job does.

.github/workflows/deploy.yml (illustrative)
jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: production
    steps:
      - run: ./deploy.sh
        env:
          DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }}

Use deployment: false when the job is not a deployment

If the only reason you want the environment is the secrets, opt out of the deployment object so the environment history stays meaningful. Keep in mind it cannot be combined with custom deployment protection rules.

Prove the value arrived without printing it

Echo the length, never the value. A redaction will hide a secret that is present, so a masked line does not tell you whether the value is there; a length does. Zero means the read did not happen, and any other number means the scope is right and the problem is downstream.

.github/workflows/deploy.yml (illustrative)
      - name: Check the key arrived
        env:
          DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }}
        run: echo "length=${#DEPLOY_KEY}"

A minimal pair

These two jobs are written for this page and have never been run. They differ by one key. The first reads an empty string from an environment secret; the second reads the value. Nothing else about them changes, including the secret name.

.github/workflows/deploy.yml (illustrative)
jobs:
  reads-empty:
    runs-on: ubuntu-latest
    steps:
      - run: ./deploy.sh
        env:
          DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }}

  reads-the-value:
    runs-on: ubuntu-latest
    environment: production
    steps:
      - run: ./deploy.sh
        env:
          DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }}

Naming the environment without creating a deployment

The usual objection to adding environment: is that it creates a deployment record against a job that is not a deployment, and clutters the environment's history with test runs. GitHub added an opt-out for exactly that. Its guide says: "By default, when a workflow job references an environment, GitHub creates a deployment object to track the deployment. You can opt out of deployment creation by setting deployment to false in the environment configuration. The valid values are true (default) and false."

The value can also be an expression, so one job can record a deployment on the default branch and stay quiet everywhere else. One limit comes with it. The jobs.<job_id>.environment syntax reference states it flatly, that "Setting deployment: false is not compatible with custom deployment protection rules", and the deployments guide says what incompatible means when it happens: the job "will fail immediately with an annotation or error message explaining that the environment's protection rules are incompatible with deployment: false".

.github/workflows/test.yml (illustrative)
jobs:
  test:
    runs-on: ubuntu-latest
    environment:
      name: staging
      deployment: false
    steps:
      - run: npm test
        env:
          API_KEY: ${{ secrets.API_KEY }}

Two ways the job can be right and the secret still empty

The first is a plan restriction rather than a mistake. GitHub notes that "If you are using GitHub Free, environment secrets are only available in public repositories. For access to environment secrets in private or internal repositories, you must use GitHub Pro, GitHub Team, or GitHub Enterprise." On a private repository on the free plan the environment exists, the secret can be saved on it, and a job referencing it still reads nothing.

The second is timing under a protection rule. A job that references an environment with required reviewers does not get the secrets while it waits: "If the environment requires approval, a job cannot access environment secrets until one of the required reviewers approves it." That job is not failing and not running, and the same reference adds the reason it looks stuck rather than broken: all deployment protection rules must pass before the job is sent to a runner.

Why there is no recorded run on this page

A recorded run would be a log of nothing happening. The failure here produces no message, no annotation and no non-zero exit until some other tool is handed an empty string, and that tool's error would be the only thing in the log. What settles the question is your repository's own settings, which no run of ours can stand in for: which scope the value was saved at, which plan the repository is on, and whether the job names the environment. Every sentence quoted above is from GitHub's reference pages, and the workflows are illustrative.

How to prevent it

  • Pick one scope per secret and write it down, rather than saving the same name at two levels.
  • Put environment: on every job that consumes environment secrets, and deployment: false on the ones that are not deployments.
  • Read environment variables through vars and environment secrets through secrets, and never mix the two in one expression.
  • Add a length check as the first step of a deploy job, so an empty value fails immediately instead of three steps later.

Frequently asked questions

Why does a repository secret work in the same job where an environment secret is empty?
Because they are read at different moments. Organization and repository secrets are read when the run is queued, so every job in the run has them. Environment secrets are read when a job referencing the environment starts, so a job without the environment: key never gets that read and the value stays empty.
Can I use an environment for its secrets without recording a deployment?
Yes. Set deployment: false inside the environment configuration on the job. GitHub documents the valid values as true, the default, and false, and allows an expression so you can record a deployment on some refs and not others. It cannot be combined with custom deployment protection rules.
Why is an environment variable empty when the environment secret works?
Because a variable is not in the secrets context. GitHub states that variables stored in an environment "are only accessible using the vars context". Change the expression to ${{ vars.NAME }} and keep the environment: key, since the per-job scoping rule is the same for both.
Do environment secrets work on private repositories?
Not on the free plan. GitHub notes that on GitHub Free environment secrets are only available in public repositories, and that access in private or internal repositories requires GitHub Pro, GitHub Team or GitHub Enterprise. Nothing in the settings page stops you saving the secret, so the only symptom is an empty value.

Related guides

References

A silent empty secret still bills for the build above it. Latchkey prices those minutes at $0.0025 at 2 vCPU. Start free → 30-day trial · No credit card