Skip to content
Muhammet Şafak
tr
Tools & Technologies 6 min read

Did Someone Say GitHub Actions?

An introduction to CI/CD with GitHub Actions: how to set up workflows and automate your deployment process.


GitHub Actions is an automation platform that lets you run workflows triggered by repository events such as a push or a pull request. You define your test, build, and deployment steps in YAML files, all in one place — GitHub handles the rest.

In this post I’ll walk through real-world scenarios: deploying a PHP project to a server over SSH, handling multiple environments, and dealing with IP restrictions. Rather than going through the rules in the abstract, I prefer to show working setups first and then talk through them.

GitHub Actions workflow screen

Core building blocks

The anatomy of an Actions configuration:

  • Workflow: A YAML file living under .github/workflows/. Each file is one workflow.
  • Job: An independent unit of work inside a workflow. Jobs run in parallel by default; use needs to make them sequential.
  • Step: A single command or action inside a job. Steps run sequentially; if one step fails, subsequent steps are skipped by default.
  • Action: A reusable step pulled from the Marketplace or defined inside the repository. Invoked with uses:.
  • Runner: The virtual machine a job runs on. ubuntu-latest is the common choice; you can also attach your own self-hosted runner.

Simple deployment to a single server

A minimal workflow that connects to the server and pulls the latest code on every push to main:

name: Deploy PHP Application

on:
  push:
    branches:
      - main

jobs:
  deploy:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout Repository
        uses: actions/checkout@v2
      
      - name: PHP Kurulumu
        uses: shivammathur/setup-php@v2
        with: 
          php-version: '8.0'
      
      - name: Composer paketlerinin kurulumu
        run: composer install --no-progress --no-suggest --prefer-dist

      - name: SSH Bağlantısı ve Son Kodun Alınması
        env:
          SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
          HOST: ${{ secrets.SSH_HOST }}
          USER: ${{ secrets.SSH_USER }}
          REMOTE_PATH: ${{ secrets.REMOTE_PATH }}
        run: |
          echo "$SSH_PRIVATE_KEY" > private_key
          chmod 600 private_key
          ssh -i private_key -o StrictHostKeyChecking=no $USER@$HOST "cd $REMOTE_PATH && git pull origin main"

Credentials used inside the workflow are not stored as plain text in the repository — they live as Actions secrets. You define a separate secret for the SSH key, host address, username, and remote path. So how do you generate those secrets?

SSH key and authorization

Generate an SSH key pair on the server:

ssh-keygen -t rsa -b 4096 -C "[email protected]"

This produces id_rsa (private key) and id_rsa.pub (public key). Add the public key to the server’s authorized_keys file:

cat ~/.ssh/id_rsa.pub
nano ~/.ssh/authorized_keys

The contents of the private key (the output of cat ~/.ssh/id_rsa) are stored in GitHub as the SSH_PRIVATE_KEY secret. Under Settings → Secrets and variables → Actions in your repository, add these four secrets:

  • SSH_PRIVATE_KEY — the private key you generated on the server
  • SSH_HOST — the server’s IP address
  • SSH_USER — the username for the SSH connection
  • REMOTE_PATH — the directory on the server where the project lives

With those in place, every push to main reaches the server.

Different branch, different environment

Production and staging environments usually map to separate servers. You can handle the branch condition inside the workflow:

name: Deploy PHP Application

on:
  push:
    branches:
      - main
      - develop

jobs:
  deploy:
    runs-on: ubuntu-latest

    steps:
      - name: SSH Bağlantısı ve Son Kodun Alınması
        if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop'
        env:
          SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
          SSH_KNOWN_HOSTS: ${{ secrets.SSH_KNOWN_HOSTS }}
        run: |
          mkdir -p ~/.ssh
          echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_rsa
          chmod 600 ~/.ssh/id_rsa
          echo "${{ secrets.SSH_KNOWN_HOSTS }}" > ~/.ssh/known_hosts
          
          if [ "${{ github.ref }}" == "refs/heads/main" ]; then
            REMOTE_USER="canli_kullanici_adi"
            REMOTE_HOST="canlı_sunucu_adresi"
            REMOTE_PATH="/home/remote/project/path"
          elif [ "${{ github.ref }}" == "refs/heads/develop" ]; then
            REMOTE_USER="test_kullanici_adi"
            REMOTE_HOST="test_sunucu_adresi"
            REMOTE_PATH="/home/remote/project/path"
          fi
          
          ssh -i ~/.ssh/id_rsa -o StrictHostKeyChecking=no $REMOTE_USER@$REMOTE_HOST "cd $REMOTE_PATH && git pull origin ${GITHUB_REF##*/}"

To populate the SSH_KNOWN_HOSTS secret, fetch the server’s host key like this:

ssh-keyscan -H your_server_domain

Parallel deployment to multiple servers

When production runs across multiple servers, a loop is the natural approach:

name: Deploy PHP Application

on:
  push:
    branches:
      - main
      - develop

jobs:
  deploy:
    runs-on: ubuntu-latest

    steps:
      - name: SSH Bağlantısı ve Son Kodun Alınması
        if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop'
        env:
          SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
          SSH_KNOWN_HOSTS: ${{ secrets.SSH_KNOWN_HOSTS }}
        run: |
          mkdir -p ~/.ssh
          echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_rsa
          chmod 600 ~/.ssh/id_rsa
          echo "${{ secrets.SSH_KNOWN_HOSTS }}" > ~/.ssh/known_hosts
          
          if [ "${{ github.ref }}" == "refs/heads/main" ]; then
            SERVERS=("canli_sunucu1" "canli_sunucu2" "canli_sunucu3")
            REMOTE_USER="canli_kullanici_adi"
            REMOTE_PATH="/home/remote/project/path"
          elif [ "${{ github.ref }}" == "refs/heads/develop" ]; then
            SERVERS=("test_sunucu1" "test_sunucu2")
            REMOTE_USER="test_kullanici_adi"
            REMOTE_PATH="/home/remote/project/path"
          fi
          
          for SERVER in "${SERVERS[@]}"
          do
          echo "Deploying to $SERVER"
          ssh -i ~/.ssh/id_rsa -o StrictHostKeyChecking=no $REMOTE_USER@$SERVER "cd $REMOTE_PATH && git pull origin ${GITHUB_REF##*/}"
          done

You can collect host keys from multiple servers in one shot for SSH_KNOWN_HOSTS:

ssh-keyscan -H your_server1_domain your_server2_domain your_server3_domain ...

Accessing IP-restricted servers via a bastion host

If you’ve restricted SSH access to your servers to specific IP addresses — which is the right call — the Actions runner’s IP range changes dynamically, so a direct connection isn’t possible. Routing through a bastion host is the working solution.

On the bastion host, generate an SSH key and write the access configuration for your other servers into ~/.ssh/config:

Host sunucu1
    HostName sunucu1_ip
    User kullanici_adi
    IdentityFile ~/.ssh/id_rsa

Host sunucu2
    HostName sunucu2_ip
    User kullanici_adi
    IdentityFile ~/.ssh/id_rsa

# Similar configuration for other servers...

With that in place, the deployment workflow uses SSH ProxyJump through the bastion:

name: Deploy PHP Application

on:
  push:
    branches:
      - main
      - develop

jobs:
  deploy:
    runs-on: ubuntu-latest

    steps:
      - name: SSH Bağlantısı ve Son Kodun Alınması
        if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop'
        env:
          SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
          BASTION_HOST: ${{ secrets.BASTION_HOST }}
          BASTION_USER: ${{ secrets.BASTION_USER }}
        run: |
          mkdir -p ~/.ssh
          echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_rsa
          chmod 600 ~/.ssh/id_rsa
          
          ssh-keyscan -H ${{ secrets.BASTION_HOST }} >> ~/.ssh/known_hosts
          
          if [ "${{ github.ref }}" == "refs/heads/main" ]; then
            SERVERS=("canli_sunucu1" "canli_sunucu2" "canli_sunucu3")
            REMOTE_USER="canli_kullanici_adi"
            REMOTE_PATH="/home/remote/project/path"
          elif [ "${{ github.ref }}" == "refs/heads/develop" ]; then
            SERVERS=("test_sunucu1" "test_sunucu2")
            REMOTE_USER="test_kullanici_adi"
            REMOTE_PATH="/home/remote/project/path"
          fi
          
          for SERVER in "${SERVERS[@]}"
          do
          echo "Deploying to $SERVER"
          ssh -i ~/.ssh/id_rsa -o StrictHostKeyChecking=no -J ${{ secrets.BASTION_USER }}@${{ secrets.BASTION_HOST }} $REMOTE_USER@$SERVER "cd $REMOTE_PATH && git pull origin ${GITHUB_REF##*/}"
          done

The -J flag enables SSH ProxyJump: the runner connects to the bastion first, then tunnels through to the target server.

Pitfalls worth knowing

A few things I’ve run into repeatedly while setting this up across several projects:

Understand the risk of StrictHostKeyChecking=no. The -o StrictHostKeyChecking=no option disables host key verification, which opens a man-in-the-middle vector. For production environments, proper host key verification via the SSH_KNOWN_HOSTS secret is much safer.

Clean up the private key file. In the first example you’re creating a private_key file temporarily. Even though the runner is cleaned up after the step, a subsequent step within the same job could still access that file. Adding rm -f private_key is a good habit.

Cache Composer output. Running composer install on every build consumes runner time. You can cache the vendor/ directory keyed on the composer.lock hash using actions/cache.

Post-deployment health check. A successful SSH command return code doesn’t mean the application is actually up. Adding a curl endpoint check after git pull gives you an early warning.

GitHub Actions is a capable and well-suited tool for this set of scenarios. Because workflow files live inside the repository, your deployment logic becomes part of the commit history — making it easy to see exactly what changed and when.

Share:

Comments

Sign in with your GitHub account to join the discussion. Comments are stored in GitHub Discussions.

Related Posts

Search the site

Start typing to search posts, projects and pages.

Esc to close Powered by Pagefind