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.

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
needsto 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-latestis 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 serverSSH_HOST— the server’s IP addressSSH_USER— the username for the SSH connectionREMOTE_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.
Comments
Sign in with your GitHub account to join the discussion. Comments are stored in GitHub Discussions.