Continuous Deployment with GitHub Actions
Ship code safely from GitHub Actions: delivery vs deployment, deploy triggers, Environments with approvals, OIDC to the cloud with no static keys, build-once flows, and rollback.
Getting a green CI run is the easy half. The hard half is turning that build into a running service in production without leaking credentials, without shipping a different artifact than the one you tested, and without a bad release taking the site down with no way back. This guide walks the whole path: how deploys get triggered, how GitHub Environments give you approvals and scoped secrets, how OIDC lets you deploy to a cloud provider with short-lived credentials instead of long-lived keys, and how to structure a build-once/deploy-the-same-artifact pipeline that you can roll back in one click.
Continuous delivery vs continuous deployment
These two terms get used interchangeably and they are not the same thing. The difference is one word: automation.
- Continuous delivery - every change that passes CI is automatically built and made ready to deploy, but a human decides when it actually goes to production. There is a gate: a click, an approval, a manual trigger. Delivery guarantees you can ship at any moment; it does not ship for you.
- Continuous deployment - every change that passes CI goes all the way to production automatically, no human in the loop. A merge to
mainbecomes a live release minutes later.
Neither is "better." Full continuous deployment demands a strong safety net - good tests, health checks, automatic rollback, feature flags - because there is no human pause to catch a mistake. Most teams start with continuous delivery (an approval gate before prod) and earn their way to continuous deployment as their tests and rollout safety mature. GitHub Actions supports both, and the difference is usually just whether an Environment has a required reviewer on it. Everything below applies to either; you choose how much of the gate to keep.
Triggering a deploy
A deploy is just a workflow, and the on: key decides when it runs. Three triggers cover almost every real setup, and they are not mutually exclusive - one workflow can offer several.
on:
push:
branches: [main] # deploy on every merge to main
release:
types: [published] # deploy when a GitHub Release is published
workflow_dispatch: # deploy manually from the Actions tab / CLI
- On push to
main- the continuous-deployment default. Merge a PR, the deploy runs. Simple and fast, and it pairs naturally with an Environment approval gate if you want a human check before prod. - On tag or release - deploy only when you cut a version. This decouples "merge" from "ship":
maincan move ahead while production tracks tagged releases (v1.4.0). A common variant triggers on tags matching a pattern withpush: { tags: ['v*'] }. This is the model when you want deliberate, versioned releases rather than shipping every commit. - Manual (
workflow_dispatch) - a button in the Actions tab (andgh workflow run) that lets a human kick off a deploy on demand, optionally with typed inputs like which environment or which version. Great for a promote-to-prod step, for redeploying a known-good version, or for one-off operational runs.
You can combine them: auto-deploy to staging on every push to main, and require a manual workflow_dispatch (or a tag) to promote to production. That single choice - automatic prod vs manual prod - is exactly the delivery/deployment line from the previous section.
GitHub Environments: the gate and the scoped secrets
An Environment is a named deployment target (staging, production) configured in your repo under Settings -> Environments. It is the single most useful GitHub feature for safe deploys, and it does two jobs at once.
Protection rules put a gate in front of a job. On an Environment you can require:
- Required reviewers - up to six people or teams who must click "Approve" before the job proceeds. The workflow pauses at the environment step and waits. This is your manual approval gate, and it is what turns continuous deployment into continuous delivery for that one target.
- A wait timer - a forced delay (say 10 minutes) before the deploy runs, giving a window to cancel.
- Deployment branches - restrict which branches (or tags) are allowed to deploy to this Environment. Lock
productiontomainonly so a random feature branch can never deploy to prod.
Environment-scoped secrets and variables are the other half. Secrets defined on an Environment are only readable by a job that declares environment: production. Your production database URL lives on the production Environment; a job targeting staging simply cannot see it. This beats repo-level secrets because access is bound to the target, and it is enforced together with the approval gate - the secret is not even available until the reviewer approves.
A job opts into all of this with one line:
jobs:
deploy:
runs-on: ubuntu-latest
environment: production # gate + scoped secrets apply here
steps:
- run: ./deploy.sh
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }} # from the production Environment
When this job is reached, the run pauses if production requires reviewers, shows up under the Environments panel with a live URL, and only then exposes that Environment's secrets. For a deeper look at how secrets are stored, masked, and safely passed around, see Secrets and security in GitHub Actions.
OIDC: deploy to the cloud with no static keys
The oldest mistake in CI/CD is storing a long-lived cloud access key as a secret. It never rotates, it works from anywhere, and if it ever leaks (a logged env var, a compromised action) an attacker has standing access to your cloud account. OIDC (OpenID Connect) removes the static key entirely.
Here is the idea. GitHub can act as an identity provider: for a workflow run, it mints a short-lived, signed OIDC token that describes exactly who is running - this repo, this branch, this environment. Your cloud provider is configured to trust GitHub's issuer and to accept that token in exchange for temporary credentials that expire in minutes. No secret is stored on either side. The trust is scoped by claims in the token (repository, ref, environment), so only the runs you allow can assume the role.
Two pieces make it work. First, the job must be allowed to request the token, which needs a specific permission:
permissions:
id-token: write # REQUIRED - lets the job request the OIDC token
contents: read # normal checkout access
Without id-token: write the job cannot get a token and the cloud login step fails. This is the pattern to remember. Second, an official cloud login action exchanges the token for temporary credentials. For AWS:
jobs:
deploy:
runs-on: ubuntu-latest
environment: production
permissions:
id-token: write
contents: read
steps:
- uses: actions/checkout@v4
- name: Configure AWS credentials via OIDC
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/github-actions-deploy
aws-region: us-east-1
# no aws-access-key-id / secret here - the role is assumed via the OIDC token
- name: Deploy
run: aws s3 sync ./dist s3://my-bucket --delete
The same pattern exists for the other clouds: google-github-actions/auth for GCP (Workload Identity Federation) and azure/login for Azure. You configure the trust once on the cloud side - an IAM role whose trust policy names GitHub's OIDC provider and restricts which repo/branch/environment can assume it - and from then on every deploy uses fresh credentials that die when the job ends. Combine OIDC with an Environment (environment: production plus a branch restriction) and you get defense in depth: only approved runs, from the right branch, can even request the token that the role trusts.
Build once, deploy the same artifact
A subtle but serious bug in naive pipelines: the deploy job rebuilds the code instead of shipping exactly what CI tested. Now you are deploying an artifact that never passed your tests - dependencies may have shifted, the build may not be reproducible, and "works in CI" no longer means "works in prod." The fix is a rule: build once, then deploy that identical artifact everywhere.
In GitHub Actions you express this with job dependencies via needs:. A build job produces the artifact (a Docker image pushed to a registry, or files uploaded with actions/upload-artifact), and one or more deploy jobs declare needs: build so they run only after it succeeds and can consume its output. The artifact is the single source of truth that flows from build to staging to production.
jobs:
build:
runs-on: ubuntu-latest
outputs:
image: ${{ steps.meta.outputs.image }}
steps:
- uses: actions/checkout@v4
- name: Build and upload the artifact
run: |
npm ci
npm run build
- uses: actions/upload-artifact@v4
with:
name: app-dist
path: dist/
deploy:
needs: build # runs only after build succeeds
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/download-artifact@v4
with:
name: app-dist # the SAME bytes build produced
path: dist/
- run: ./deploy.sh dist/
The needs: build line is what links them into a pipeline and guarantees ordering. Because deploy downloads the artifact build uploaded, it ships the exact bytes that were built and tested - never a fresh, unverified rebuild. Scale this to staging-then-prod by adding a second deploy job (needs: build, environment: staging) and letting the production job depend on both, so prod only runs after staging has deployed.
A concrete example: build a Docker image, push it, deploy it
For containerized apps the build-once artifact is the image itself, addressed by an immutable tag (the commit SHA). Build and push it once, then every deploy references that exact tag - no rebuild, no "latest" ambiguity about which code is live.
name: deploy
on:
push:
branches: [main]
permissions:
id-token: write
contents: read
packages: write # to push to GitHub Container Registry
jobs:
build:
runs-on: ubuntu-latest
outputs:
image: ${{ steps.meta.outputs.tag }}
steps:
- uses: actions/checkout@v4
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set image tag from commit SHA
id: meta
run: echo "tag=ghcr.io/${{ github.repository }}:${{ github.sha }}" >> "$GITHUB_OUTPUT"
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tag }}
deploy:
needs: build
runs-on: ubuntu-latest
environment: production # approval gate + prod secrets live here
steps:
- name: Configure AWS credentials via OIDC
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/github-actions-deploy
aws-region: us-east-1
- name: Roll out the new image
run: |
aws ecs update-service \
--cluster prod \
--service web \
--force-new-deployment \
--task-definition web:${{ github.sha }}
Read the shape of it: build tags the image with the commit SHA (so the tag is unique and traceable straight back to the code), pushes it, and exposes the tag as an output. deploy waits on build, logs in to the cloud with OIDC (no stored keys), and tells the platform to run that image tag. The same tag could be deployed to staging first and promoted to prod later - it is always the identical image, tested once.
Safe rollout: smoke check and easy rollback
A deploy is not done when the new version is running; it is done when you have confirmed it is healthy and you know how to undo it fast. Two practices carry most of the weight.
Smoke check after deploy. Immediately after the rollout, hit a real endpoint and verify it responds correctly. If it fails, the workflow fails loudly - and you have caught a broken release in the pipeline instead of from user complaints.
- name: Smoke check
run: |
for i in $(seq 1 10); do
if curl -fsS https://myapp.example.com/healthz; then
echo "healthy"; exit 0
fi
echo "waiting for app..."; sleep 6
done
echo "smoke check failed"; exit 1
Easy rollback. Because you deploy immutable, SHA-tagged artifacts, rolling back is just redeploying the previous known-good tag - the artifact still exists in your registry. A workflow_dispatch deploy that takes a version input makes this a one-click operation:
on:
workflow_dispatch:
inputs:
image_tag:
description: "Image tag (commit SHA) to deploy"
required: true
Now recovering from a bad release is "run the deploy workflow with the last good SHA," not a panicked manual scramble. This is exactly why build-once and immutable tags matter: rollback is only trivial when the old artifact is still sitting there, byte-for-byte, ready to redeploy. For higher-stakes services, layer on progressive strategies - blue/green (deploy alongside, then switch traffic) or canary (send a small slice of traffic to the new version first) - but a reliable smoke check plus a one-command rollback to the previous artifact is the baseline every pipeline should have before it calls itself continuous deployment.
The shape of it
Deployment with GitHub Actions is a few ideas stacked together. You pick a trigger - push to main, a tag/release, or manual workflow_dispatch - and that choice is where the delivery vs deployment line lives. An Environment wraps the target with a gate (required reviewers, branch limits) and scoped secrets that only the approved job can read. OIDC with permissions: id-token: write swaps long-lived cloud keys for short-lived credentials, so nothing static is ever stored. Build once and link jobs with needs: so every stage ships the exact artifact - ideally an immutable, SHA-tagged Docker image - that CI tested. And you close the loop with a smoke check and a one-command rollback to the previous artifact. Get those five right and shipping stops being the scary part of your day. For handling the credentials all of this depends on, read Secrets and security in GitHub Actions.