A GitHub environment is a named deployment target: staging,
production, preprod. It carries its own secrets, its own variables,
its protection rules, and it turns an ordinary job into a tracked
deployment in the repository's Deployments tab. This page shows how to create
one, how a job attaches to it, and why isolating secrets per environment beats
repository secrets prefixed with PROD_.
What you will learn
- Create an environment and understand the constraints on its name
- Attach a job to an environment, in the short and the long form
- Publish the deployment URL and know where GitHub displays it
- Isolate the secrets per target rather than by a naming convention
- Spot the plan limit that makes environments inert on a free private repository
Why not simply repository secrets?
The most widespread practice is to store every secret at repository level and
tell them apart with a prefix: STAGING_DEPLOY_TOKEN, PROD_DEPLOY_TOKEN. It
works, and it protects nothing.
A repository secret is reachable by every workflow of the repository, so by
every job, so by every step. A test workflow installing a compromised dependency
can read PROD_DEPLOY_TOKEN as easily as the deployment job. The prefix is a
human convention; it does not exist for the execution engine.
An environment secret changes the nature of the control: it is only delivered to jobs that declare the environment, and only after that environment's protection rules have been satisfied. The prefix becomes a real boundary.
| Repository secret | Environment secret | |
|---|---|---|
| Who can read it | Every job of the repository | The jobs declaring the environment |
| When | From the moment the job starts | After the protection rules are satisfied |
| staging/prod separation | By a naming convention | By the GitHub engine |
| Trace | None | A recorded deployment, with its state |
Creating an environment
The environment is created in the interface, not in the YAML. That is deliberate: it carries governance rules, which must not be editable by anyone pushing a commit to a branch.
-
Open the repository settings, the Settings > Environments section.
-
Create the environment with New environment, then give it a name.
Names are not case sensitive, never exceed 255 characters and are unique within the repository.
Productionandproductiontherefore designate the same environment. -
Configure the protection rules if the target deserves them (approvals, a delay, allowed branches). The detail of those four rules is the subject of the next page.
-
Add the secrets and variables specific to that target, in the Environment secrets and Environment variables sections.
Attaching a job to an environment
Two forms exist. The short form is enough when the deployment exposes no URL:
jobs: deploy-staging: runs-on: ubuntu-24.04 environment: staging steps: - name: Deploy to staging run: ./deploy.sh env: DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}The DEPLOY_TOKEN read here is the one from the staging environment, not the
repository's. The same workflow, with environment: production, reads the
production token under the same variable name. That is what lets you write a
single deployment job and parameterise it by its target.
The long form adds the deployment URL:
jobs: deploy-staging: runs-on: ubuntu-24.04 environment: name: staging url: ${{ steps.deploy.outputs.url }} steps: - name: Deploy to staging id: deploy run: | ./deploy.sh echo "url=https://staging.my-app.example.com" >> "$GITHUB_OUTPUT"Where the URL appears
Filling in url: is not cosmetic, it is what ties the deployment to a reachable
address. GitHub shows it in three places:
- on the repository's Deployments page, next to the environment;
- in the visualisation graph of the workflow run;
- as a View deployment button in the pull request timeline, where it applies.
That last point is the most useful day to day: the reviewer of a pull request reaches the preview environment without leaving the pull request, and without anyone pasting the URL into a comment.
Environment variables and secrets: what goes where?
Both mechanisms carry a value specific to the target, but they do not protect the same thing. The rule is binary: if leaking the value has an impact, it is a secret; otherwise it is a variable.
| Environment variable | Environment secret | |
|---|---|---|
| Visible in the logs | Yes, in clear text | No, masked by GitHub |
| Readable after creation | Yes | No |
| Examples | API URL, bucket name, region, log level | Deployment token, signing key, password |
jobs: deploy: runs-on: ubuntu-24.04 environment: production steps: - name: Deploy run: ./deploy.sh --region "$AWS_REGION" --bucket "$BUCKET" env: AWS_REGION: ${{ vars.AWS_REGION }} # a variable, not sensitive BUCKET: ${{ vars.BUCKET }} # a variable, not sensitive DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }} # a secret, maskedNote the systematic use of an env: block: a secret interpolated straight into
the command line shows up in the debug logs, and the automatic masking does
not catch a value transformed along the way.
The order in which secrets resolve
The same name can exist at three levels. GitHub applies a precedence, from the most specific to the most general:
Environment secret (the most specific, wins) | vRepository secret | vOrganisation secret (the most general)That precedence is the tool that lets you override cleanly: an organisation
defines a shared REGISTRY_TOKEN, a repository replaces it for its particular
case, and the production environment replaces it again with a token carrying
wider rights. The workflow, meanwhile, does not change a line.
The downside is a classic diagnosis trap: a secret that "does not take" is often a secret defined at the wrong level, masked by a more specific namesake. When in doubt, temporarily renaming one of the two removes the ambiguity in a single run.
The plan limit to check before building on it
Protection rules and environment secrets are not available on every plan. On a Free plan, they only work on public repositories. A private or internal repository requires at least GitHub Pro for a personal account, or a Team or Enterprise plan for an organisation.
The consequence is sneaky: on a free private repository, the environment is created, the job runs, the deployment appears in the history, but the approval never fires. You believe you have a door, you have a sign. Check the plan before founding a production governance on this mechanism.
Key points
- An environment is a named deployment target carrying its secrets, its variables and its rules, and turning a job into a tracked deployment.
- A repository secret is readable by every job of the repository; an environment secret is only delivered to the jobs declaring the environment, after its rules are satisfied.
- The
environment:key exists in a short form (a name) and a long form (name:plusurl:). - The deployment URL appears on the Deployments page, in the run graph, and as a View deployment button in the pull request.
- An environment named but non-existent is created automatically with no protection: create it first in Settings > Environments.
- Secrets resolve from the most specific to the most general: environment, then repository, then organisation.
- On a Free plan, environment protections and secrets only exist on public repositories: a private one requires Pro, Team or Enterprise.
Next steps
- Promoting an artifact: taking the same binary through staging then production, without ever rebuilding it in between.
- Releases and packages: publishing to GHCR and attaching artifacts to a GitHub release once the target is validated.
- Rollback and concurrency: what environment isolation buys you the day you have to go back.