CI/CD means tests run automatically when you push code, and if they pass, the code deploys automatically. You set it up once, then deploy without fear forever. This post walks through setting up a complete CI/CD pipeline from scratch using GitHub Actions—the industry-standard tool that’s free and requires zero infrastructure.
The Relief of Hitting Deploy
After our first proper CI/CD setup, deploys stopped being stressful. You push code, tests run automatically, and if they pass, it’s live. No manual checking. No “wait, did I run the tests?” No deploying at 3pm and hoping nothing breaks.
For two weeks before we set it up, I manually ran tests before deploying. Once. Forgot the second time, shipped a bug. That taught me: automation isn’t nice-to-have, it’s essential. CI/CD takes the human error out of deployment.
What CI/CD Actually Means
CI/CD is automating the testing and deployment of your code. Continuous Integration (CI) means tests run automatically on every commit. Continuous Deployment (CD) means code is automatically deployed to production if tests pass. The key benefit is catching bugs before they reach users and shipping faster without manual steps.
Without CI/CD, your workflow is:
- Write code
- Push to GitHub
- Manually run tests on your machine
- Cross fingers nothing breaks in production
- Manually SSH into the server and pull the latest code
This is where bugs escape to production. You forget a test, or tests pass locally but fail in production (different environment), or you deploy the wrong version.
With CI/CD, your workflow is:
- Write code
- Push to GitHub
- CI automatically runs tests
- If tests pass, CD automatically deploys
- You’re done
No manual steps. No room for human error.
A Complete CI/CD Pipeline in 20 Minutes
We’ll set up a pipeline that:
- Runs tests on every commit
- Builds a Docker image
- Pushes the image to a registry
- Deploys to production
- All automatically
Using GitHub Actions (GitHub’s built-in CI/CD). It’s free for public repos and generous for private ones.
Step 1: Create a GitHub Actions Workflow
In your repository, create a file:
.github/workflows/deploy.yml
name: CI/CD Pipeline
# Trigger on every push to main branch
on:
push:
branches:
- main
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Set up Node.js
uses: actions/setup-node@v3
with:
node-version: '18'
- name: Install dependencies
run: npm install
- name: Run tests
run: npm test
- name: Run linter
run: npm run lint
build:
needs: test # Only run if tests pass
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Login to Docker Hub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build and push Docker image
uses: docker/build-push-action@v4
with:
context: .
push: true
tags: ${{ secrets.DOCKER_USERNAME }}/my-app:${{ github.sha }}
deploy:
needs: build # Only run if build succeeds
runs-on: ubuntu-latest
steps:
- name: Deploy to production
uses: appleboy/ssh-action@master
with:
host: ${{ secrets.SERVER_HOST }}
username: ${{ secrets.SERVER_USER }}
key: ${{ secrets.SERVER_SSH_KEY }}
script: |
cd /app
docker pull ${{ secrets.DOCKER_USERNAME }}/my-app:${{ github.sha }}
docker stop my-app || true
docker rm my-app || true
docker run -d \
--name my-app \
-p 80:3000 \
-e DATABASE_URL=${{ secrets.DATABASE_URL }} \
-e API_KEY=${{ secrets.API_KEY }} \
${{ secrets.DOCKER_USERNAME }}/my-app:${{ github.sha }}
Let’s break this down:
on: push: branches: [main]: Trigger on every push to main.
jobs: Three jobs: test, build, deploy.
test job: Checks out code, installs Node, runs tests and lint. If any step fails, the workflow stops.
needs: test: The build job only runs if the test job succeeds.
build job: Builds a Docker image and pushes to Docker Hub. Tags with the commit SHA (git hash) for traceability.
needs: build: The deploy job only runs if the build succeeds.
deploy job: SSHes into your production server and runs Docker commands to pull and run the new image.
Step 2: Set Up Secrets
The workflow references secrets like DOCKER_USERNAME, SERVER_HOST, etc. Add these in GitHub:
- Go to your repository on GitHub.com
- Settings → Secrets and variables → Actions
- Add these secrets:
- DOCKER_USERNAME (your Docker Hub username)
- DOCKER_PASSWORD (your Docker Hub password or token)
- SERVER_HOST (your server’s IP or hostname)
- SERVER_USER (SSH user, usually ‘root’ or ‘ubuntu’)
- SERVER_SSH_KEY (your private SSH key to connect to the server)
- DATABASE_URL (production database connection string)
- API_KEY (any production API keys)
GitHub encrypts these. They’re not visible in logs or the workflow file.
Step 3: Test It
Commit and push the workflow file:
git add .github/workflows/deploy.yml
git commit -m "Add CI/CD pipeline"
git push origin main
Go to Actions in GitHub. You’ll see the workflow running. Click on it to watch the logs.
If tests fail, the build and deploy jobs won’t run. Fix the code, push again, it retries.
If everything succeeds, your code is live.
Different Pipeline Strategies
Not everyone deploys on every successful test. Here are common alternatives:
Test on Every PR, Deploy on Release
Run CI on every push and pull request, but deploy only when you tag a release.
on:
push:
branches:
- main
pull_request:
branches:
- main
jobs:
test:
# ... (same as before)
deploy:
if: startsWith(github.ref, 'refs/tags/') # Only run on tags
needs: test
runs-on: ubuntu-latest
# ... (deploy steps)
Now deployments only happen when you do: git tag v1.0.0 && git push –tags
Staging and Production
Deploy to staging automatically, production manually.
jobs:
test:
# ... (same as before)
deploy-staging:
needs: test
runs-on: ubuntu-latest
steps:
- name: Deploy to staging
run: |
# Deploy to staging server
deploy-production:
if: github.ref == 'refs/heads/main'
needs: deploy-staging
runs-on: ubuntu-latest
environment: production # Requires approval
steps:
- name: Deploy to production
run: |
# Deploy to production server
The `environment: production` requires manual approval before deploying.
Multiple Environments
Deploy to different servers based on the branch:
jobs:
deploy:
needs: test
runs-on: ubuntu-latest
steps:
- name: Deploy to staging
if: github.ref == 'refs/heads/develop'
run: |
# Deploy to staging
- name: Deploy to production
if: github.ref == 'refs/heads/main'
run: |
# Deploy to production
What Should Your Pipeline Actually Check?
Not just tests. Here’s a comprehensive checklist:
jobs:
quality:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: '18'
- name: Install dependencies
run: npm install
- name: Run unit tests
run: npm test
- name: Run E2E tests
run: npm run test:e2e
- name: Lint code
run: npm run lint
- name: Check types (TypeScript)
run: npm run type-check
- name: Check security vulnerabilities
run: npm audit
- name: Check code coverage
run: npm run coverage
- name: Fail if coverage below threshold
run: if [ $(cat coverage/coverage-summary.json | grep linesCovered | awk -F':' '{print $2}' | tr -d ',}' | tr -d ' ') -lt 80 ]; then exit 1; fi
Catch everything before deploying: broken code, security issues, type errors, low coverage.
Monitoring and Rollbacks
Deployments can fail. The pipeline should handle rollbacks.
deploy:
needs: test
runs-on: ubuntu-latest
steps:
- name: Deploy
run: |
# Deploy new version
docker pull ${{ secrets.DOCKER_USERNAME }}/my-app:${{ github.sha }}
docker run -d --name my-app-new ...
- name: Health check
run: |
# Wait for app to be healthy
for i in {1..30}; do
curl -f http://localhost/health && exit 0
sleep 2
done
exit 1
- name: Switch traffic
run: |
# If health check passes, switch old to new
docker stop my-app-old || true
docker rename my-app-new my-app
- name: Rollback on failure
if: failure()
run: |
# If anything failed, restart old version
docker stop my-app || true
docker run -d --name my-app ...
This ensures every deploy checks health before switching. If the new version fails, the old version stays up.
GitHub Actions Alternatives
GitHub Actions is free and simple, but other options exist:
GitLab CI/CD
If you use GitLab, CI/CD is built-in. Works similarly to GitHub Actions.
CircleCI
Popular for its reliability and extensive integrations. Free tier available.
Jenkins
Self-hosted, powerful, complex. Good if you need complete control but requires managing servers.
Travis CI, Buildkite, etc.
Specialized CI/CD platforms. Useful for specific workflows but usually paid.
For most teams, GitHub Actions is the sweet spot: free, integrated, and capable.
Common Pipeline Mistakes
Not failing on test failures. If a test fails but the pipeline continues, you’ll deploy broken code. Make sure failures actually stop the pipeline.
Slow tests blocking deployment. If tests take 30 minutes, developers will push code without waiting. Keep tests fast (under 5 minutes). Move slow tests to nightly builds.
Flaky tests. Tests that pass sometimes and fail sometimes are worse than useless. They erode trust. Fix flaky tests immediately.
Not monitoring deployments. Pipeline succeeded ≠ production is working. Add health checks and monitoring. Alert if production goes down.
No way to rollback. If a deployment breaks production, you need a one-click rollback. Build this in from day one.
FAQ
How often should I run the pipeline?
On every push. This catches problems immediately. Some teams also run nightly builds for deeper testing (performance, security).
What if I need to deploy without tests?
You can manually trigger the deploy job in GitHub Actions. But if you’re doing this regularly, something is wrong with your pipeline or tests.
Can I test multiple Node versions?
Yes. Use a matrix strategy:
test:
strategy:
matrix:
node-version: [16, 18, 20]
runs-on: ubuntu-latest
steps:
- uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node-version }}
# ... run tests
Now tests run on Node 16, 18, and 20 in parallel.
How do I test database migrations?
Spin up a test database in the pipeline:
services:
postgres:
image: postgres:14
env:
POSTGRES_PASSWORD: test
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- name: Run migrations
env:
DATABASE_URL: postgres://postgres:test@localhost/test
run: npm run migrate
- name: Run tests
env:
DATABASE_URL: postgres://postgres:test@localhost/test
run: npm test
How do I handle secrets in pull requests?
Secrets aren’t available in PRs from forks (for security). Use `if: github.event_name == ‘push’` to skip secret-dependent steps in PRs.
What should my build time be?
Under 10 minutes is good. Under 5 is excellent. If builds take 30+ minutes, developers will skip them or push without waiting. Optimize: parallelize tests, use caching, remove unnecessary steps.
How do I know if deployment succeeded?
Add monitoring. Send metrics to DataDog, New Relic, or Sentry. Alert if error rates spike after deploy. Check application logs. A green build doesn’t mean production is healthy.
CI/CD isn’t magic. It’s automation. You define the steps once (test, build, deploy), and they run the same way every time. No human error. No forgotten tests. No 3am deploys gone wrong. Once you have CI/CD working, shipping code stops being scary. It becomes boring. And that’s the goal—boring deploys that just work.