Set Up CI/CD Once, Deploy Without Fear Forever

TL;DR

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:

  1. Write code
  2. Push to GitHub
  3. Manually run tests on your machine
  4. Cross fingers nothing breaks in production
  5. 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:

  1. Write code
  2. Push to GitHub
  3. CI automatically runs tests
  4. If tests pass, CD automatically deploys
  5. 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:

  1. Go to your repository on GitHub.com
  2. Settings → Secrets and variables → Actions
  3. 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.

Facebook
Twitter
LinkedIn
Pinterest

Leave a Reply

Your email address will not be published. Required fields are marked *

DevelopersCodex

Real-world dev tutorials. No fluff, no filler.

© 2026 DevelopersCodex. All rights reserved.