CI/CD with GitHub Actions: test, build and deploy safely
CI/CD runs your tests and deploys automatically on every change. Here is a clear first workflow and the safety rules that matter.

CI (continuous integration) means every change is automatically built and tested. CD (continuous delivery or deployment) means a passing change can be released automatically. Together they catch bugs early and make releases boring, which is the goal.
GitHub Actions runs these pipelines inside your repository, with no separate server to manage.
The pieces
- Workflow: a YAML file in
.github/workflows/. - Event (
on): what starts it, such as a push, a pull request, a schedule or a manual run. - Job: a group of steps that runs on one machine (a runner). Jobs run in parallel unless you chain them with
needs. - Step: one command or one reusable action.
A first workflow: test and build
name: CI
on:
push:
branches: [main]
pull_request:
permissions:
contents: read
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 test
- run: npm run build
Notes:
npm ciinstalls exactly what the lockfile says, which makes builds repeatable.cache: npmreuses downloaded packages and speeds runs up.permissions: contents readgives the job only the access it needs.- Pull requests run the same checks, so a broken change is visible before merge. Use branch protection to require the check to pass.
Secrets
Store passwords, tokens and keys in Settings, Secrets and variables, Actions, and read them as ${{ secrets.NAME }}.
- Never write secrets in the YAML or commit them.
- Logs hide known secret values, but do not print them on purpose.
- Give tokens the minimum scope and rotate them.
- Workflows triggered by pull requests from forks do not receive your secrets by default. Keep it that way.
A careful deploy job
deploy:
needs: test
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/checkout@v4
- name: Deploy over SSH
run: ./scripts/deploy.sh
env:
DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }}
needs: testdeploys only if tests pass.- The
iflimits deploys to the main branch. environment: productioncan require a manual approval and holds its own secrets.- Make the deploy script repeatable and reversible, with a health check and a quick way to roll back.
Good habits
- Pin third-party actions to a version, and for sensitive pipelines to a commit SHA, because an action is code that runs with your secrets nearby.
- Keep workflows fast: cache dependencies and run independent jobs in parallel.
- Use
concurrencyso two deploys do not run at once. - Treat pull request titles, branch names and issue text as untrusted input. Do not paste them directly into shell commands.
- Notify on failure and fix a red build quickly. A pipeline everyone ignores is useless.
Common mistakes
- Putting secrets in the repo or in logs.
- Giving the workflow broad write permissions it does not need.
- Deploying from every branch.
- No tests, so the pipeline only proves the code compiles.
Takeaway: start with a workflow that installs, tests and builds on every push and pull request, add a deploy job gated by passing tests and the main branch, keep secrets in the secret store, and give the workflow the least permission it needs.
Linux & DevOps series, Part 2 (delivery and operations): 2 of 5. Previous: Kubernetes basics: pods, deployments and services.


