Skip to main content
Back to Category

Automated Deployment with GitHub Actions: CI/CD Pipeline Setup

Build CI/CD pipelines with GitHub Actions: Docker build and push, SSH server deployment, test automation, secrets management, and workflow optimization.

Read time: 18 min DevOps & Automation
github-actionsci/cddeploymentdevopsautomationdockerssh
Author
REXE Teknoloji Network & Security Team
Editor
REXE Teknoloji Technical Editorial
First published
Last updated

Automated Deployment with GitHub Actions: CI/CD Pipeline Setup

GitHub Actions is a powerful CI/CD platform that automatically tests, builds, and deploys code changes to your server. In this guide we'll build a production-ready pipeline from scratch.

What is GitHub Actions?

GitHub Actions is an automation platform integrated into GitHub repositories:

  • Reacts to events like push, PR, and schedule
  • Runs parallel and sequential workflows
  • 2,000 minutes/month free (unlimited for public repos)
  • Extensible with thousands of ready-made actions

GitHub Actions workflow files are stored in YAML format in the .github/workflows/ directory.

Basic Workflow Structure

hljs yaml
# .github/workflows/deploy.yml
name: CI/CD Pipeline

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'
      - name: Install dependencies
        run: npm ci
      - name: Run tests
        run: npm test

  deploy:
    needs: test
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main'
    steps:
      - uses: actions/checkout@v4
      - name: Deploy
        run: echo "Deploy steps here"

on / jobs / steps Structure

Triggers (on)

hljs yaml
on:
  # Push to branch
  push:
    branches: [main, develop]
    paths:
      - 'src/**'
      - 'package.json'

  # Pull request
  pull_request:
    branches: [main]
    types: [opened, synchronize, reopened]

  # Scheduled run (cron)
  schedule:
    - cron: '0 2 * * *'  # Every night at 02:00

  # Manual trigger
  workflow_dispatch:
    inputs:
      environment:
        description: 'Deploy environment'
        required: true
        default: 'staging'
        type: choice
        options: [staging, production]

  # When another workflow completes
  workflow_run:
    workflows: ["Build"]
    types: [completed]

Job Configuration

hljs yaml
jobs:
  build:
    runs-on: ubuntu-latest
    env:
      NODE_ENV: test
    timeout-minutes: 30
    strategy:
      matrix:
        node-version: [18, 20, 22]
        os: [ubuntu-latest, windows-latest]
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node-version }}

Docker Build and Push

Push to Docker Hub

hljs yaml
jobs:
  docker:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Docker metadata
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: username/myapp
          tags: |
            type=ref,event=branch
            type=semver,pattern={{version}}
            type=sha,prefix=sha-

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - 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 }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

GitHub Container Registry (GHCR)

hljs yaml
      - name: Login to GHCR
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Build and Push (GHCR)
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: ghcr.io/${{ github.repository }}:latest

SSH Deployment to Server

Using appleboy/ssh-action

hljs yaml
  deploy:
    needs: docker
    runs-on: ubuntu-latest
    environment: production
    steps:
      - name: Deploy via SSH
        uses: appleboy/ssh-action@v1.0.3
        with:
          host: ${{ secrets.SERVER_HOST }}
          username: ${{ secrets.SERVER_USER }}
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          port: ${{ secrets.SERVER_PORT }}
          script: |
            cd /root/myapp
            git pull origin main
            docker compose pull
            docker compose up -d --remove-orphans
            docker image prune -f
            echo "Deploy completed: $(date)"

Deployment with Docker Compose

hljs yaml
      - name: Docker Compose deploy
        uses: appleboy/ssh-action@v1.0.3
        with:
          host: ${{ secrets.SERVER_HOST }}
          username: ${{ secrets.SERVER_USER }}
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          script: |
            # Pull new image
            docker pull ghcr.io/${{ github.repository }}:latest

            # Zero-downtime deploy
            docker compose -f /root/app/docker-compose.yml up -d \
              --no-deps \
              --build \
              --remove-orphans

            # Clean old images
            docker image prune -af --filter "until=24h"

            # Health check
            sleep 10
            curl -f http://localhost:3000/health || exit 1

Secrets Management

GitHub Secrets securely stores sensitive information:

hljs yaml
steps:
  - name: Application configuration
    env:
      DATABASE_URL: ${{ secrets.DATABASE_URL }}
      JWT_SECRET: ${{ secrets.JWT_SECRET }}
      API_KEY: ${{ secrets.API_KEY }}
    run: |
      echo "Configuration loaded"
      # Secrets are never printed to logs, automatically masked

Required secrets:

  • SERVER_HOST: Server IP address
  • SERVER_USER: SSH username
  • SSH_PRIVATE_KEY: SSH private key
  • DOCKERHUB_USERNAME: Docker Hub username
  • DOCKERHUB_TOKEN: Docker Hub access token

Cache Usage

hljs yaml
      - name: Cache Node.js dependencies
        uses: actions/cache@v4
        with:
          path: ~/.npm
          key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
          restore-keys: |
            ${{ runner.os }}-node-

      - name: npm ci (cached)
        run: npm ci

      # Docker layer cache
      - name: Build and Push
        uses: docker/build-push-action@v5
        with:
          cache-from: type=gha
          cache-to: type=gha,mode=max

Matrix Builds

hljs yaml
jobs:
  test:
    strategy:
      fail-fast: false
      matrix:
        node: [18, 20]
        os: [ubuntu-latest, macos-latest]
        include:
          - node: 20
            os: ubuntu-latest
            coverage: true
        exclude:
          - node: 18
            os: macos-latest
    runs-on: ${{ matrix.os }}
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node }}
      - run: npm ci
      - run: npm test
      - name: Coverage report
        if: matrix.coverage
        run: npm run coverage

Environment Protection Rules

hljs yaml
jobs:
  deploy-production:
    runs-on: ubuntu-latest
    environment:
      name: production
      url: https://example.com
    steps:
      - name: Production deploy
        run: echo "Deploying to production"

In GitHub under Settings > Environments > production:

  • Required reviewers (require approval)
  • Wait timer (waiting period)
  • Deployment branches (main only)

Full CI/CD Pipeline Example

hljs yaml
name: Full CI/CD

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}

jobs:
  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 run lint
      - run: npm test -- --coverage
      - name: Upload coverage
        uses: codecov/codecov-action@v4

  build:
    needs: test
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
    outputs:
      image-tag: ${{ steps.meta.outputs.version }}
    steps:
      - uses: actions/checkout@v4
      - uses: docker/setup-buildx-action@v3
      - uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
      - uses: docker/build-push-action@v5
        with:
          push: ${{ github.event_name != 'pull_request' }}
          tags: ${{ steps.meta.outputs.tags }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

  deploy:
    needs: build
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main'
    environment:
      name: production
      url: https://example.com
    steps:
      - uses: appleboy/ssh-action@v1.0.3
        with:
          host: ${{ secrets.SERVER_HOST }}
          username: ${{ secrets.SERVER_USER }}
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          script: |
            docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:main
            docker compose -f /root/app/docker-compose.yml up -d
            docker image prune -f

Conclusion

By setting up a fully automated CI/CD pipeline with GitHub Actions, you can improve code quality, reduce deployment errors, and speed up your development process. With secrets management, cache optimization, and environment protection rules, you get a production-grade pipeline.

Frequently Asked Questions

Is GitHub Actions free?

It's completely free and unlimited for public repositories. Private repositories get 2,000 free minutes per month. If this limit is exceeded on the GitHub Free plan, workflows pause. GitHub Pro and Team plans include more minutes.

How do I create an SSH private key and add it to GitHub?

Generate a key pair on your local machine with 'ssh-keygen -t ed25519 -C "github-actions"'. Add the public key (~/.ssh/id_ed25519.pub) to ~/.ssh/authorized_keys on your server. Save the private key (~/.ssh/id_ed25519) in your GitHub repo under Settings > Secrets as SSH_PRIVATE_KEY.

How does job dependency with needs work?

'needs: test' means this job won't run until the 'test' job completes successfully. Use 'needs: [test, build]' for multiple dependencies. If a job fails, dependent jobs are automatically cancelled.

Is it possible to trigger a workflow only when specific files change?

Yes, use 'on.push.paths' to watch specific files or directories. For example 'paths: ["src/**", "package.json"]' triggers the workflow only when these files change. Use 'paths-ignore' to exclude specific files.

Why is Docker build cache important?

Docker build cache prevents rebuilding unchanged layers. 'cache-from: type=gha' uses GitHub Actions cache. This can reduce build time by 50-80%. It makes a big difference especially for dependency installation steps (npm install, pip install).

What do environment protection rules do?

They add an approval mechanism for critical environments like production. With 'Required reviewers', deployment cannot proceed without approval from specific people. 'Wait timer' sets a waiting period before deployment. This prevents accidental deployments to production.

Related Articles

Server Automation with Ansible: Infrastructure as Code

Ansible installation, inventory file, playbook writing, role structure, variables, encrypted vault, ad-hoc commands, and server configuration automation. Step-by-step guide.

20 min
ansibleautomationinfrastructure as code
Network TrafficInbound GbpsOutbound Gbps