Deployments, Environments and Rollbacks
Deploy with GitHub Actions through a protected environment with a required reviewer, run one deployment at a time, smoke test it, and roll back.
A deployment pipeline has to answer four questions: what exactly is being deployed, who may deploy it, what happens when two deployments overlap, and how you know it worked. GitHub Actions answers them with artifacts (the exact files CI built), environments with protection rules, concurrency groups, and a smoke test against the live system.
In this part you deploy the cheatsheet to GitHub Pages through an environment that needs an approval, check the live version automatically, and roll back by re-running an older deployment. Then you look at how rolling, blue-green and canary deployments fit into a workflow when your target is a cluster or a fleet of servers.
Prerequisites
- The repository from Part 5, with the
SITE_TITLEvariable. - A public repository, or a private one on a plan that includes GitHub Pages and required reviewers (see below).
Build Once, Deploy the Same Files
The most important rule of deployment pipelines: build the artifact once, test it, and deploy that exact artifact. If each environment rebuilds from source, a dependency update or a different build variable between two builds means production runs something nobody tested.
Environments and Protection Rules
An environment is a named deployment target in Settings > Environments, such as staging, production or github-pages. A job that sets environment: <name> deploys to it. An environment can hold:
- Secrets and variables that only jobs in that environment receive.
- Required reviewers: up to six people or teams, one of whom must approve each deployment. "Prevent self-review" stops the person who triggered the run from approving it.
- A wait timer of up to 30 days before the job starts.
- Deployment branches and tags: for example, only
mainmay deploy to production. - Custom protection rules from GitHub Apps, such as a check in your monitoring system (in public preview).
The job pauses before it starts, with no runner and no billable minutes, until the rules pass. A run can wait up to 30 days for an approval. Every deployment is recorded in the repository's deployment history.
Availability depends on the plan. In public repositories, all of this works on every plan. In private repositories, environments, their secrets and branch rules need GitHub Pro, Team or Enterprise, and required reviewers and wait timers need GitHub Enterprise.
If you need an environment's secrets and variables for a job that does not deploy anything, such as integration tests, set deployment: false under environment: (since March 2026); the job then does not create a deployment record.
Set Up GitHub Pages
GitHub Pages hosts static sites for free from public repositories, and it can deploy from a workflow. Turn it on with the API (or in Settings > Pages, with Source: GitHub Actions):
This also creates an environment called github-pages that only the default branch may deploy to. Add yourself as a required reviewer: Settings > Environments > github-pages > Required reviewers, then Save protection rules. Now it has two rules:
The Deploy Workflow
Create .github/workflows/deploy.yml:
name: Deploy
on:
push:
branches: [main]
workflow_dispatch:
permissions:
contents: read
# One deployment at a time; a newer run waits instead of cancelling a deployment
concurrency:
group: pages-production
cancel-in-progress: false
jobs:
build:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-node@v7
with:
node-version: 24
cache: npm
- run: npm ci
- run: npm test
- run: npm run build
env:
SITE_TITLE: ${{ vars.SITE_TITLE }}
- name: Package the site for GitHub Pages
uses: actions/upload-pages-artifact@v5
with:
path: dist/
deploy:
needs: build
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
pages: write
id-token: write
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
outputs:
page_url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v5
smoke-test:
needs: deploy
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Check that the new version is live
env:
PAGE_URL: ${{ needs.deploy.outputs.page_url }}
run: |
for attempt in 1 2 3 4 5 6; do
live=$(curl -sSf "${PAGE_URL}version.json?run=$GITHUB_RUN_ID" | jq -r .commit) || live=""
echo "Attempt $attempt: live commit ${live:-none}, expected $GITHUB_SHA"
if [ "$live" = "$GITHUB_SHA" ]; then
exit 0
fi
sleep 10
done
echo "::error::The site does not serve commit $GITHUB_SHA"
exit 1
The pieces:
concurrencyputs every run of this workflow into the grouppages-production. Only one run in a group is active at a time. Withcancel-in-progress: false, a new run waits instead of canceling a deployment halfway. Only one run can wait: if a third run arrives, it replaces the waiting one, which is canceled. Runs enter the group in the order GitHub queues them, which is usually, but not always, the order of the pushes. Since May 2026,queue: maxlets up to 100 runs wait in order instead.upload-pages-artifactpacksdist/into an artifact calledgithub-pages, the formatdeploy-pagesexpects. It runs in thebuildjob, so the files are fixed before anyone approves anything.- The
deployjob is the only one with write access:pages: writeto publish, andid-token: writebecausedeploy-pagesproves its identity with an OIDC token.environment:applies the protection rules, andurl:links the deployment to the live site. - The job output
page_urlpasses the site address to the next job. smoke-testchecks that the live site serves the commit this run built, and retries for a minute while the new version spreads. Pages serves files through a CDN withCache-Control: max-age=600, so the request adds a query string that the CDN has not seen before.
A review of this workflow found a flaw in that query string: ?run=$GITHUB_RUN_ID is the same for every retry and for every re-run of the run. If the first request comes back with the old version, the CDN caches that answer under that URL for ten minutes, and the retries get it again. The final version in Part 10 makes every request unique:
live=$(curl -sSf "${PAGE_URL}version.json?run=$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT-$attempt" | jq -r .commit) || live=""
Deploy and Approve
Push the workflow. The build job runs at once, then the run stops at deploy:
The run page shows Review deployments; a reviewer opens it, can leave a comment, and approves or rejects. The GitHub CLI has no command for it, but the API does the same:
After the approval, the deployment and the smoke test ran:
The site is live at the address in page_url, and every deployment shows up in the repository's Deployments list with its commit.
Roll Back
When a deployment goes wrong, you have two ways back:
- Re-deploy the previous build. Every run keeps its artifact, so re-running the
deployjob of an older run puts that run's files back. This is the fast path. - Revert the change.
git revertthe bad commit and merge it; the normal pipeline builds and deploys the result. It is slower, butmainand production agree again, and the next deployment does not bring the bad change back.
Use the first to stop the damage and the second to fix the history. To try the fast path, the companion repository deployed two versions and then rolled back to the older one. These runs use the final version of deploy.yml from Part 7, which has the same deploy and smoke-test jobs:
The live site serves cb69783 again, the commit of the older run, while main is one commit ahead. Re-running the newest run's deploy job rolled it forward again.
Two things make this work. The companion repository got the second one wrong at first:
The artifact must still exist.
upload-pages-artifactkeeps its artifact for 1 day by default. Setretention-daysto how far back you want to be able to roll:- name: Package the site for GitHub Pages uses: actions/upload-pages-artifact@v5 with: path: dist/ retention-days: 14The deploy job must not create the artifact. An earlier version of the workflow packaged the site inside the
deployjob. Re-running that job uploaded a secondgithub-pagesartifact into the same run, and the deployment failed:
Package once, in an earlier job, and keep the deploy job to the deployment itself. A run can be re-run for up to 30 days, and at most 50 times (since April 2026).
Deployment Strategies
GitHub Pages swaps the whole site at once. For an application on servers or Kubernetes, you choose how the new version replaces the old one. The workflow triggers and watches the rollout; the platform (a load balancer, Kubernetes, a service mesh) does the traffic switching.
| Strategy | How it works | You need | Rollback |
|---|---|---|---|
| Recreate | Stop the old version, start the new one | Nothing extra; accepts a short outage | Deploy the old version again |
| Rolling | Replace instances a few at a time, each after a health check | Several instances behind a load balancer | Roll back the same way, or undo the rollout |
| Blue-green | Start the new version next to the old, then switch all traffic at once | Capacity for two full copies | Switch traffic back, in seconds |
| Canary | Send a small share of traffic to the new version, widen it while metrics stay good | Traffic splitting and good metrics | Send the share back to zero |
On Kubernetes, a Deployment does a rolling update by default, and the workflow only needs to change the image and wait for the result. These steps (not run in the companion repository) deploy an image by digest and undo the rollout only if the new version was applied and did not become healthy:
- name: Update the image
id: update
env:
IMAGE: ghcr.io/my-org/cheatsheet@${{ needs.build.outputs.digest }}
run: kubectl set image deployment/cheatsheet web="$IMAGE"
- name: Wait for the rollout
id: rollout
run: kubectl rollout status deployment/cheatsheet --timeout=5m
- name: Undo the rollout if it did not become healthy
if: failure() && steps.update.outcome == 'success' && steps.rollout.outcome == 'failure'
run: kubectl rollout undo deployment/cheatsheet
The condition matters: a plain if: failure() would also run when an earlier step failed before anything changed, and rollout undo would then revert the release that was running fine.
For blue-green and canary releases on Kubernetes, tools such as Argo Rollouts and Flagger change the traffic split step by step and roll back on bad metrics; the workflow then only updates the desired version, often through a Git repository that Argo CD watches.
For a single server, a release directory per commit and a symlink give you atomic switches and instant rollbacks. This sketch (also not run here) uses an SSH key from an environment secret and pins the server's host key instead of turning host key checking off:
- name: Deploy over SSH
env:
SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
KNOWN_HOSTS: ${{ vars.DEPLOY_KNOWN_HOSTS }}
HOST: ${{ vars.DEPLOY_HOST }}
run: |
install -m 700 -d ~/.ssh
printf '%s\n' "$SSH_KEY" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
printf '%s\n' "$KNOWN_HOSTS" > ~/.ssh/known_hosts
release="/srv/cheatsheet/releases/$GITHUB_SHA"
rsync -a -e "ssh -i ~/.ssh/deploy_key" site/ "deploy@$HOST:$release/"
ssh -i ~/.ssh/deploy_key "deploy@$HOST" \
"ln -sfn $release /srv/cheatsheet/next && mv -T /srv/cheatsheet/next /srv/cheatsheet/current"
mv -T replaces the current link in one step, so no request ever sees a half-copied release. A rollback points current at the previous release directory.
Common Mistakes
- Building separately for each environment. Build once and promote the same artifact.
cancel-in-progress: trueon deployments. It can stop a deployment halfway. Use it for CI on pull requests, not for deploys.- No smoke test. "The deploy step succeeded" does not mean users get the new version.
- Production credentials at repository level. Put them in the environment, so only approved deployment jobs get them.
- Turning off SSH host key checking.
StrictHostKeyChecking=nolets anyone in the network path pose as your server. Store the host key and check it. - Artifacts that expire before you need them. Set
retention-dayson what you may want to roll back to.
Summary
- Build once, then deploy that exact artifact.
- Environments hold deployment secrets and protection rules such as required reviewers, wait timers and branch rules.
- A
concurrencygroup withcancel-in-progress: falseallows one deployment at a time;queue: maxkeeps more than one waiting. - A smoke test against the live system confirms the deployment; re-running an older run's deploy job is a fast rollback if its artifact still exists.
- Rolling, blue-green and canary strategies are done by the platform; the workflow starts and watches them.
In Part 7 you test on Linux, Windows and macOS, turn CI into a reusable workflow that the deploy workflow calls, and merge through a merge queue.
Keep Learning
Tags
Found an issue?