Skip to content

Continuous Delivery with GitHub Actions

6 min readGitHub Actions & CI/CD · Continuous Delivery

Continuous integration answers is this change safe? Delivery answers how does it reach users, and who decides when?

The second question is mostly not technical. The mechanics of deploying are usually a handful of steps; the engineering is in what must be true first, who can approve, what happens when it goes wrong, and how the thing being deployed relates to the thing that was tested.

Start with Deploy to AWS

Continuous integration — every change is built and tested on integration. Output: a verdict.

Continuous delivery — every validated change could be released at any time. A deployable artifact exists and is proven deployable. Releasing is a decision someone makes.

Continuous deployment — every validated change is released, automatically, with no human gate.

A successful build should not automatically imply an uncontrolled production deployment.

Continuous deployment is a legitimate and sometimes excellent choice. It is a choice, made with knowledge of your blast radius, your monitoring and your ability to reverse. Arriving at it because nobody configured a gate is different.

The principle that shapes every lesson here:

One artifact, promoted through environments

A sequence: source is built once into an artifact identified by digest; that same artifact is tested, deployed to staging, validated, and then promoted unchanged to production.

SourceA specific commitBuildOnce — never per environmentArtifact / digestImmutably identifiedTestAgainst that exact artifactStagingDeploy the same artifactValidateProve it works thereProductionPromote — do not rebuild

Rebuilding per environment means production runs something that was never tested. The source may be identical while dependency resolution, base images and build tooling have moved. Promoting one immutable artifact — identified by a digest, not a mutable tag — is what makes staging validation mean anything at all.

Container images make this concrete: myapp:latest is a moving pointer, while myapp@sha256:a3f8c2… is one specific image forever. The lessons here deploy by digest.

Every cloud lesson in this cluster authenticates with OIDC, not stored access keys.

The short version: the workflow requests a short-lived signed token from GitHub describing who it is — repository, branch, environment. Your cloud provider is configured to trust tokens matching specific claims and exchanges one for temporary credentials. Nothing long-lived is ever stored in GitHub.

permissions:
contents: read
id-token: write # allows requesting an OIDC token

The Security cluster covers each cloud’s federation setup in depth. This cluster covers the deployment itself.

You should be able toCovered in
Build and test a project in CIContinuous Integration
Use job dependencies and conditionsJobs
Explain the OIDC trust modelGitHub Actions OIDC
Configure an environmentEnvironments
Understand artifact versus cacheArtifacts

You also need an account with the cloud provider you intend to deploy to, and enough access to create an identity provider and a role. Every example uses placeholders — no real account identifiers appear.

  • Deploy to AWS, Azure and Google Cloud without storing a single long-lived credential.
  • Structure a deployment as a separate job that depends on a successful build.
  • Use environments to attach secrets, protection rules and deployment history to a target.
  • Prevent two production deployments running simultaneously with concurrency control.
  • Promote an immutable artifact rather than rebuilding, and identify it by digest.
  • Verify a deployment succeeded rather than assuming a green job means a working system.
  • Describe rollback accurately for the system you are deploying — which differs enormously.
  1. Lesson 1: 01. Deploy to AWSDeploy to AWS from GitHub Actions using OIDC instead of stored access keys — S3 and CloudFront, ECS, Lambda, environments, concurrency and honest rollback.Intermediate → Advanced6 min read
  2. Lesson 2: 02. Deploy to AzureDeploy to Azure from GitHub Actions using federated credentials instead of a service principal secret — App Service, Functions, Container Apps, slots and rollback.Intermediate → Advanced5 min read
  3. Lesson 3: 03. Deploy to Google CloudDeploy to Google Cloud from GitHub Actions using Workload Identity Federation — Cloud Run, Artifact Registry, traffic splitting, revisions and rollback.Intermediate → Advanced4 min read
  4. Lesson 4: 04. Deploy Docker ApplicationsDeploy container images from GitHub Actions — publishing to GHCR, deploying by digest, registry authentication, rolling updates and rollback that actually works.Intermediate4 min read
  5. Lesson 5: 05. Deploy to KubernetesDeploy to Kubernetes from GitHub Actions — cluster authentication without stored kubeconfigs, manifest templating, rollout status gating, undo, and push versus GitOps.Advanced5 min read
  6. Lesson 6: 06. Deploy TerraformApply Terraform from GitHub Actions after merge — saved plans, environments and approvals, state locking, drift detection, and what infrastructure rollback actually means.Advanced6 min read
  7. Lesson 7: 07. Deploy GitHub PagesDeploy a static site to GitHub Pages with GitHub Actions — the Pages deployment API, split build and deploy jobs, custom domains, concurrency and rollback.Beginner → Intermediate4 min read

For CI, cancelling a superseded run saves money. For deployment, concurrency control prevents corruption.

concurrency:
group: deploy-production
cancel-in-progress: false

Two production deployments running simultaneously can interleave in ways that leave a system in a state neither intended. For Terraform it can mean competing state operations; for Kubernetes, conflicting rollouts; for anything with migrations, genuine data risk.

cancel-in-progress: false is the critical difference from the CI pattern. Cancelling a half-finished deployment is usually worse than queueing behind it — the in-flight one is left partially applied.

Every deployment lesson in this cluster sets concurrency, and says why.

Every deployment lesson here uses a GitHub environment, which is the feature that connects five otherwise-separate concerns:

Secrets and variables scoped to one target, so staging and production configuration cannot be confused.

Protection rules — required reviewers, wait timers, branch restrictions — that gate whether the job may proceed at all.

Deployment history, visible on the repository, recording what went where and when.

OIDC claims, so a cloud trust policy can require that a token came from a job targeting production rather than merely from the repository.

A URL, surfaced in the interface, linking to what was deployed.

jobs:
deploy:
runs-on: ubuntu-latest
environment:
name: production
url: https://example.com
steps:
- run: ./deploy.sh

Declaring environment: is what makes the environment’s secrets reachable and its protection rules apply. A deployment job without one is using repository secrets and no gate — which is a choice, and should be a deliberate one.

A deployment job that exits zero means the deployment command succeeded. It does not mean the system works.

- name: Deploy
run: ./deploy.sh
- name: Verify
run: |
for attempt in $(seq 1 30); do
if curl -fsS --max-time 5 "$HEALTH_URL" >/dev/null; then
echo "healthy after ${attempt} attempt(s)"
exit 0
fi
sleep 10
done
echo "::error::service did not become healthy within 5 minutes"
exit 1
env:
HEALTH_URL: https://example.com/healthz

Without that, a deployment that succeeded in shipping a broken build reports success, and the first signal is a user. With it, the pipeline knows — which is also what makes automated rollback possible, since something must decide the deployment failed.

Rollback semantics differ so much between systems that a single description would be wrong for most of them.

SystemWhat rollback actually means
Stateless applicationRedeploy the previous artifact — usually clean
Kuberneteskubectl rollout undo, or redeploy the previous digest
Container platformPoint the service at the previous image digest
TerraformNo generic rollback. Applying an older configuration is a new change
Database migrationMigration-specific; often not reversible at all
Static siteRedeploy the previous build — genuinely simple

Never write “click rollback and everything returns to normal.”

For anything with state, forward-fixing is often safer than reversing. Each lesson states what rollback means for its target, including where the honest answer is “you cannot, and here is what to do instead.”

Before a deployment workflow runs against anything real:

  1. Does it declare an environment? That is what attaches secrets, protection rules and history.
  2. Is concurrency set with cancel-in-progress: false? Two simultaneous production deployments is the failure this prevents.
  3. Does it authenticate with OIDC rather than stored credentials?
  4. Are permissions minimal, with id-token: write only where OIDC is used?
  5. Does it deploy an artifact built earlier, identified by digest, rather than rebuilding?
  6. Does it verify the deployment succeeded, rather than assuming a zero exit means a working system?
  7. Is the trigger filtered so it cannot fire from an arbitrary branch?
  8. Is rollback documented for this specific system, honestly?

Items 2 and 5 are the ones most often missing, and both fail silently until the day they do not.

The Advanced cluster covers the machinery these deployments use — environments, approvals, artifacts and reusable workflows — in the depth this cluster assumes.

The Security cluster covers per-cloud OIDC federation setup, and the supply-chain question of proving that the artifact you deployed is the one your pipeline built.## One artifact, many targets

The single idea to carry through this cluster: the thing you deploy to production should be the exact thing you tested, identified by a digest rather than a tag, promoted rather than rebuilt.

Everything else — environments, approvals, OIDC, concurrency — exists to make that promotion safe.

The lessons use the word for anything that makes a change to a running system: pushing an image, applying a manifest, updating infrastructure, publishing a site.

That breadth is deliberate, because the engineering concerns are the same regardless of target — identity, concurrency, verification, and an honest account of reversal. What differs is the mechanism, and the mechanism is usually the least interesting part.

Begin: Deploy to AWS with GitHub Actions