# Did Someone Say GitHub Actions?

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

- Published: 2024-07-23
- Updated: 2026-09-07
- Category: Tools & Technologies
- Tags: GitHub Actions
- Reading time: 4 min read
- Source: https://www.muhammetsafak.com.tr/en/blog/did-someone-say-github-actions/
- Language: en-US
- Author: Muhammet Şafak

---
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](/images/86f83-github-actions.png)

## 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; [in the reference's words](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax), "to run jobs sequentially, you can define dependencies on other jobs using the `jobs.<job_id>.needs` keyword".
- **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`:

```yaml
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](https://docs.github.com/en/actions/concepts/security/secrets). You define a separate secret for the SSH key, host address, username, and remote path. So how do you generate those secrets?

A note from today: the `actions/checkout@v2` above has fallen a long way behind — [the action's repository is on v7 today](https://github.com/actions/checkout). Pinning action versions is a good habit, but never revisiting the pin means the workflow will one day break quietly on a runner runtime upgrade.

## SSH key and authorization

Generate an SSH key pair on the server:

```bash
ssh-keygen -t rsa -b 4096 -C "yourmail@example.com"
```

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

```bash
cat ~/.ssh/id_rsa.pub
```

```bash
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:

```yaml
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:

```bash
ssh-keyscan -H your_server_domain
```

## Parallel deployment to multiple servers

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

```yaml
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`:

```bash
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`:

```text
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:

```yaml
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.** [As the OpenSSH manual describes it](https://man.openbsd.org/ssh_config#StrictHostKeyChecking), this setting doesn't merely add new host keys without asking; it also permits connections to hosts whose key has **changed** — that is exactly where the man-in-the-middle vector opens, and you are handing your private key to that connection. The manual calls `yes` "maximum protection against man-in-the-middle (MITM) attacks", while `accept-new` is the middle ground: it accepts new hosts but refuses ones whose key changed. For production environments, proper host key verification via the `SSH_KNOWN_HOSTS` secret, as shown above, 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.

---

### Sources

- [GitHub Docs: Workflow syntax for GitHub Actions](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax)
- [GitHub Docs: Secrets](https://docs.github.com/en/actions/concepts/security/secrets)
- [actions/checkout](https://github.com/actions/checkout)
- [OpenSSH ssh_config(5): StrictHostKeyChecking](https://man.openbsd.org/ssh_config#StrictHostKeyChecking)
