GitHub Actions #3 Secrets, Variables, and Environments: secrets vs vars and Environment Approval Gates
The workflows up through #2 had no secrets in them. Real-world workflows are different. They need credentials for deploy targets, API keys, webhook URLs — and the moment you write those into YAML, they are exposed to everyone who can read the repository. This post covers the mechanisms that supply secrets and configuration to workflows safely, and the environment feature that builds a human approval gate on top.
secrets and vars: what to hide and what to show #
There are two channels for keeping a value in repository settings and referencing it from a workflow. Both are registered under the Secrets and variables menu in Settings.
- secrets: secret values. Once registered, nobody can view the value again (it can only be overwritten), and it is masked in logs. Referenced as
${{ secrets.API_KEY }}. - vars: configuration values that are not secret. The value stays visible and appears in logs as is. Referenced as
${{ vars.AWS_REGION }}.
The dividing line is “is it fine for this to appear in a log?” Putting harmless-if-public values — region names, bucket names, feature flags — into secrets only makes log debugging harder, so keep those in vars and reserve secrets for values whose leak would cause damage. The workflow in Terraform Operations #1, which keeps the role ARN in vars.TF_PLAN_ROLE_ARN, is an example of this rule: an ARN is not a secret.
Masking is an assist, not a guarantee #
When a secrets value shows up in a log, GitHub covers it with ***. Useful — but never treat it as the basis of your safety. Masking hides strings that exactly match a known value, so encoding the value in base64 before printing it, or printing it one character at a time, exposes it untouched. In other words, masking is an assist that reduces accidents; real safety comes from never echoing secrets in the first place, and from the structure we cover in #9 — not handing secrets to untrusted code.
Three layers: organization, repository, environment #
secrets and vars can live in three places, and the narrower scope wins.
Organization ← shared across repositories (e.g., a shared notification webhook)
└ Repository ← shared within this repository
└ Environment ← specific to one environment (e.g., a prod deploy role)When the same name exists at multiple layers, it applies in the order environment > repository > organization. This structure is what lets “dev and prod share one workflow while receiving different credentials” be expressed with a single name. The workflow code says ${{ secrets.DEPLOY_ROLE }} just once, and a different value flows in depending on which environment the job runs as.
environment: the gate in front of a deploy #
An environment is a secrets scope and, at the same time, the unit that protection rules attach to. You create one under Environments in Settings and assign it to a job.
jobs:
deploy-prod:
runs-on: ubuntu-latest
environment: production
steps:
- run: ./deploy.sh # secrets/vars from the production scope apply hereThe protection rules you can attach to an environment are the practical core.
- Required reviewers: the job does not start until the designated people or teams approve. A job waiting for approval sits paused in the Actions screen with a Review button, and approvals and rejections are recorded. This is the standard answer to the requirement “automate the deploy button, but keep the final call human.”
- Wait timer: forces a delay before the job starts. Its purpose is buying time to cancel after a deploy has been announced.
- Deployment branches: restricts which branches may deploy to this environment. Lock production to main only, and a workflow on any other branch accidentally receiving prod secrets is ruled out at the source.
This is the full picture of the mechanism that Terraform Operations #1 only previewed as “an environment approval putting a human gate in front of prod.”
GITHUB_TOKEN: the secret you never registered #
The secrets context always contains one value you never registered: secrets.GITHUB_TOKEN. It is a short-lived token GitHub issues automatically for every workflow run; it carries API permissions for this repository and expires when the run ends. Commenting on PRs, creating releases, even the GHCR push we will see in #6 — all of it runs on this token, so work that stays within the repository needs no extra token of your own. What permissions this token holds, and why its defaults should be reduced, is the opening topic of the #9 security post.
Recap #
What we covered in this post:
- The dividing line is “can it appear in a log?” Only secrets go into secrets; keeping the rest in vars keeps debugging sane
- Masking is an assist that hides only exact string matches. The essence of safety is never printing secrets, plus structural isolation
- secrets and vars have three layers — organization, repository, environment — and the narrower wins, the basis for the same code receiving different values per environment
- An environment is both a secrets scope and a protection-rule unit. Required reviewers, wait timers, and branch restrictions build the deploy gate
- GITHUB_TOKEN is a short-lived token issued automatically per run, and it covers most work inside the repository
In the next post (#4 Matrix, Cache, and Artifacts), we cover the matrix that multiplies a run into many variants, the cache that cuts time and cost in the other direction, and the artifacts that carry files between jobs.