Build a Complete CI/CD Pipeline Using GitHub Actions (Step-by-Step)
What We're Building
A real, production-grade GitHub Actions pipeline with 5 stages: Build โ Unit Test โ Docker Build + Push โ Deploy to Staging โ Manual Gate โ Deploy to Production. The same pattern I run across 12 microservices at my current company. No tutorial shortcuts.
Prerequisites
- GitHub account + a repository with a Node.js/Python/any app
- Docker Hub or AWS ECR account (for image push)
- An EC2 instance or server to deploy to (or just Staging = EC2, Prod = manual)
Stage 1: The Workflow File Structure
Create .github/workflows/ci-cd.yml in your repository root.
name: CI/CD Pipeline
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
env:
REGISTRY: docker.io
IMAGE_NAME: yourusername/yourapp
EC2_HOST: ${{ secrets.EC2_HOST }}
jobs:
build-and-test:
name: Build & Test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run unit tests
run: npm test
- name: Run linting
run: npm run lint
Stage 2: Docker Build + Security Scan
docker-build-push:
name: Docker Build & Push
needs: build-and-test
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
outputs:
image-tag: ${{ steps.meta.outputs.tags }}
steps:
- uses: actions/checkout@v4
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.IMAGE_NAME }}
tags: |
type=sha,prefix=sha-
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Run Trivy vulnerability scan
uses: aquasecurity/trivy-action@master
with:
image-ref: ${{ env.IMAGE_NAME }}:latest
format: 'sarif'
output: 'trivy-results.sarif'
exit-code: '1'
severity: 'CRITICAL,HIGH'
Stage 3: Deploy to Staging
deploy-staging:
name: Deploy to Staging
needs: docker-build-push
runs-on: ubuntu-latest
environment: staging
steps:
- name: Deploy to staging via SSH
uses: appleboy/[email protected]
with:
host: ${{ secrets.STAGING_HOST }}
username: ubuntu
key: ${{ secrets.SSH_PRIVATE_KEY }}
script: |
docker pull ${{ env.IMAGE_NAME }}:latest
docker stop app-staging || true
docker rm app-staging || true
docker run -d \
--name app-staging \
--env-file /home/ubuntu/.env.staging \
-p 3000:3000 \
--restart unless-stopped \
${{ env.IMAGE_NAME }}:latest
docker system prune -f
Stage 4: Manual Gate + Production Deploy
deploy-production:
name: Deploy to Production
needs: deploy-staging
runs-on: ubuntu-latest
environment:
name: production # This requires manual approval in GitHub Environments
steps:
- name: Deploy to production
uses: appleboy/[email protected]
with:
host: ${{ secrets.PROD_HOST }}
username: ubuntu
key: ${{ secrets.SSH_PRIVATE_KEY }}
script: |
docker pull ${{ env.IMAGE_NAME }}:latest
docker stop app-prod || true && docker rm app-prod || true
docker run -d \
--name app-prod \
--env-file /home/ubuntu/.env.prod \
-p 80:3000 \
--restart always \
${{ env.IMAGE_NAME }}:latest
Setting Up GitHub Environments for Manual Approval
Go to your repo โ Settings โ Environments โ Create "production" โ Add required reviewers. When a deploy hits this stage, GitHub will pause and wait for a human to approve. This is the manual gate that prevents accidental production deployments.
Secrets to Configure
DOCKERHUB_USERNAMEandDOCKERHUB_TOKENSSH_PRIVATE_KEYโ the private key to SSH into your serversSTAGING_HOSTandPROD_HOSTโ IP addresses of your servers
/health endpoint and fail the pipeline if it returns anything other than 200. Silent failed deploys are the worst kind.