How to Build a CI/CD Pipeline: Step-by-Step With Sample Config
A starter CI/CD pipeline with build, test and deploy stages, explained step by step, with sample GitHub Actions and GitLab CI configuration you can adapt.
To build a CI/CD pipeline, put your code in Git, choose a CI/CD platform, then add 3 stages in a config file: build the app, run automated tests, and deploy to staging and production. Trigger it on every pull request and merge to main, store credentials as secrets, and add an approval gate before production until your tests are trusted.
Photo: Unsplash
On this page
You know you need a CI/CD pipeline. Maybe releases still mean someone running commands from a laptop, or tests only run when a developer remembers. The good news is that a useful pipeline does not take weeks. A starter version with build, test and deploy stages can be running in an afternoon, and you can improve it from there.
This guide walks through the decisions and steps in order, then gives you sample configuration for GitHub Actions and GitLab CI/CD that you can adapt. The examples use a Node.js web app, but the structure works for almost any language.
What you are building
A starter pipeline has 3 stages and 2 triggers.
Trigger: pull request Trigger: merge to main
| |
[Build] -> [Test] [Build] -> [Test] -> [Deploy staging] -> [Approve] -> [Deploy production]
|
Pass/fail shown on the PR
- On every pull request: build the app and run tests, so reviewers know the change works before they merge.
- On every merge to main: build, test, deploy automatically to staging, then deploy to production after approval.
That approval step makes this a continuous delivery pipeline. You can remove it later to move to continuous deployment. Our guide to continuous delivery vs continuous deployment explains when that makes sense, and our primer on what CI/CD is covers the concepts.
Before you start: a short checklist
| Prerequisite | Why it matters | Quick check |
|---|---|---|
| Code in Git with a main branch | Pipelines trigger on commits and merges | Everyone pushes to a shared remote |
| One command to build | Pipelines cannot click buttons | npm run build (or equivalent) works from a clean clone |
| One command to test | Tests must run unattended | npm test exits non-zero on failure |
| A deploy method you can script | Deployment must be repeatable | A CLI, script or deploy tool can release the build |
| A staging environment | Test the real deployment before production | Mirrors production config as closely as practical |
| Credentials for deployment | The pipeline needs access, safely | A dedicated deploy credential, not a person’s account |
If any row is missing, fix it first. Most pipeline problems are really build or deployment problems that were hidden by manual steps.
Steps 1 to 4: platform, build, test and deploy
These 4 decisions shape the pipeline before you write any configuration.
Step 1: Choose a CI/CD platform
Pick the platform closest to where your code lives. That keeps permissions, pull request checks and secrets in one place.
| Platform | Config file | Good fit when |
|---|---|---|
| GitHub Actions | .github/workflows/*.yml |
Code is on GitHub |
| GitLab CI/CD | .gitlab-ci.yml |
Code is on GitLab, including self-managed |
| Azure Pipelines | azure-pipelines.yml |
Teams invested in Azure DevOps |
| Jenkins | Jenkinsfile |
You need full control and can run your own servers |
| CircleCI and similar hosted services | .circleci/config.yml |
You want a dedicated CI service across code hosts |
Pricing is usually based on build minutes, runner size and users, and it changes. For example, GitHub’s documentation says standard GitHub-hosted runners are free for public repositories, and its Free plan includes 2,000 minutes a month for private repositories. Check the current numbers for your plan.
Step 2: Create the build stage
The build stage proves the code compiles and produces the artifact you will deploy. Key rules:
- Start from a clean environment every time, so nothing depends on a developer’s machine.
- Install exact dependency versions from your lockfile (
npm cirather thannpm install). - Run fast checks here, such as linting and type checks.
- Build once and save the result as an artifact. Deploy that same artifact to every environment, rather than rebuilding for each one.
Step 3: Add the test stage
Start with the tests you already have, even if there are only a few. A pipeline with 10 reliable tests is far more useful than none.
- Run unit tests on every pull request.
- Keep this stage fast. DORA’s continuous integration guidance suggests developers should get feedback from commit-triggered tests within about 10 minutes.
- Move slow end-to-end tests to after the staging deployment, or run them in parallel.
- Treat flaky tests as bugs. If developers learn to ignore red builds, the pipeline stops protecting you.
Add security checks early too: dependency vulnerability scanning and secret scanning are built into many platforms and catch common problems with little setup.
Step 4: Add the deploy stages
Deploy automatically to staging on every merge to main. Then deploy the same artifact to production, behind an approval while you build confidence.
- Use environments. Most platforms let you define staging and production environments, each with its own secrets and protection rules.
- Protect production. In GitHub Actions, environments can require reviewers before a job runs; only one listed reviewer needs to approve, and you can block people from approving their own deployments. GitHub’s docs note that required reviewers on private repositories depend on your plan, so check availability.
- Prevent overlapping deploys. Make sure 2 deployments to the same environment cannot run at once.
- Have a rollback. Know how to redeploy the previous version in one step before you need it.
Key takeaway: Build once, test automatically, deploy the same artifact everywhere and gate production with an approval until your tests earn your trust. That one pattern covers most of what a starter pipeline needs.
Step 5: Write the config (GitHub Actions example)
Save this as .github/workflows/ci-cd.yml. It assumes npm run lint, npm run build and npm test work in your project, that the build outputs to dist/, and that scripts/deploy.sh deploys a folder to the named environment.
```yaml name: CI/CD
on: pull_request: push: branches: [main]
permissions: contents: read
jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - uses: actions/setup-node@v7 with: node-version: 24 cache: npm - run: npm ci - run: npm run lint - run: npm run build - uses: actions/upload-artifact@v7 with: name: web-build path: dist/
test: needs: build runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - uses: actions/setup-node@v7 with: node-version: 24 cache: npm - run: npm ci - run: npm test
deploy-staging: if: github.event_name == ‘push’ && github.ref == ‘refs/heads/main’ needs: test runs-on: ubuntu-latest environment: staging concurrency: deploy-staging steps: - uses: actions/checkout@v7 - uses: actions/download-artifact@v8 with: name: web-build path: dist/ - run: ./scripts/deploy.sh staging env: DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
deploy-production: needs: deploy-staging runs-on: ubuntu-latest environment: production concurrency: deploy-production steps: - uses: actions/checkout@v7 - uses: actions/download-artifact@v8 with: name: web-build path: dist/ - run: ./scripts/deploy.sh production env: DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }} ```
What each part does:
on: runs on every pull request and every push to main.permissions: gives the workflow read-only access to the repository by default, a good security habit.needs: chains the jobs, so tests wait for a successful build and deploys wait for tests.ifon staging: deploys only after a merge to main, never from a pull request.environment: links each deploy job to an environment, where you store that environment’sDEPLOY_TOKENsecret and add required reviewers for production.concurrency: stops 2 deploys to the same environment from running at the same time.
Action versions and the Node.js version change over time. Check each action’s page and use the current major version and a supported Node.js LTS release for your project.
Step 6: The same pipeline in GitLab CI/CD
Save this as .gitlab-ci.yml. GitLab runs jobs stage by stage, and a job with when: manual inside rules waits for someone to start it.
```yaml stages: [build, test, staging, production]
default: image: node:24
build: stage: build script: - npm ci - npm run lint - npm run build artifacts: paths: [dist/]
test: stage: test script: - npm ci - npm test
deploy_staging: stage: staging script: ./scripts/deploy.sh staging environment: staging rules: - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
deploy_production: stage: production script: ./scripts/deploy.sh production environment: production rules: - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH when: manual ```
Store DEPLOY_TOKEN as a protected, masked CI/CD variable, and use protected environments if you want to restrict who can run the production job.
Step 7: Lock down secrets and permissions
Pipelines hold the keys to production, which makes them an attractive target.
- Never put credentials in the repository. Use the platform’s secret store, scoped per environment.
- Use a dedicated deploy identity with only the permissions deployment needs. Where your cloud supports it, prefer short-lived credentials through OpenID Connect (OIDC) over long-lived keys.
- Protect the main branch. Require pull requests, passing checks and at least one review before merging.
- Limit who can change pipeline files, since editing the workflow can change what runs with production access.
- Review third-party actions or plugins before use, and keep them updated.
These follow the same least-privilege thinking as zero trust security.
Step 8: Measure and improve
Once the pipeline runs, measure it. The DORA metrics are a solid starting set: change lead time, deployment frequency, change fail rate, failed deployment recovery time and deployment rework rate.
Common next improvements:
- Cache dependencies to speed up builds (the
cache: npmline above does this for npm). - Run tests in parallel as the suite grows.
- Add end-to-end tests against staging after deployment.
- Add monitoring checks after production deploys, with automatic rollback on error spikes.
- Introduce feature flags so you can deploy code without exposing unfinished features.
- Turn the pipeline into a shared template for other repositories; see platform engineering.
If you are building these skills for a career, they sit at the center of how to become a DevOps engineer.
Conclusion
A working CI/CD pipeline comes down to 3 stages, 2 triggers and a few safety rules. Build once from a clean environment, run fast automated tests on every pull request, deploy the same artifact to staging and production, and protect production with an approval and tightly scoped secrets. Start with the sample config, get it green and improve one stage at a time.
Sources
- GitHub Docs, Deployments and environments — environment protection rules, required reviewers and plan availability
- GitHub Docs, GitHub Actions billing — included minutes and free public repository usage
- GitLab Docs, CI/CD YAML syntax reference — stages, manual jobs and environments
- DORA, Continuous integration capability — feedback time guidance and CI practices
- DORA’s software delivery metrics — metrics for measuring the pipeline
Frequently asked questions
What are the basic stages of a CI/CD pipeline?
The basic stages are build, test and deploy. Build compiles or packages the code into an artifact. Test runs automated checks such as unit tests, linting and security scans. Deploy releases the artifact to staging and then production. Many pipelines add later stages for end-to-end tests, approvals and post-deployment monitoring.
How long does it take to build a CI/CD pipeline?
A starter pipeline with build, test and deploy stages can often be set up in a day if your project already builds and tests with single commands and has a scriptable deployment. The bigger time investment is usually fixing hidden manual steps, adding reliable tests and setting up a proper staging environment.
What is the easiest CI/CD tool to start with?
The easiest tool is usually the one built into your code host: GitHub Actions if your code is on GitHub, GitLab CI/CD if it is on GitLab. Both use a YAML file in your repository, integrate with pull requests and have hosted runners, so you do not need to manage servers to get started.
Is GitHub Actions free?
GitHub Actions is free for public repositories using standard GitHub-hosted runners, according to GitHub's documentation. For private repositories, plans include a monthly allowance of minutes, such as 2,000 minutes on GitHub Free, and usage beyond that is billed. Larger runners and some features cost extra, so check GitHub's current billing page.
How do I keep secrets safe in a CI/CD pipeline?
Store credentials in your platform's secret store, never in the repository, and scope them to the environment that needs them. Use a dedicated deploy identity with minimal permissions, prefer short-lived credentials through OIDC where your cloud supports it, protect the main branch and limit who can edit pipeline files.
Should every pull request deploy somewhere?
Not necessarily. Every pull request should build and run tests. Deploying each pull request to a temporary preview environment is useful for web apps and design review, but it adds cost and complexity. Many teams start by deploying only merges to main into staging, then add preview environments later if reviewers need them.
Do I need Docker to build a CI/CD pipeline?
No. Many pipelines build and deploy without Docker, for example by deploying a static site or a packaged app directly. Containers help when you want the same image to run in every environment, or when deploying to platforms such as Kubernetes. You can start without Docker and add it when your deployment target benefits from it.
Was this guide useful
No reviews yet. Share your experience and help other readers.