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:
# .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
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 thebuildandtestjobs 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 tomaintriggers a deploy. A pull request from a feature branch still runsbuildandtest(so contributors get fast feedback), but never reachesdeploy— 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 }}:
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:
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
needsdependency sodeploycan start even iftestis 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.