Back to blog

CI/CD Pipelines and DevOps Automation with GitHub Actions

9 min readBy Mustafa Akkaya
#CI/CD#GitHub Actions#DevOps#Automation#Docker#Deployment

CI/CD pipelines are essential for modern software development. Automated testing, building, and deployment save time and reduce human errors. Let's build a production-ready pipeline for a full-stack application using GitHub Actions.

Why CI/CD Matters

CI/CD provides significant benefits:

- Speed - Automate repetitive tasks, deploy faster

- Reliability - Consistent deployments reduce errors

- Quality - Automated testing catches issues early

- Collaboration - Better feedback for team members

- Monitoring - Track build history and failures

- Rollback - Quickly revert problematic deployments

GitHub Actions Basics

GitHub Actions is a built-in CI/CD platform integrated with GitHub.

Key Concepts

- Workflows - YAML files in .github/workflows/

- Jobs - Individual tasks in a workflow

- Steps - Commands within a job

- Actions - Reusable units of code

- Triggers - Events that start workflows (push, pull_request, etc.)

Basic Workflow Structure

yaml

# .github/workflows/ci.yml

name: CI

# Trigger on push to main and pull requests

on:

push:

branches: [main, develop]

pull_request:

branches: [main, develop]

env:

NODE_VERSION: "20"

jobs:

test:

runs-on: ubuntu-latest

strategy:

matrix:

node-version: [18.x, 20.x]

steps:

# Check out code

- uses: actions/checkout@v4

# Setup Node.js

- uses: actions/setup-node@v4

with:

node-version: ${{ matrix.node-version }}

cache: "npm"

# Install dependencies

- name: Install dependencies

run: npm ci

# Lint code

- name: Lint

run: npm run lint

# Run tests

- name: Run tests

run: npm run test:ci

# Build project

- name: Build

run: npm run build

# Upload coverage

- name: Upload coverage

uses: codecov/codecov-action@v3

with:

file: ./coverage/coverage-final.json

Full-Stack CI/CD Pipeline

Comprehensive pipeline for Next.js full-stack app.

yaml

# .github/workflows/full-stack-ci-cd.yml

name: Full-Stack CI/CD

on:

push:

branches: [main, develop]

pull_request:

branches: [main, develop]

workflow_dispatch:

env:

REGISTRY: ghcr.io

IMAGE_NAME: ${{ github.repository }}

permissions:

contents: read

packages: write

jobs:

# Quality checks

quality:

name: Code Quality

runs-on: ubuntu-latest

steps:

- uses: actions/checkout@v4

- uses: actions/setup-node@v4

with:

node-version: "20"

cache: "npm"

- run: npm ci

- name: Lint TypeScript

run: npm run lint

- name: Type checking

run: npm run type-check

- name: Format check

run: npm run format:check

# Unit & Integration Tests

test:

name: Tests

runs-on: ubuntu-latest

services:

postgres:

image: postgres:16

env:

POSTGRES_PASSWORD: postgres

POSTGRES_DB: test_db

options: >-

--health-cmd pg_isready

--health-interval 10s

--health-timeout 5s

--health-retries 5

ports:

- 5432:5432

redis:

image: redis:7-alpine

options: >-

--health-cmd "redis-cli ping"

--health-interval 10s

--health-timeout 5s

--health-retries 5

ports:

- 6379:6379

steps:

- uses: actions/checkout@v4

- uses: actions/setup-node@v4

with:

node-version: "20"

cache: "npm"

- run: npm ci

- name: Setup database

env:

DATABASE_URL: postgresql://postgres:postgres@localhost:5432/test_db

run: |

npm run db:migrate

npm run db:seed

- name: Run unit tests

run: npm run test:unit

env:

DATABASE_URL: postgresql://postgres:postgres@localhost:5432/test_db

REDIS_URL: redis://localhost:6379

- name: Run integration tests

run: npm run test:integration

env:

DATABASE_URL: postgresql://postgres:postgres@localhost:5432/test_db

REDIS_URL: redis://localhost:6379

- name: Run e2e tests

run: npm run test:e2e

env:

DATABASE_URL: postgresql://postgres:postgres@localhost:5432/test_db

BASE_URL: http://localhost:3000

- name: Upload coverage

uses: codecov/codecov-action@v3

if: always()

with:

flags: unittests

name: codecov-umbrella

# Build Docker image

build:

name: Build Docker Image

needs: [quality, test]

runs-on: ubuntu-latest

outputs:

image: ${{ steps.image.outputs.image }}

tags: ${{ steps.meta.outputs.tags }}

digest: ${{ steps.build.outputs.digest }}

steps:

- uses: actions/checkout@v4

- uses: docker/setup-qemu-action@v3

- uses: docker/setup-buildx-action@v3

- uses: docker/login-action@v3

with:

registry: ${{ env.REGISTRY }}

username: ${{ github.actor }}

password: ${{ secrets.GITHUB_TOKEN }}

- name: Extract metadata

id: meta

uses: docker/metadata-action@v5

with:

images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}

tags: |

type=ref,event=branch

type=semver,pattern={{version}}

type=semver,pattern={{major}}.{{minor}}

type=sha

- name: Build and push

id: build

uses: docker/build-push-action@v5

with:

context: .

push: ${{ github.event_name != 'pull_request' }}

tags: ${{ steps.meta.outputs.tags }}

labels: ${{ steps.meta.outputs.labels }}

cache-from: type=gha

cache-to: type=gha,mode=max

- name: Set image output

id: image

run: echo "image=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}" >> $GITHUB_OUTPUT

# Security scanning

security:

name: Security Scan

runs-on: ubuntu-latest

steps:

- uses: actions/checkout@v4

- uses: actions/setup-node@v4

with:

node-version: "20"

cache: "npm"

- run: npm ci

- name: Run npm audit

run: npm audit --audit-level=moderate

continue-on-error: true

- name: Dependency scanning

uses: github/super-linter@v5

env:

DEFAULT_BRANCH: main

GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

# Deploy to staging

deploy-staging:

name: Deploy to Staging

needs: [build, security]

runs-on: ubuntu-latest

if: github.event_name == 'push' && github.ref == 'refs/heads/develop'

environment:

name: staging

url: https://staging.akkaya.dev

steps:

- uses: actions/checkout@v4

- name: Deploy to staging

env:

VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}

VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}

VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID_STAGING }}

run: |

npm i -g vercel

vercel pull --yes --environment=preview

vercel build --prod

vercel deploy --prebuilt --token=${{ secrets.VERCEL_TOKEN }}

- name: Notify deployment

uses: 8398a7/action-slack@v3

if: always()

with:

status: ${{ job.status }}

text: "Staging deployment ${{ job.status }}"

webhook_url: ${{ secrets.SLACK_WEBHOOK }}

# Deploy to production

deploy-production:

name: Deploy to Production

needs: [build, security]

runs-on: ubuntu-latest

if: github.event_name == 'push' && github.ref == 'refs/heads/main'

environment:

name: production

url: https://akkaya.dev

steps:

- uses: actions/checkout@v4

- name: Create deployment

uses: actions/github-script@v7

id: deployment

with:

script: |

const deployment = await github.rest.repos.createDeployment({

owner: context.repo.owner,

repo: context.repo.repo,

ref: context.sha,

environment: 'production',

required_contexts: [],

auto_merge: false

})

return deployment.data.id

- name: Deploy to production

env:

VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}

VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}

VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}

run: |

npm i -g vercel

vercel pull --yes --environment=production

vercel build --prod

vercel deploy --prod --token=${{ secrets.VERCEL_TOKEN }}

- name: Update deployment status

uses: actions/github-script@v7

if: always()

with:

script: |

github.rest.repos.createDeploymentStatus({

owner: context.repo.owner,

repo: context.repo.repo,

deployment_id: ${{ steps.deployment.outputs.result }},

state: '${{ job.status }}',

environment_url: 'https://akkaya.dev'

})

- name: Notify Slack

uses: 8398a7/action-slack@v3

if: always()

with:

status: ${{ job.status }}

text: "Production deployment ${{ job.status }}"

webhook_url: ${{ secrets.SLACK_WEBHOOK }}

Dockerfile for Next.js

Optimized multi-stage Docker build.

dockerfile

# Dockerfile

FROM node:20-alpine AS deps

WORKDIR /app

COPY package.json package-lock.json ./

RUN npm ci --only=production

FROM node:20-alpine AS builder

WORKDIR /app

COPY package.json package-lock.json ./

RUN npm ci

COPY . .

RUN npm run build

FROM node:20-alpine AS runtime

WORKDIR /app

ENV NODE_ENV production

RUN addgroup -g 1001 -S nodejs && \

adduser -S nextjs -u 1001

COPY --from=builder /app/public ./public

COPY --from=builder /app/.next/standalone ./

COPY --from=builder /app/.next/static ./.next/static

USER nextjs

EXPOSE 3000

ENV PORT 3000

CMD ["node", "server.js"]

Environment Secrets Management

Securely manage secrets in GitHub Actions.

yaml

# .github/workflows/secure-deployment.yml

name: Secure Deployment

on:

push:

branches: [main]

jobs:

deploy:

runs-on: ubuntu-latest

environment:

name: production

url: https://akkaya.dev

steps:

- uses: actions/checkout@v4

- name: Deploy with secrets

env:

# Organization secrets (available to all repos)

VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}

# Repository secrets

DATABASE_URL: ${{ secrets.DATABASE_URL }}

JWT_SECRET: ${{ secrets.JWT_SECRET }}

# Environment secrets (per environment)

API_KEY: ${{ secrets.API_KEY }}

STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY }}

run: |

npm run build

npm run deploy

- name: Create issue on failure

if: failure()

uses: actions/github-script@v7

with:

script: |

github.rest.issues.create({

owner: context.repo.owner,

repo: context.repo.repo,

title: Deployment failed on ${new Date().toISOString()},

body: Workflow: ${context.workflow}\nRun: ${context.runId}

})

Matrix Testing

Test across multiple configurations.

yaml

# .github/workflows/matrix-testing.yml

name: Matrix Testing

on: [push, pull_request]

jobs:

test:

runs-on: ${{ matrix.os }}

strategy:

matrix:

os: [ubuntu-latest, macos-latest, windows-latest]

node-version: [18.x, 20.x]

database: [postgres, mysql, mariadb]

exclude:

# Don't test MySQL on macOS

- os: macos-latest

database: mysql

steps:

- uses: actions/checkout@v4

- uses: actions/setup-node@v4

with:

node-version: ${{ matrix.node-version }}

- name: Run tests on ${{ matrix.os }}

run: npm run test

env:

DB_TYPE: ${{ matrix.database }}

Caching Dependencies

Speed up workflows with caching.

yaml

name: Fast CI

on: [push, pull_request]

jobs:

build:

runs-on: ubuntu-latest

steps:

- uses: actions/checkout@v4

- uses: actions/setup-node@v4

with:

node-version: "20"

# Automatically handles npm caching

cache: "npm"

# Cache Prisma generated files

- uses: actions/cache@v3

with:

path: |

node_modules/.prisma/client

key: ${{ runner.os }}-prisma-${{ hashFiles('**/package-lock.json') }}

# Cache build output

- uses: actions/cache@v3

with:

path: .next/cache

key: ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}

- run: npm ci

- run: npm run build

Performance Monitoring

Track workflow performance.

yaml

name: Performance Monitoring

on: [push]

jobs:

monitor:

runs-on: ubuntu-latest

steps:

- uses: actions/checkout@v4

- uses: actions/setup-node@v4

with:

node-version: "20"

cache: "npm"

- name: Install dependencies

run: npm ci

- name: Run Lighthouse CI

uses: treosh/lighthouse-ci-action@v9

with:

configPath: "./lighthouserc.json"

uploadArtifacts: true

- name: Performance report

run: npm run analyze:build

- name: Upload bundle analysis

uses: actions/upload-artifact@v3

with:

name: bundle-analysis

path: .next/analyze

Release Management

Automate versioning and releases.

yaml

# .github/workflows/release.yml

name: Release

on:

push:

branches: [main]

permissions:

contents: write

packages: write

jobs:

release:

runs-on: ubuntu-latest

steps:

- uses: actions/checkout@v4

with:

fetch-depth: 0

- uses: actions/setup-node@v4

with:

node-version: "20"

- name: Create release

uses: changesets/action@v1

with:

publish: npm run release

env:

GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

NPM_TOKEN: ${{ secrets.NPM_TOKEN }}

Notification Integration

Send notifications to Slack, Discord, etc.

yaml

# .github/workflows/notifications.yml

name: Notifications

on: [push, pull_request]

jobs:

notify:

runs-on: ubuntu-latest

steps:

- uses: actions/checkout@v4

- name: Slack notification

uses: 8398a7/action-slack@v3

with:

status: ${{ job.status }}

text: |

Commit: ${{ github.event.head_commit.message }}

Author: ${{ github.event.head_commit.author.name }}

Status: ${{ job.status }}

webhook_url: ${{ secrets.SLACK_WEBHOOK }}

- name: Discord notification

if: failure()

uses: sarisia/actions-status-discord@v1

with:

webhook_url: ${{ secrets.DISCORD_WEBHOOK }}

status: ${{ job.status }}

Best Practices

1. Keep Secrets Secure - Never hardcode secrets

2. Use Matrix Testing - Test across configurations

3. Cache Dependencies - Speed up workflows

4. Fail Fast - Run quick checks first

5. Parallel Jobs - Run independent jobs concurrently

6. Clear Naming - Make workflow purposes obvious

7. Documentation - Document why workflow exists

8. Monitor Costs - Watch for slow runners

9. Regular Reviews - Update actions regularly

10. Automate Everything - More automation = fewer errors

Common Pitfalls

Don't:

- Commit .env files with secrets

- Use always() condition everywhere

- Have extremely long workflows

- Run expensive operations unnecessarily

- Ignore workflow failures

Do:

- Use GitHub secrets for sensitive data

- Use if: failure() for cleanup steps

- Break workflows into jobs

- Use matrix for variations

- Monitor workflow execution time

Conclusion

GitHub Actions provides a powerful, free CI/CD platform integrated with your repository. Start with basic testing and linting, then gradually add deployment automation. A well-designed CI/CD pipeline catches bugs early, speeds up releases, and gives confidence in code quality.

Happy automating! 🚀