Skip to content

DevOps

How to set up a CI/CD pipeline with GitHub Actions, step by step

Build a working CI pipeline that tests every push and a CD step that deploys to a server over SSH, with caching, secrets, protected environments and rollback.

By · Published · 3 min read

Short answer: CI (continuous integration) runs your tests and checks automatically on every push and pull request. CD (continuous delivery or deployment) then ships a passing build to a server. With GitHub Actions you need one YAML file in .github/workflows/: a job to install, test and build, and a second job that deploys only from the main branch.

What does CI do?

It answers one question quickly: does the code still work? On every change a clean machine checks out the code, installs dependencies, runs the linter, type checker and tests, and builds. If any step fails, the change is marked red and should not be merged.

A working CI workflow

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 run lint
      - run: npm run typecheck
      - run: npm test
      - run: npm run build

Pin action versions as above. cache: npm stores the dependency cache between runs and often halves the time. Use npm ci, not npm install, so the lockfile is respected.

How do you test against a real database?

Run PostgreSQL as a service container in the job.

    services:
      postgres:
        image: postgres:16
        env: { POSTGRES_PASSWORD: test }
        ports: ["5432:5432"]
        options: >-
          --health-cmd "pg_isready -U postgres"
          --health-interval 5s --health-retries 10
    env:
      DATABASE_URL: postgres://postgres:test@localhost:5432/postgres

Tests that touch a real database catch problems that mocks hide, such as migrations that do not apply or queries that fail under row-level security.

How do you deploy from it?

A simple, reliable pattern for a single server: build, then connect over SSH and run the update commands. Keep the deploy job separate and gate it on CI passing and on the main branch.

  deploy:
    needs: test
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    environment: production
    steps:
      - uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.DEPLOY_HOST }}
          username: deploy
          key: ${{ secrets.DEPLOY_KEY }}
          script: |
            cd /srv/app
            git pull --ff-only
            docker compose build
            docker compose up -d
            docker image prune -f

How should you handle secrets?

  • Store them in GitHub secrets, scoped to an environment called production so only that job can read them. Environments can also require a manual approval before the job runs.
  • Create a dedicated deploy user on the server with a key used only for this, and restrict what that user can do.
  • Never echo secrets in logs. Be careful with set -x in scripts.
  • Do not run untrusted pull request code with access to secrets. Workflows on pull_request from forks do not receive secrets by default, which is correct. Keep it that way.

How do you roll back?

  • Tag images by commit SHA instead of reusing latest, so you can redeploy the previous one.
  • Keep the last few releases on disk or in a registry.
  • Make database migrations backward compatible when you can: add columns first, switch code, remove columns in a later release.
  • Take a backup before deploys that include migrations.

What are the common mistakes?

  • Slow pipelines nobody wants to wait for. Cache dependencies and run jobs in parallel.
  • Flaky tests that people learn to ignore. Fix or delete them.
  • Deploying without a health check. After up -d, curl a health endpoint and fail the job if it does not respond.
  • One giant job. Split lint, test and deploy so failures are easy to read.
  • No branch protection. Require passing checks before merging to main.

Start with the CI half. Deploying by hand with a documented command is better than a fragile pipeline. Automate the deploy when you can trust it, and test the restore path the same way, as in the restore drill.

References

Author

Raktim Ranjit is a software engineer and the founder of NodeDR Infotech. He builds and maintains the software described here.

Have something in mind?

Let’s build something useful.

Tell me about the idea, product, or workflow you’re working through.

Tap to say hello