Continuous Integration vs Continuous Delivery vs Deployment
Teams say “CI/CD” as if it were one thing, but continuous integration (CI), continuous delivery, and continuous deployment are three distinct stages of automation, each building on the guarantees of the one before it. Mixing them up is not just sloppy vocabulary — it changes who is expected to approve a release, what steps run without a human watching, and which secrets a workflow needs access to. This lesson defines each stage precisely and shows how the boundary between them is expressed in real GitHub Actions workflow YAML.
Overview / How it works
Continuous integration means every change is automatically built and tested as soon as it is pushed or opened as a pull request. The goal is to catch integration bugs — two branches that individually work but conflict when combined — within minutes instead of days. CI does not necessarily produce anything meant for production; its job is to keep the main branch in a known-good state.
Continuous delivery builds on CI by guaranteeing that every change which passes the pipeline results in a release-ready artifact: a container image, a package, a build bundle. The team is confident that main is always deployable. What continuous delivery does not do is push that artifact to production automatically — a human still decides when a release happens, typically by approving a deployment gate.
Continuous deployment removes that human gate. Every change that passes the pipeline’s automated checks is deployed to production immediately, with no manual approval step. Because there is no person reviewing the release before customers see it, the safety of continuous deployment depends entirely on the quality of the automation: fast and thorough tests, security scans, health checks after deploy, and an automatic rollback path.
The difference between the three is not about tooling — the same GitHub Actions workflow can express any of them. The difference is where the automation stops and a person is expected to intervene.
Syntax or workflow structure
GitHub Actions maps cleanly onto this spectrum using a small set of building blocks:
on:triggers decide when each stage runs —pull_requestfor CI feedback on proposed changes,pushto a protected branch for anything that touches production.jobs:combined withneeds:express the pipeline as an explicit chain:testmust succeed beforebuild-and-pushruns, which must succeed beforedeployruns.permissions:should be scoped per job, not left at the repository default. A test job only needscontents: read; a job that pushes a container image also needspackages: write; neither needs deploy credentials.environment:on a job is how GitHub Actions expresses a delivery gate. When an environment has required reviewers configured under Settings > Environments, any job referencing that environment pauses and waits for an approval before it runs — this single setting is what turns a workflow from continuous deployment into continuous delivery, even though the YAML looks almost identical.outputs:pass values like an image digest between jobs, so the deploy job can reference the exact artifact that was tested and built, not just a mutable tag.
Examples
Example 1: Continuous integration only
This workflow runs on every push and pull request, including ones from forks. It has read-only permissions and touches no secrets, so it is safe to run against untrusted contributions.
name: CI
on:
push:
branches: [main]
pull_request:
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- run: npm ci
- run: npm test
- run: npm run lint
Expected behavior: Every push and PR gets a pass/fail status check within a few minutes. A branch protection rule requiring this check means nothing merges to main without green tests. Nothing is deployed — this pipeline only tells you the code is correct, not that it has shipped anywhere.
Example 2: Continuous delivery
This workflow extends Example 1: after tests pass on main, it builds a container image, tags it with the commit SHA, and pushes it to GitHub Container Registry. The deploy job targets an environment: production that has required reviewers configured in the repository settings, so it pauses until someone approves it.
name: Build and Publish Image
on:
push:
branches: [main]
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npm test
build-and-push:
needs: test
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
outputs:
digest: ${{ steps.push.outputs.digest }}
steps:
- uses: actions/checkout@v4
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- id: push
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ghcr.io/${{ github.repository }}:${{ github.sha }}
deploy:
needs: build-and-push
runs-on: ubuntu-latest
environment: production
permissions:
contents: read
steps:
- name: Deploy reviewed image by digest
run: ./scripts/deploy.sh ghcr.io/${{ github.repository }}@${{ needs.build-and-push.outputs.digest }}
Expected behavior: Every merge to main produces a fresh, tested image sitting in the registry, ready to ship. The deploy job appears as “waiting” in the Actions UI until a designated reviewer clicks approve. Note the deploy step references the image by digest (a content hash), not by a mutable tag — the reviewer approves an immutable artifact, not a name that could point somewhere else by the time the job runs.
Example 3: Continuous deployment
This workflow removes the manual gate. The production environment here has no required reviewers — deployment happens automatically as soon as the build succeeds — but it adds a health check after deploy and an automatic rollback if that check fails.
name: Deploy to Production
on:
push:
branches: [main]
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npm test
build-and-push:
needs: test
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
outputs:
digest: ${{ steps.push.outputs.digest }}
previous_digest: ${{ steps.previous.outputs.digest }}
steps:
- uses: actions/checkout@v4
- id: previous
run: echo \"digest=$(./scripts/current-production-digest.sh)\" >> \"$GITHUB_OUTPUT\"
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- id: push
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ghcr.io/${{ github.repository }}:${{ github.sha }}
deploy:
needs: build-and-push
runs-on: ubuntu-latest
environment: production
permissions:
contents: read
steps:
- name: Deploy image by digest
run: ./scripts/deploy.sh ghcr.io/${{ github.repository }}@${{ needs.build-and-push.outputs.digest }}
- name: Health check
run: |
for i in 1 2 3 4 5; do
if curl -fsS https://${{ secrets.DEPLOY_HOST }}/healthz; then
exit 0
fi
sleep 10
done
exit 1
- name: Roll back on failure
if: failure()
run: ./scripts/deploy.sh ghcr.io/${{ github.repository }}@${{ needs.build-and-push.outputs.previous_digest }}
Expected behavior: Every merge to main is live in production within minutes, with no human in the loop. If the post-deploy health check fails five times in a row, the workflow redeploys the previously known-good digest automatically and the job is marked failed so the team is alerted. This only works safely because tests, the health check, and the rollback path were already trustworthy before the manual gate was removed.
Step by step
- A developer opens a pull request. The CI workflow (Example 1) runs on
pull_requestwith read-only permissions and no secrets, so it is safe even for a pull request from a fork. - The pull request merges to main. The
pushevent triggers the full pipeline: tests run again against the merge commit, thenbuild-and-pushbuilds a container image and pushes it to the registry tagged with the commit SHA. - If the team practices continuous delivery, the
deployjob pauses on theproductionenvironment’s required reviewers. A release manager checks the build, the changelog, and any pending incidents, then approves. - If the team practices continuous deployment, there is no pause: the same job runs immediately, deploys the image by digest, and executes a health check loop.
- Regardless of which model is in place, monitoring and alerting outside the workflow keep watching production after the job finishes — that ongoing observability is what lets a team trust continuous deployment enough to remove the manual gate in the first place.
Common Mistakes
Mistake 1: Deploying with credentials on untrusted pull request code
A workflow that checks out a fork’s pull request head using pull_request_target and then runs a deploy step with production secrets lets an attacker who opens a pull request run arbitrary code with your deploy token. This is not a theoretical risk — it is one of the most common real-world GitHub Actions compromises.
on:
pull_request_target:
jobs:
deploy-preview:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
- run: npm ci
- run: npm run build
- run: ./scripts/deploy.sh --token ${{ secrets.DEPLOY_TOKEN }}
The fix is to keep the two concerns separated: run tests and builds for pull requests (including forks) on the plain pull_request event with no access to deploy secrets, and only run the job that has deploy credentials on push to main, after the pull request has already been reviewed and merged by a maintainer.
name: CI
on:
pull_request:
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npm run build
- run: npm test
# A separate workflow triggered only by push to main, after merge,
# runs deploy.sh with production secrets. Fork pull requests never reach it.
Mistake 2: Calling a pipeline “continuous deployment” when it isn’t
A team labels its workflow “Deploy to Production” and tells new hires “we do continuous deployment,” but the actual release only happens after someone pastes an approval into a chat channel outside GitHub. During an incident, whoever is on call assumes production auto-updates on every merge and loses time looking for a deploy that a person is still sitting on. The workflow’s real behavior and its name and mental model disagree.
The fix is to make the gate visible in the same system that runs the pipeline, and name things for what they actually do: use an environment with required reviewers configured in Settings > Environments so the approval is visible in the Actions run itself, and call the workflow and process “continuous delivery” until that gate is actually removed.
jobs:
deploy:
needs: build-and-push
runs-on: ubuntu-latest
environment: production
steps:
- run: ./scripts/deploy.sh ghcr.io/${{ github.repository }}@${{ needs.build-and-push.outputs.digest }}
Best Practices
- Set explicit, minimal
permissions:at the workflow and job level; never rely on the default token scope for jobs that push images or deploy. - Never run a job with deploy secrets in response to a
pull_requestorpull_request_targetevent triggered by a fork; restrict deploy jobs topushevents on protected branches. - Deploy by image digest, not by a mutable tag — a tag can be overwritten after review, a digest cannot.
- Require the CI status check on the main branch’s protection rule so nothing reaches the delivery or deployment stage without passing tests.
- Use protected
environment:entries with required reviewers to make delivery gates visible and auditable, instead of approvals that happen outside GitHub. - Before removing a manual approval gate to move from continuous delivery to continuous deployment, make sure automated health checks and a rollback step already exist and have been tested.
- Name workflows, jobs, and environments to match what they actually do, so an on-call engineer reading the Actions tab during an incident understands the real release process at a glance.
Practice Exercises
- Take the CI workflow from Example 1 and add a step that fails the build if test coverage drops below a threshold, without adding any new permissions.
- Starting from the continuous delivery workflow in Example 2, configure a
productionenvironment with a required reviewer in your repository settings, and trigger a run to see the job pause and wait for approval. - Modify the continuous deployment workflow in Example 3 so the health check step also posts a failure notification (using a placeholder like
${{ secrets.ALERT_WEBHOOK }}) before the rollback step runs, so the team is notified the same moment production reverts.
Summary
Continuous integration automatically builds and tests every change so integration bugs surface early. Continuous delivery adds the guarantee that every change on main becomes a release-ready, immutable artifact, while still leaving the decision of when to release to a person via a protected environment. Continuous deployment removes that person entirely, shipping every passing change straight to production and relying on automated health checks and rollback to catch what a human reviewer would have caught. The three stages share the same GitHub Actions building blocks — triggers, job dependencies, environments, and scoped permissions — and the difference between them is a small, deliberate configuration choice, not a difference in tooling.
