CI/CD: The Complete Guide
CI/CD is the automation backbone of modern software delivery: the practices and tooling that take a code change from a developer's branch to production quickly, repeatably, and with evidence at every step that it is safe to proceed. This guide covers what the terms actually mean, what a pipeline looks like in practice, and how to build one on GitHub Actions — including a complete, working deployment to AWS Lambda that uses no long-lived cloud credentials at all.
What CI/CD Actually Means
Continuous Integration (CI) is the practice of merging code changes into a shared mainline frequently — ideally daily — and validating every merge with an automated build and test suite. The goal is to surface integration problems within minutes of the change that caused them, while the diff is small and the author still has context.
The "CD" expands two ways, and the difference matters. Continuous Delivery means every change that passes the pipeline produces a deployable artifact, and releasing it to production is a deliberate decision — typically a manual approval or a scheduled release. Continuous Deployment goes one step further: every passing change ships to production automatically, with no human gate. Most teams practice continuous delivery; continuous deployment is the right end state for services with strong test coverage and mature operations. Note that deploying every change does not by itself make releases safer — what limits blast radius is the machinery around the deploy: staged rollouts, canary releases, health checks, and automated rollback when metrics regress.
Anatomy of a Pipeline
Whatever tool you use, a pipeline is a sequence of stages, each of which can stop a change from progressing:
- Source — a trigger fires on a pull request or a merge to the main branch.
- Build — compile, resolve dependencies, and produce a versioned artifact: a container image, a zip bundle, a package.
- Test — fast unit tests on every commit; integration and end-to-end tests where their cost is justified.
- Security checks — dependency audit, static analysis, secret scanning.
- Deploy to staging — the artifact, unchanged, goes to a production-like environment.
- Promote to production — the same artifact again, behind an approval gate or automatically.
The most important property is build once, deploy the same artifact everywhere. If staging and production run different builds, staging validates nothing.
GitHub Actions as the Reference Implementation
GitHub Actions is the default CI/CD system for teams already on GitHub, and its model maps directly onto pipeline anatomy. A workflow is a YAML file in .github/workflows/ that runs in response to events. A workflow contains one or more jobs; each job runs on a runner — a GitHub-hosted virtual machine, or a self-hosted machine or container you operate — and consists of sequential steps. Jobs run in parallel by default and can be ordered with needs.
Here is a minimal but production-grade CI workflow for a Node.js project:
name: ci
on:
pull_request:
push:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm test
The details separate this from tutorial code: actions pinned to current majors (checkout@v4, setup-node@v4 — GitHub fails workflows still using retired majors like v2, which ran on end-of-life Node runtimes), Node 22 as the active LTS line, dependency caching, and npm ci instead of npm install for reproducible installs.
Deploying AWS Lambda with GitHub Actions and OIDC
Deployment is where most published tutorials go wrong, usually by storing AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY as repository secrets. Long-lived cloud credentials in CI are a liability: they never expire, they can leak through logs and forks, and rotating them is manual work that rarely happens. The correct pattern — supported by AWS and GitHub since 2021 and the unambiguous best practice in 2026 — is OIDC federation: the workflow requests a short-lived identity token from GitHub, presents it to AWS STS, and assumes an IAM role scoped to exactly that repository and branch. No static keys exist anywhere.
The one-time AWS setup:
- Create an IAM OIDC identity provider for
token.actions.githubusercontent.com. - Create a deployment role whose trust policy allows
sts:AssumeRoleWithWebIdentityonly when the token'ssubclaim matches your repository and branch (for examplerepo:your-org/your-repo:ref:refs/heads/main), with a permissions policy allowinglambda:UpdateFunctionCodeon the target function.
Keep this deployment role separate from the function's execution role: the execution role is what grants your running Lambda code access to other AWS services, and the pipeline never needs those permissions. If the service itself is new to you, start with our primer on what AWS Lambda is and when to use it.
The complete deployment workflow:
name: deploy-lambda
on:
push:
branches: [main]
permissions:
id-token: write
contents: read
concurrency:
group: lambda-deploy
cancel-in-progress: false
jobs:
deploy:
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- name: Install production dependencies
run: npm ci --omit=dev
- name: Package function
run: zip -r bundle.zip . -x ".git/*" -x ".github/*"
- name: Configure AWS credentials via OIDC
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/github-actions-lambda-deploy
aws-region: eu-central-1
- name: Deploy
run: |
aws lambda update-function-code \
--function-name my-lambda-function \
--zip-file fileb://bundle.zip
aws lambda wait function-updated \
--function-name my-lambda-function
Four lines carry the security and correctness weight. permissions: id-token: write lets the job request the OIDC token. The trigger is restricted to pushes on main — a bare on: push fires on every branch, which for a deploy workflow means every feature branch ships to production. The concurrency group prevents two deploys from racing when merges land in quick succession. And aws lambda wait function-updated makes the job fail loudly if the update does not complete, instead of reporting green on a half-finished deploy.
Testing Gates
A pipeline is only as trustworthy as the gates inside it. Run unit tests and linting on every pull request and mark them as required status checks in branch protection, so a red build is impossible to merge rather than merely embarrassing. Reserve slower integration and end-to-end suites for merges to main or a pre-deploy stage. Treat a flaky test as an incident, not an annoyance: the first time engineers re-run a red build "because it's probably fine", the gate has stopped meaning anything. We cover how to layer these suites in our guide to automated testing strategies.
Environments and Approvals
GitHub environments are the release control plane. Define staging and production in the repository settings, attach environment-scoped secrets and variables, and add protection rules: required reviewers for production, optional wait timers, and deployment branch restrictions so only main can target it. The environment: production line in the workflow above is what activates those rules — the job pauses until an authorized reviewer approves, and every approval is audited. That is continuous delivery with an explicit release gate; remove the required-reviewer rule and the same pipeline becomes continuous deployment.
Common Pitfalls
- Static cloud keys in CI. Replace them with OIDC role assumption — on AWS, Azure, and Google Cloud alike.
- Deprecated action majors.
checkout@v2and friends run on retired runtimes and now fail outright. Pin current majors and let Dependabot propose updates. - Over-broad triggers. An unfiltered
on: pushruns — and deploys — from every branch. Always filter deploy workflows to the release branch. - Rebuilding per environment. Promote the artifact you tested; a rebuild for production is an untested build.
- No rollback path. Use Lambda versions and aliases (or blue-green for servers) so rollback is a pointer flip, not a re-deploy under pressure.
None of this is exotic — the workflows above are templates you can adapt in an afternoon. The harder work is organizational: getting test coverage to where green genuinely means shippable, and deploy frequency to where releases are boring. That is the work our DevOps consulting practice does with engineering teams, from a first pipeline to fully automated delivery.