CI/CD Pipeline Tutorial with GitHub Actions: Zero to Automated Deployment 2026
Tutorials

CI/CD Pipeline Tutorial with GitHub Actions: Zero to Automated Deployment 2026

14 min read
101 Views
Share:

Why CI/CD transforms how teams ship software

Before I set up proper CI/CD pipelines, deploying software felt like defusing a bomb. Manual steps, forgotten environment variables, \"works on my machine\" bugs hitting production at 2am. After years of building and maintaining pipelines for teams of various sizes, I can tell you that a good CI/CD setup is one of the highest-ROI investments a development team can make.

CI/CD stands for Continuous Integration and Continuous Deployment. CI ensures that every code change is automatically tested and integrated. CD takes validated code and deploys it to staging or production automatically. Together, they eliminate entire categories of human error and let teams ship faster with more confidence.

In 2026, GitHub Actions is the dominant CI/CD platform for open-source and many enterprise projects. It is free for public repositories and has generous free minutes for private repos. Its deep integration with GitHub, massive marketplace of reusable actions, and intuitive YAML syntax make it the best starting point.

Core concepts you need to understand

Before writing YAML, you need to understand four concepts. A workflow is an automated process defined in a YAML file. Events trigger workflows (push, pull request, schedule, manual). Jobs are groups of steps that run on the same machine. Steps are individual tasks within a job, either shell commands or pre-built Actions from the marketplace.

Jobs within a workflow run in parallel by default. If you need one job to wait for another (like deploying only after tests pass), you declare that dependency explicitly.

Your first workflow: automated testing

Create the file .github/workflows/ci.yml in your repository. GitHub automatically detects any YAML file in this directory and treats it as a workflow.

# .github/workflows/ci.yml
name: CI Pipeline

# Trigger on push to any branch, and on pull requests targeting main
on:
  push:
    branches: ['**']
  pull_request:
    branches: [main, develop]

jobs:
  test:
    name: Run Tests
    runs-on: ubuntu-latest  # GitHub-hosted runner

    # Test against multiple Node.js versions
    strategy:
      matrix:
        node-version: [20.x, 22.x]

    steps:
      # Checkout the repository code
      - name: Checkout code
        uses: actions/checkout@v4

      # Set up Node.js
      - name: Set up Node.js ${{ matrix.node-version }}
        uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node-version }}
          cache: 'npm'  # Cache npm dependencies for faster runs

      # Install dependencies
      - name: Install dependencies
        run: npm ci  # ci is faster and more reliable than npm install

      # Run linting
      - name: Run ESLint
        run: npm run lint

      # Run tests
      - name: Run tests
        run: npm test -- --coverage

      # Upload coverage report
      - name: Upload coverage to Codecov
        uses: codecov/codecov-action@v4
        with:
          token: ${{ secrets.CODECOV_TOKEN }}
          fail_ci_if_error: false

Adding a build and security scan job

# Add these jobs to the same ci.yml file

  build:
    name: Build Application
    runs-on: ubuntu-latest
    needs: test  # Only runs if test job succeeds

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: '22.x'
          cache: 'npm'

      - run: npm ci

      - name: Build production bundle
        run: npm run build
        env:
          NODE_ENV: production

      # Save the build output for the deploy job
      - name: Upload build artifact
        uses: actions/upload-artifact@v4
        with:
          name: production-build
          path: dist/
          retention-days: 7

  security-scan:
    name: Security Audit
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - name: Run npm audit
        run: npm audit --audit-level=high

      - name: Scan for secrets in code
        uses: trufflesecurity/trufflehog@main
        with:
          path: ./
          base: ${{ github.event.repository.default_branch }}
          head: HEAD

Deploying with Docker

The most portable deployment approach is containerizing your application and pushing a Docker image to a registry. This works with any cloud provider.

# .github/workflows/deploy.yml — Separate workflow for deployment
name: Deploy to Production

on:
  push:
    branches: [main]  # Only deploy when code lands on main

jobs:
  test:
    uses: ./.github/workflows/ci.yml  # Reuse the CI workflow

  docker-build-push:
    name: Build and Push Docker Image
    runs-on: ubuntu-latest
    needs: test

    steps:
      - uses: actions/checkout@v4

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

      # Set up Docker Buildx for multi-platform builds
      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      # Extract metadata for tags
      - name: Extract Docker metadata
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ secrets.DOCKER_USERNAME }}/my-app
          tags: |
            type=sha,prefix=sha-
            type=raw,value=latest,enable={{is_default_branch}}

      # Build and push
      - name: Build and push Docker image
        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

  deploy:
    name: Deploy to Production Server
    runs-on: ubuntu-latest
    needs: docker-build-push
    environment: production  # Requires manual approval if configured

    steps:
      - name: Deploy via SSH
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.PROD_HOST }}
          username: ${{ secrets.PROD_USER }}
          key: ${{ secrets.PROD_SSH_KEY }}
          script: |
            cd /opt/my-app
            docker pull ${{ secrets.DOCKER_USERNAME }}/my-app:latest
            docker compose down
            docker compose up -d
            docker image prune -f
            echo "Deployment complete at $(date)"

The Dockerfile your pipeline needs

# Dockerfile — multi-stage build for minimal production image
# Stage 1: Install dependencies and build
FROM node:22-alpine AS builder

WORKDIR /app

COPY package*.json ./
RUN npm ci --only=production && npm cache clean --force

COPY . .
RUN npm run build

# Stage 2: Minimal production image
FROM node:22-alpine AS production

# Security: don't run as root
RUN addgroup -g 1001 -S nodejs && adduser -S nodejs -u 1001

WORKDIR /app

# Copy only what's needed from builder
COPY --from=builder --chown=nodejs:nodejs /app/dist ./dist
COPY --from=builder --chown=nodejs:nodejs /app/node_modules ./node_modules
COPY --from=builder --chown=nodejs:nodejs /app/package.json ./

USER nodejs

EXPOSE 3000

HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
  CMD node -e "require('http').get('http://localhost:3000/health', (r) => r.statusCode === 200 ? process.exit(0) : process.exit(1))"

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

CI/CD pipeline comparison

PlatformFree TierEase of SetupGitHub IntegrationBest For
GitHub Actions2,000 min/monthExcellentNativeGitHub-hosted projects
GitLab CI400 min/monthGoodVia mirrorGitLab repositories
CircleCI6,000 min/monthGoodGoodComplex pipelines
JenkinsSelf-hostedComplexPluginFull control, enterprise
Bitbucket Pipelines50 min/monthGoodVia integrationBitbucket teams

Managing secrets securely

Never hardcode credentials in your YAML files. GitHub Actions provides encrypted secrets that are injected as environment variables at runtime. Go to your repository Settings → Secrets and variables → Actions to add them.

# Bad — NEVER do this
- name: Deploy
  run: |
    ssh user@server "export DB_PASS=my_actual_password && deploy.sh"

# Correct — use secrets
- name: Deploy
  env:
    DB_PASSWORD: ${{ secrets.DB_PASSWORD }}
    API_KEY: ${{ secrets.API_KEY }}
  run: |
    # Environment variables are available here
    ./deploy.sh

# For deployment environments with required reviewers
# Set up Environments in repo Settings → Environments
deploy:
  environment:
    name: production
    url: https://myapp.com
  # This job will wait for a reviewer's approval before running

Common errors and solutions

Error: "Resource not accessible by integration" on GitHub API calls

The default GITHUB_TOKEN does not have the permissions your workflow needs. At the top of your workflow or specific job, declare the required permissions explicitly: add permissions: contents: write (or read) for repo access, packages: write for GitHub Container Registry, or pull-requests: write for commenting on PRs.

Workflow does not trigger on push

Check that your YAML file is in the exact path .github/workflows/ (note the dot prefix) and that the on.push.branches pattern matches your branch name. Patterns like main are literal strings, not wildcards. Use '**' for all branches or 'feature/**' for branches starting with feature/.

Docker build fails with "no space left on device"

GitHub-hosted runners have limited disk space (~14GB). Large Docker builds can exhaust it. Add a step before your build to free space: use the jlumbroso/free-disk-space@main action, or add docker system prune -af before the build. Alternatively, optimize your Dockerfile to reduce layer sizes using multi-stage builds.

SSH deployment fails: "Host key verification failed"

When connecting to a server for the first time, SSH asks to verify the host key. In a non-interactive pipeline, this causes failure. Fix it by adding the server's host key to known_hosts before connecting. With appleboy/ssh-action, add fingerprint: ${{ secrets.PROD_HOST_FINGERPRINT }}. Get the fingerprint by running ssh-keyscan -H your-server-ip on your local machine.

Job runs but deployment is not reflected

The deployment script ran successfully from the pipeline's perspective but the live site did not update. Common causes: the Docker container was pulled but the old container is still running (add docker compose down before up -d), the app server needs a restart, or there is a caching layer (CDN, Nginx, application-level cache) serving stale content. Add explicit cache-busting steps to your deployment script.

Additional resources

J
Written by
Jesús García

Apasionado por la tecnologia y las finanzas personales. Escribo sobre innovacion, inteligencia artificial, inversiones y estrategias para mejorar tu economia. Mi objetivo es hacer que temas complejos sean accesibles para todos.

Share post:

Related posts

Comments

Leave a comment

Recommended Tools

The ones we use in our projects

Affiliate links. No extra cost to you.

Need technology services?

We offer comprehensive web development, mobile apps, consulting, and more.

Web Development Mobile Apps Consulting