Building a Full CI/CD Pipeline

Extending a workflow into build, test and deploy stages with secrets and branch-based deployment.

From "CI" to "CI/CD"

The previous page built a workflow that builds and tests on every push and PR — that's Continuous Integration. Turning it into a full CI/CD pipeline means adding a deploy stage that only runs for changes that are both fully tested and on the right branch. This page extends that same workflow with build, test, and deploy stages wired together with needs, secrets, and a branch guard.

Structuring the pipeline as separate jobs

Splitting build, test, and deploy into separate jobs (rather than one long job) gives clearer status reporting in the GitHub UI, lets independent jobs run in parallel, and lets you gate the risky stage (deploy) behind the safe ones succeeding first:

YAML
# .github/workflows/ci-cd.yml
name: CI/CD

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

jobs:
  build:
    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
      - name: Upload build artifact
        uses: actions/upload-artifact@v4
        with:
          name: dist
          path: dist/

  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

  deploy:
    needs: [build, test]
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'
    runs-on: ubuntu-latest
    environment: production
    steps:
      - name: Download build artifact
        uses: actions/download-artifact@v4
        with:
          name: dist
          path: dist/

      - name: Deploy over SSH
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.DEPLOY_HOST }}
          username: ${{ secrets.DEPLOY_USER }}
          key: ${{ secrets.DEPLOY_SSH_KEY }}
          script: |
            cd /var/www/myapp
            git pull origin main
            npm ci --omit=dev
            pm2 restart myapp

Why deploy is gated the way it is

YAML
deploy:
  needs: [build, test]
  if: github.ref == 'refs/heads/main' && github.event_name == 'push'
  • needs: [build, test] — the deploy job will not even start unless both the build and test jobs succeeded. A failing test stops the pipeline dead before anything touches production.
  • if: github.ref == 'refs/heads/main' && github.event_name == 'push' — this is the branch-based deployment strategy: only a direct push to main triggers a deploy. A pull request from a feature branch still runs build and test (so contributors get fast feedback), but never reaches deploy — you don't want every PR from every branch deploying to production.

This pattern — feature branches and PRs get full CI, only main gets CD — is the most common branch strategy in real teams, whether or not they also use pull-request-per-feature workflows or trunk-based development.

Secrets

Hardcoding a deploy password or private key into a workflow file is a serious security mistake — the file is committed to the repository and visible to anyone with read access. Instead, secrets are stored encrypted in the repository (or organization) settings under Settings → Secrets and variables → Actions, and referenced in the workflow as ${{ secrets.NAME }}:

YAML
key: ${{ secrets.DEPLOY_SSH_KEY }}

GitHub Actions masks any secret value that appears in log output automatically, and secrets are never exposed to workflows triggered from a fork's pull request by default — a deliberate protection against a malicious PR trying to exfiltrate your production credentials.

The environment: production key on the deploy job additionally lets you configure required reviewers in GitHub's environment settings — turning this from pure Continuous Deployment into Continuous Delivery, where a human still approves the actual production push even though everything up to that point is fully automated.

An alternative deploy stage: pushing a Docker image

Many teams deploy by building and pushing a container image rather than SSHing into a server:

YAML
  deploy:
    needs: [build, test]
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Log in to Docker Hub
        uses: docker/login-action@v3
        with:
          username: ${{ secrets.DOCKERHUB_USERNAME }}
          password: ${{ secrets.DOCKERHUB_TOKEN }}

      - name: Build and push image
        uses: docker/build-push-action@v6
        with:
          context: .
          push: true
          tags: myorg/myapp:latest,myorg/myapp:${{ github.sha }}

Tagging the image with both latest and the commit SHA (github.sha) is a common practice: latest is convenient, but the SHA tag gives you an exact, immutable reference to roll back to if the new deploy misbehaves.

Common mistakes

  • Deploying directly from a pull request trigger without a branch guard — this lets untrusted, unreviewed code reach production.
  • Storing secrets as plain workflow-file environment variables instead of GitHub's encrypted secrets store.
  • Skipping the needs dependency so deploy can start even if test is still running or has failed.
  • No rollback plan — tagging every deployed image or artifact with an immutable identifier (a commit SHA) is what makes "redeploy the previous version" possible in an emergency.