# How to Set Up a GitHub Actions CI/CD Pipeline from Scratch

> Step-by-step guide to building a production-ready GitHub Actions CI/CD pipeline: install, test, build, and deploy automatically on every push.

- **Published:** 2025-06-01
- **Author:** Clixo
- **Reading time:** 4 min read
- **Tags:** ci-cd, github-actions, deployment, automation
- **Canonical URL:** https://clixo.sh/blog/how-to-set-up-github-actions-cicd-pipeline-from-scratch

Most teams manually deploy code far longer than they should. The process is slow, error-prone, and blocks everyone every time something goes wrong. GitHub Actions gives you a straightforward path to full automation — tests run on every push, builds are reproducible, and deployments happen without anyone touching a server.

This guide walks you through setting up a complete CI/CD pipeline with GitHub Actions from an empty workflow file to a production-grade release process.

## Setting Up a GitHub Actions CI/CD Pipeline: The Foundation

GitHub Actions organizes work into workflows — YAML files stored in `.github/workflows/`. Each workflow runs on a trigger (a push, a pull request, a schedule) and contains one or more jobs that execute on virtual runners.

Create your first workflow file at `.github/workflows/ci.yml`.

### Step 1: Define Your Trigger

```yaml
name: CI/CD Pipeline

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]
```

This runs the pipeline whenever code is pushed to `main` or a pull request targets `main`. Pull requests get CI feedback before merge; pushes to `main` trigger the full deploy path.

### Step 2: Run Tests in a Dedicated Job

```yaml
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: npm
      - run: npm ci
      - run: npm test
```

Always use `npm ci` instead of `npm install` in CI — it installs exactly what the lockfile specifies and fails if the lockfile is out of sync. Cache dependencies using the `cache` key on the setup action; this alone can cut 60-90 seconds off a typical Node.js job.

### Step 3: Build and Validate

Add a `build` job that depends on `test` passing:

```yaml
  build:
    needs: test
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: npm
      - run: npm ci
      - run: npm run build
      - uses: actions/upload-artifact@v4
        with:
          name: dist
          path: dist/
```

Upload the build artifact so the deploy job can download it instead of rebuilding. This keeps your artifacts reproducible — the exact same bytes that passed tests are the bytes that ship.

### Step 4: Deploy Only on Main

```yaml
  deploy:
    needs: build
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'
    environment: production
    steps:
      - uses: actions/download-artifact@v4
        with:
          name: dist
      - run: ./scripts/deploy.sh
        env:
          DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
```

The `if` condition is critical. Pull requests run `test` and `build` but never trigger `deploy`. Only a direct push to `main` reaches production. Secrets are stored in GitHub's encrypted secrets store and injected as environment variables at runtime — never hardcode tokens in workflow files.

## Managing Environment Variables Safely

Store all sensitive values in **Settings > Secrets and variables > Actions**. Reference them as `${{ secrets.MY_SECRET }}`. For non-sensitive configuration that differs per environment (API base URLs, feature flags), use **Variables** rather than Secrets so you can inspect them.

## Speeding Up the Pipeline

- **Cache aggressively.** Node modules, pip packages, Go module caches — cache all of them.
- **Parallelize independent jobs.** Tests and lint checks do not need to run sequentially.
- **Limit the trigger scope.** Use `paths` filters so a docs-only commit does not trigger a full deploy.
- **Set timeouts.** Add `timeout-minutes: 10` to jobs so a stuck test does not burn runner minutes indefinitely.

## What a Mature Pipeline Looks Like

A production pipeline typically has five stages:

1. **Lint** — enforce code style, fail fast
2. **Test** — unit and integration tests in parallel
3. **Security scan** — dependency audit, static analysis
4. **Build** — produce a reproducible artifact
5. **Deploy** — push the artifact, run smoke tests, notify on failure

Start with the middle three and add the outer two once the core flow is stable.

```mermaid
flowchart LR
  A["Push to main"] --> B[Lint]
  B --> C[Test]
  B --> D["Security scan"]
  C --> E["Build artifact"]
  D --> E
  E --> F[Deploy]
  F --> G["Smoke test"]
```

## Common First-Time Mistakes

- Using `npm install` instead of `npm ci` in CI
- Storing secrets in the repository or workflow file
- Deploying from feature branches instead of `main`
- Not caching dependencies, making every run slow
- Skipping a smoke test after deploy — you find out production is broken from users

Setting up a solid pipeline is a one-time investment that pays back on every subsequent release. If you want a team that can ship continuously without incidents, the pipeline is the foundation you build on.

[Talk to Clixo about building your deployment infrastructure](https://clixo.sh/#contact)

---

Clixo · 1141 W Bryn Mawr Ave, Itasca, IL 60143, US · [hello@clixo.sh](mailto:hello@clixo.sh)
[Start a build](https://clixo.sh/#contact) · [All services](https://clixo.sh/services) · [Agent guide (llms.txt)](https://clixo.sh/llms.txt)
