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 Raktim Ranjit · 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 buildPin 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/postgresTests 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 -fHow should you handle secrets?
- Store them in GitHub secrets, scoped to an environment called
productionso 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 -xin scripts. - Do not run untrusted pull request code with access to secrets. Workflows on
pull_requestfrom 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.