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.

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 lives | When it is read | Which jobs can read it | Context |
|---|---|---|---|
| Organization | When the run is queued | Every job in the run | secrets |
| Repository | When the run is queued | Every job in the run | secrets |
| Environment secret | When a job referencing the environment starts | Only jobs with that environment: | secrets |
| Environment variable | When a job referencing the environment starts | Only 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
- Open the repository settings and look under Secrets and variables for Actions.
- Check the repository tab and each environment tab; the same name can exist in more than one place with different values.
- Check whether it is under secrets or under variables, because the second is only readable through the
varscontext.
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.
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.
- 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.
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".
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, anddeployment: falseon the ones that are not deployments. - Read environment variables through
varsand environment secrets throughsecrets, 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?
environment: key never gets that read and the value stays empty.Can I use an environment for its secrets without recording a deployment?
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?
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?
Related guides
References
- GitHub Actions: secrets reference, when Actions reads secrets and the per-scope limits
- GitHub Actions: deployments and environments, environment secrets and variables
- GitHub Actions: using environments without deployments
- GitHub Actions: workflow syntax, jobs.<job_id>.environment
- GitHub Actions documentation