Build a complete CI/CD pipeline — run tests on every PR, build a Docker image on merge, push to ECR using OIDC (no long-lived keys), and deploy to EC2 with a health-checked zero-downtime script.
Every manual deployment is a potential incident. Someone forgets to run the build step, deploys the wrong branch, skips the migration, or pushes untested code on a Friday evening. CI/CD — Continuous Integration and Continuous Deployment — automates the entire path from a merged pull request to running production code. The pipeline catches failures before they reach users and makes deployment so routine that it loses its fear factor.
This guide builds a complete GitHub Actions CI/CD pipeline for a Node.js application: automated testing and linting on every pull request, Docker image build and push to Amazon ECR on merge to main, and zero-downtime deployment to EC2 via SSH. By the end, every git push to main automatically ships to production — with full test coverage as the gate.
| Stage | Trigger | Jobs | Time |
|---|---|---|---|
| CI | Every PR + push to main | lint, typecheck, test | ~2–3 min |
| Build | Push to main (CI passes) | docker build, push to ECR | ~3–5 min |
| Deploy | Build succeeds | SSH to EC2, pull image, restart | ~1–2 min |
| Total | Merge PR → live | All three stages | ~6–10 min |
your-app/
├── .github/
│ └── workflows/
│ ├── ci.yml # runs on every PR
│ └── deploy.yml # runs on merge to main
├── Dockerfile
├── .dockerignore
├── package.json
└── src/# Stage 1: Build
FROM node:22-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Stage 2: Production image
FROM node:22-alpine AS runner
RUN addgroup -g 1001 -S nodejs && adduser -S nodeapp -u 1001
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production && npm cache clean --force
COPY --from=builder --chown=nodeapp:nodejs /app/dist ./dist
USER nodeapp
EXPOSE 3000
CMD ["node", "dist/server.js"]node_modules
.git
.github
*.md
.env*
coverage/
dist/
.DS_Storename: CI
on:
pull_request:
branches: [main]
push:
branches: [main]
jobs:
ci:
name: Lint, Typecheck, Test
runs-on: ubuntu-latest
services:
# Spin up a Postgres container for integration tests
postgres:
image: postgres:16-alpine
env:
POSTGRES_USER: testuser
POSTGRES_PASSWORD: testpass
POSTGRES_DB: testdb
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
env:
DATABASE_URL: postgresql://testuser:testpass@localhost:5432/testdb
NODE_ENV: test
JWT_ACCESS_SECRET: test-access-secret-min-32-chars-long
JWT_REFRESH_SECRET: test-refresh-secret-min-32-chars-long
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm' # caches node_modules between runs
- name: Install dependencies
run: npm ci
- name: Lint
run: npm run lint
- name: Type check
run: npx tsc --noEmit
- name: Run migrations (for integration tests)
run: npx prisma migrate deploy
# or: npx drizzle-kit migrate
- name: Run tests
run: npm test -- --coverage
- name: Upload coverage report
uses: actions/upload-artifact@v4
if: always()
with:
name: coverage-report
path: coverage/
retention-days: 7Amazon Elastic Container Registry (ECR) is a private Docker registry that integrates natively with AWS. Each account gets 500MB of free storage per month. Creating a repository and pushing images requires AWS credentials — we will use GitHub's OIDC integration to avoid storing long-lived AWS keys in GitHub Secrets.
# Create the repository
aws ecr create-repository --repository-name my-app --region ap-south-1 --image-scanning-configuration scanOnPush=true
# Note the repository URI from the output:
# 123456789.dkr.ecr.ap-south-1.amazonaws.com/my-appGitHub Actions supports OpenID Connect (OIDC) — GitHub issues a short-lived token that AWS trusts, eliminating the need to store AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY in GitHub Secrets. The token is only valid for the duration of the workflow run.
# 1. Create the OIDC provider in AWS IAM (once per account)
aws iam create-open-id-connect-provider --url https://token.actions.githubusercontent.com --client-id-list sts.amazonaws.com --thumbprint-list 6938fd4d98bab03faadb97b34396831e3780aea1
# 2. Create a trust policy file
cat > trust-policy.json << 'EOF'
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::YOUR_ACCOUNT_ID:oidc-provider/token.actions.githubusercontent.com"
},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
},
"StringLike": {
"token.actions.githubusercontent.com:sub": "repo:YOUR_GITHUB_USERNAME/YOUR_REPO:*"
}
}
}]
}
EOF
# 3. Create the IAM role
aws iam create-role --role-name github-actions-deploy --assume-role-policy-document file://trust-policy.json
# 4. Attach ECR push permissions
aws iam attach-role-policy --role-name github-actions-deploy --policy-arn arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryPowerUserGo to your GitHub repository → Settings → Secrets and variables → Actions → New repository secret. Add the following secrets:
| Secret Name | Value | Used By |
|---|---|---|
| AWS_ROLE_ARN | arn:aws:iam::123456789:role/github-actions-deploy | Deploy workflow (OIDC) |
| AWS_REGION | ap-south-1 | Deploy workflow |
| ECR_REPOSITORY | my-app | Deploy workflow |
| EC2_HOST | your-ec2-public-ip-or-domain | Deploy workflow |
| EC2_USERNAME | ubuntu | Deploy workflow |
| EC2_SSH_KEY | Contents of your .pem file | Deploy workflow |
name: Deploy
on:
push:
branches: [main]
# Cancel in-progress deploys if a new push arrives
concurrency:
group: deploy-production
cancel-in-progress: false # never cancel an in-progress deploy
jobs:
deploy:
name: Build and Deploy
runs-on: ubuntu-latest
# Require CI to pass first — reuse the ci.yml run
needs: [] # ci.yml runs separately; branch protection enforces it
permissions:
id-token: write # required for OIDC
contents: read
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Configure AWS credentials (OIDC — no long-lived keys)
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
aws-region: ${{ secrets.AWS_REGION }}
- name: Login to Amazon ECR
id: login-ecr
uses: aws-actions/amazon-ecr-login@v2
- name: Build, tag, and push Docker image to ECR
id: build-image
env:
ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
ECR_REPOSITORY: ${{ secrets.ECR_REPOSITORY }}
IMAGE_TAG: ${{ github.sha }}
run: |
docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG .
docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:latest .
docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG
docker push $ECR_REGISTRY/$ECR_REPOSITORY:latest
echo "image=$ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG" >> $GITHUB_OUTPUT
- name: Deploy to EC2 via SSH
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.EC2_HOST }}
username: ${{ secrets.EC2_USERNAME }}
key: ${{ secrets.EC2_SSH_KEY }}
envs: AWS_REGION,ECR_REGISTRY,ECR_REPOSITORY,IMAGE_TAG
script: |
# Login to ECR from EC2 (uses instance role)
aws ecr get-login-password --region $AWS_REGION | docker login --username AWS --password-stdin $ECR_REGISTRY
# Pull the new image
docker pull $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG
# Stop old container gracefully
docker stop my-app 2>/dev/null || true
docker rm my-app 2>/dev/null || true
# Start new container
docker run -d --name my-app --restart unless-stopped -p 3000:3000 -e NODE_ENV=production -e AWS_REGION=$AWS_REGION $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG
# Wait for health check
sleep 5
curl -f http://localhost:3000/health || exit 1
# Clean up old images (keep last 3)
docker image prune -f
echo "Deployment complete: $IMAGE_TAG"
env:
AWS_REGION: ${{ secrets.AWS_REGION }}
ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
ECR_REPOSITORY: ${{ secrets.ECR_REPOSITORY }}
IMAGE_TAG: ${{ github.sha }}The deploy script checks /health after starting the container. This endpoint should return 200 if the app is running correctly — checking the database connection is a useful signal.
import { Router } from 'express';
import { db } from '../lib/db';
const router = Router();
router.get('/health', async (req, res) => {
try {
// Verify database connectivity
await db.$queryRaw`SELECT 1`;
res.json({
status: 'ok',
timestamp: new Date().toISOString(),
version: process.env.npm_package_version,
});
} catch {
res.status(503).json({ status: 'error', message: 'Database unreachable' });
}
});
export default router;Branch protection rules enforce that CI passes before any code reaches main. Go to GitHub repository → Settings → Branches → Add rule for 'main':
- name: Notify Slack on failure
if: failure()
uses: slackapi/slack-github-action@v1
with:
payload: |
{
"text": ":red_circle: Deploy failed for *${{ github.repository }}*",
"blocks": [{
"type": "section",
"text": {
"type": "mrkdwn",
"text": ":red_circle: *Deploy failed*
*Repo:* ${{ github.repository }}
*Branch:* ${{ github.ref_name }}
*Commit:* ${{ github.sha }}
*Author:* ${{ github.actor }}
*<${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|View Run>*"
}
}]
}
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
SLACK_WEBHOOK_TYPE: INCOMING_WEBHOOK| Optimisation | Time Saved | How |
|---|---|---|
| npm cache | 40–50 sec | cache: 'npm' in setup-node |
| Docker layer cache | 1–3 min | Copy package.json before src — npm ci layer cached if no dep changes |
| Parallel jobs | Varies | Split lint / typecheck / test into parallel matrix jobs |
| Concurrency group | Queuing | Cancel superseded CI runs on the same PR |
| Self-hosted runner | ~50% faster | Use an EC2 instance as a GitHub runner for faster builds |
jobs:
lint:
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 run lint
typecheck:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '22', cache: 'npm' }
- run: npm ci
- run: npx tsc --noEmit
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16-alpine
# ... (same as before)
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '22', cache: 'npm' }
- run: npm ci && npm test
# Deploy only after all three pass
deploy:
needs: [lint, typecheck, test]
# ... (deploy job)How many GitHub Actions minutes does this pipeline use?
A typical run — CI (2 min) + Build (4 min) + Deploy (1 min) = 7 minutes per deploy. GitHub's free tier gives 2,000 minutes/month on public repos (unlimited) and private repos. A team of 5 merging 10 PRs/day uses ~70 minutes/day = ~1,500 minutes/month, staying within the free tier.
What is the rollback strategy if a deploy breaks production?
Since we tag images with the git SHA, rollback is: docker stop my-app && docker run ... ECR_REGISTRY/my-app:PREVIOUS_SHA. To make this fast, keep the previous 3 image tags in ECR. A better approach is a blue-green deployment (two containers, swap the Nginx upstream) — but that requires more infrastructure.
Should I use Docker or run Node.js directly on EC2?
Docker has advantages: reproducible builds (the image that passes CI is exactly what runs in production), easier rollback (just pull a previous tag), and portability (same image works on any machine). The tradeoff is added complexity and slightly more memory usage. For teams and production apps, Docker is worth it. For solo projects, PM2 directly on EC2 is simpler.
Can I use this pipeline with other cloud providers (GCP, Azure, DigitalOcean)?
Yes — the CI and Docker build steps are cloud-agnostic. The deploy step uses appleboy/ssh-action which works for any SSH-accessible server. Replace the ECR steps with GCR (Google Container Registry) or DOCR (DigitalOcean Container Registry) and update the login commands. The OIDC pattern for keyless auth is available on GCP and Azure too.
Key Takeaways
What did you think?
Join 15,000+ Indian developers and creators receiving our curated newsletter every Sunday morning.
No spam. Only high-quality content. Unsubscribe anytime.