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.

A pipeline moving code through test, build and deploy

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 ci installs exactly what the lockfile says, which makes builds repeatable.
  • cache: npm reuses downloaded packages and speeds runs up.
  • permissions: contents read gives 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: test deploys only if tests pass.
  • The if limits deploys to the main branch.
  • environment: production can 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 concurrency so 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.

Keep reading