GitHub Actions is an automation tool that runs your tests every time you push code and, if everything is green, deploys your project to the server automatically. It lives inside your repository, so there is no separate service to sign up for, and it ships with a generous free quota for open source projects. In this guide we will build a CI/CD pipeline from scratch: first clarifying the concepts, then writing a real test and deploy workflow.
Why CI/CD and GitHub Actions help
CI (Continuous Integration) means automatically building and testing every code change, so you catch a broken commit before it is even merged. CD (Continuous Delivery/Deployment) means automatically shipping code that passes the tests to a staging or production environment. The combined flow is called a CI/CD pipeline.
The manual loop of "run tests locally, FTP the files up, cross your fingers" is slow and error-prone. GitHub Actions makes that loop repeatable:
- Consistency: The pipeline runs on a clean virtual machine, largely eliminating the "it worked on my machine" problem.
- Speed: Jobs can run in parallel, so lint, test and build progress at the same time.
- Security: SSH keys and tokens are stored encrypted in repository secrets instead of being hard-coded.
Core concepts: workflow, job, step, action
The GitHub Actions vocabulary is small but important:
- Workflow: A YAML file in the
.github/workflows/folder. It defines when and what runs. - Event (trigger): The event that starts the workflow —
push,pull_request, a scheduledschedule, or a manualworkflow_dispatch. - Job: A group of steps that runs on the same runner. Jobs run in parallel by default and sequentially with
needs. - Step: A single command (
run) inside a job, or a ready-madeusesaction. - Runner: The virtual machine the job runs on, for example
ubuntu-latest.
Your first workflow: running tests
Let's take a Node.js project. Create .github/workflows/ci.yml at the repository root:
name: CI
on:
push:
branches: [main]
pull_request:
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 test
actions/checkout@v4 clones your repo onto the runner, setup-node installs the correct Node version, and cache: npm caches your dependencies. npm ci does a clean install that strictly follows package-lock.json — more reliable than npm install in CI. The moment you push this file, tests run automatically on every commit and pull request.
Testing multiple versions with a matrix
If you want to test a library against several language versions, strategy.matrix multiplies a single job:
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [18, 20, 22]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
- run: npm ci
- run: npm test
This workflow runs the same tests on Node 18, 20 and 22 in parallel. On the PHP/Laravel side you can apply the same idea with the shivammathur/setup-php action to try multiple PHP versions, then run php artisan test.
Secrets and the deploy job
The deploy step needs access to your server, which means secrets. Never write an SSH private key or API token into the YAML. Instead create a secret on GitHub under Settings → Secrets and variables → Actions (for example SSH_PRIVATE_KEY, SSH_HOST) and reference it in the workflow as ${{ secrets.SSH_HOST }}.
The example below adds a second job that deploys after the tests pass. Thanks to needs: test, the deploy only runs if the test job succeeded, and the if condition restricts it to pushes on main:
deploy:
needs: test
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Deploy on the server via SSH
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.SSH_HOST }}
username: ${{ secrets.SSH_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
port: ${{ secrets.SSH_PORT }}
script: |
cd ~/app
git pull origin main
composer install --no-dev -o
php artisan migrate --force
php artisan optimize
Here the deploy strategy is a simple "connect to the server, pull, update" flow. If you use Docker you would instead build the image and push it to a registry (docker/build-push-action), then pull the new image on the server. The principle stays the same: configuration in code, secrets in secrets.
Tips to harden the pipeline
- Fail fast: Make lint and test separate jobs before deploy, so a red test blocks the deploy entirely.
- Use caching: Dependency and build caches cut pipeline time from minutes to seconds.
- Pin action versions: Bind to a fixed major version like
@v4; for the most sensitive projects, pin to a commit SHA. - Concurrency: Use a
concurrencyblock to cancel older runs of the same branch and prevent redundant deploys. - Visibility: Add a status badge to your README so you can see at a glance whether the pipeline is green or red.
Frequently Asked Questions
Is GitHub Actions free?
Standard runners are free for public repositories. Private repositories get a free monthly minute quota; once you exceed it, you are billed per minute. Most small and medium projects stay comfortably within the free quota.
Why GitHub Actions instead of Jenkins or GitLab CI?
If your code is already on GitHub, Actions lives in the same place as your repo, so you start with zero infrastructure setup. Jenkins offers more control and plugins but requires you to run a server. The decision depends on where your team works and how much customization you need.
What happens if a deploy fails?
The job turns red, later steps do not run, and GitHub notifies you. The logs show the exact command and error output. For safe deploys, design your migrations to be reversible and, where possible, use a blue-green or rolling deploy strategy.
Want to set up your pipeline or automate an existing deploy flow? I can help you move your project onto a fast, secure and repeatable CI/CD pipeline — get in touch.