Programming Intermediate 13 min

How to Set Up CI/CD with GitHub Actions

A CI/CD pipeline catches broken commits before they reach production and deploys passing code automatically. GitHub Actions is free for public repos and generous for private ones, and the workflow YAML lives in your repo alongside the code it tests. This guide builds a two-job pipeline: tests first, deploy only if they pass.

Step-by-step

  1. 1

    Create the workflow directory and file

    GitHub Actions looks for workflow files in .github/workflows/. Any .yml file there is picked up automatically. One file = one workflow.

    bash
    mkdir -p .github/workflows
    touch .github/workflows/deploy.yml
  2. 2

    Write the trigger and test job

    Trigger on push to main. The test job checks out the code, installs dependencies, and runs the test suite. This example uses PHP/Laravel — swap the setup action and install command for Node, Python, etc.

    yaml
    name: CI/CD
    
    on:
      push:
        branches: [main]
      pull_request:
        branches: [main]
    
    jobs:
      test:
        runs-on: ubuntu-latest
    
        services:
          mysql:
            image: mysql:8.4
            env:
              MYSQL_ROOT_PASSWORD: secret
              MYSQL_DATABASE: laravel_test
            ports:
              - 3306:3306
            options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3
    
        steps:
          - uses: actions/checkout@v4
    
          - name: Set up PHP
            uses: shivammathur/setup-php@v2
            with:
              php-version: "8.3"
              extensions: mbstring, pdo_mysql, zip, bcmath
              coverage: none
    
          - name: Cache Composer packages
            uses: actions/cache@v4
            with:
              path: vendor
              key: ${{ runner.os }}-php-${{ hashFiles("**/composer.lock") }}
              restore-keys: ${{ runner.os }}-php-
    
          - name: Install dependencies
            run: composer install --no-dev --optimize-autoloader --no-interaction
    
          - name: Copy .env
            run: cp .env.example .env && php artisan key:generate
    
          - name: Run tests
            env:
              DB_CONNECTION: mysql
              DB_HOST: 127.0.0.1
              DB_PORT: 3306
              DB_DATABASE: laravel_test
              DB_USERNAME: root
              DB_PASSWORD: secret
            run: php artisan test --parallel
  3. 3

    Add the deploy job with SSH

    The deploy job runs only if test passes (needs: test) and only on pushes to main (not on PRs). It SSH-es into your server and runs the deploy commands.

    yaml
      deploy:
        runs-on: ubuntu-latest
        needs: test
        if: github.ref == "refs/heads/main" && github.event_name == "push"
    
        steps:
          - name: Deploy to server
            uses: appleboy/ssh-action@v1
            with:
              host: ${{ secrets.SSH_HOST }}
              username: ${{ secrets.SSH_USER }}
              key: ${{ secrets.SSH_KEY }}
              port: 22
              script: |
                cd /var/www/myapp
                git pull origin main
                composer install --no-dev --optimize-autoloader
                php artisan migrate --force
                php artisan optimize
                php artisan view:cache
                chown -R www-data:www-data /var/www/myapp
  4. 4

    Store secrets in GitHub

    Never put credentials in the YAML file. Store them as repository secrets and reference them with ${{ secrets.NAME }}. Navigate to your repo → Settings → Secrets and variables → Actions → New repository secret.

    bash
    # Secrets to create in GitHub:
    # SSH_HOST     — your server IP or hostname
    # SSH_USER     — e.g. root or deploy
    # SSH_KEY      — the private key (the full content of ~/.ssh/id_ed25519)
    
    # Generate a dedicated deploy key (don't reuse your personal key)
    ssh-keygen -t ed25519 -C "github-actions-deploy" -f ~/.ssh/deploy_key -N ""
    
    # Add the public key to the server
    ssh-copy-id -i ~/.ssh/deploy_key.pub user@your-server
    
    # Paste the PRIVATE key content into the SSH_KEY secret on GitHub
    cat ~/.ssh/deploy_key
  5. 5

    Use environment-scoped secrets for staging vs production

    For multi-environment setups, create Environments in GitHub (Settings → Environments). Environments can have their own secrets, protection rules (required reviewers), and deployment branch restrictions.

    yaml
      deploy:
        runs-on: ubuntu-latest
        needs: test
        environment: production          # Links to a GitHub Environment
        if: github.ref == "refs/heads/main" && github.event_name == "push"
    
        steps:
          - name: Deploy to production
            uses: appleboy/ssh-action@v1
            with:
              host: ${{ secrets.SSH_HOST }}     # Scoped to "production" env
              username: ${{ secrets.SSH_USER }}
              key: ${{ secrets.SSH_KEY }}
              script: cd /var/www/myapp && git pull && php artisan optimize
  6. 6

    Pass build artifacts between jobs

    If your build step produces a compiled artifact (Vite assets, a binary, a Docker image digest), upload it in the test job and download it in deploy — don't rebuild in the deploy job.

    yaml
        # In the test job — upload compiled assets
          - name: Build frontend
            run: npm ci && npm run build
    
          - uses: actions/upload-artifact@v4
            with:
              name: frontend-build
              path: public/build/
    
      deploy:
        needs: test
        runs-on: ubuntu-latest
        steps:
          - uses: actions/download-artifact@v4
            with:
              name: frontend-build
              path: public/build/
    
          # Then rsync or scp the public/build/ dir to the server
  7. 7

    Add a matrix build for multiple versions

    Matrix builds run the same job across multiple versions in parallel — useful for libraries or multi-version support guarantees. Each combination is a separate job in the Actions UI.

    yaml
      test:
        runs-on: ubuntu-latest
        strategy:
          matrix:
            php: ["8.2", "8.3"]
            # node: [18, 20, 22]   # Swap for Node projects
    
        steps:
          - uses: actions/checkout@v4
          - uses: shivammathur/setup-php@v2
            with:
              php-version: ${{ matrix.php }}
          - run: composer install --no-interaction
          - run: php artisan test
  8. 8

    Troubleshoot failed runs

    Most failures come from one of three places. Check them in order:

    • Missing secret — a blank secret causes a cryptic SSH error. Re-check the secret name matches exactly (case-sensitive).
    • SSH key format — the key must include the -----BEGIN...----- and -----END...----- lines. Paste it raw, no extra spaces.
    • Server permission error — the deploy user must own the app directory or be in the www-data group. Run chown -R deploy:deploy /var/www/myapp once.
    bash
    # Re-run a failed workflow from the CLI
    gh run list --limit 5
    gh run rerun <run-id>
    
    # Watch a run live
    gh run watch <run-id>
    
    # Download logs from a failed run
    gh run view <run-id> --log-failed

Tips & gotchas

  • Use <code>concurrency</code> groups to cancel in-progress runs when a new push arrives — prevents multiple deploys queuing up from rapid commits: <code>concurrency: { group: deploy, cancel-in-progress: true }</code>
  • Add a <code>workflow_dispatch</code> trigger so you can manually kick off a deploy from the GitHub UI without a push: <code>on: [push, workflow_dispatch]</code>
  • Cache your package manager dependencies with <code>actions/cache@v4</code> keyed on the lockfile hash. A warm cache cuts install time from 60 s to under 5 s.
  • Keep the deploy job fast — run <code>php artisan migrate --pretend</code> in the test job to catch migration errors before you SSH to the server.
  • Use <code>actions/checkout@v4</code> with <code>fetch-depth: 0</code> if your tests or versioning tools need the full git history.

Wrapping up

You now have a pipeline that runs tests on every push, blocks deploys when tests fail, and ships to production over SSH when the branch is main and tests are green. Extend it incrementally — add code quality checks (phpstan, eslint), notifications (Slack, email), or a staging environment gate before production.

#CI/CD #GitHub Actions #Deployment
Back to all guides

Need Help With Your Project?

Book a free 30-minute consultation to discuss your technical challenges and explore solutions together.