Have you ever kicked off a manual deploy on Friday at 6 PM and found out on Monday that a test had failed? Or worse: you forgot to run the database migration and the site was down for half a day? We, at Meteora Web, have seen these scenarios dozens of times. The solution is CI/CD automation, and GitHub Actions is the most accessible tool to start—especially if your code is already on GitHub.
This guide starts from zero but is not for absolute beginners: you already know some Git and YAML? Good. Here you’ll truly understand how workflow, job, step and trigger work, not just how to copy a template YAML file. You’ll learn what belongs in a job vs a step, when to use a push trigger versus a pull_request trigger, and why getting the structure wrong can cost you time and resources.
What is a Workflow? The Black Box of Your Automation
A workflow is an automated process defined in a YAML file inside .github/workflows/. Each repository can have multiple workflows, each independent. Think of a workflow as a recipe that says: “when X happens, run this sequence of operations.”
The minimal structure looks like this:
name: Deploy to production
on:
push:
branches: ["main"]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Run deploy
run: echo "Simulated deploy"
Key components:
- name: human-readable name (optional but useful).
- on: the trigger — when to start the workflow.
- jobs: a set of jobs that run in parallel by default.
- runs-on: the runner environment (e.g.,
ubuntu-latest,windows-latest). - steps: commands executed one after another inside the job.
YAML is indentation-sensitive. We, at Meteora Web, have seen workflows break because a tab was used instead of two spaces. Always use a YAML-aware editor.
Triggers: When and Why to Run the Workflow
The on trigger is the decision heart. You can use simple events like push, pull_request, schedule (cron), workflow_dispatch (manual), or events from external services via repository_dispatch.
A common mistake is using push on all branches. For CI you usually want to run only on main branches and pull requests. Example:
on:
push:
branches: ["main", "develop"]
pull_request:
branches: ["main"]
Note that pull_request triggers the workflow when the PR is opened or updated, not when pushing to the PR branch (unless it’s against main). This avoids duplicate runs.
Other useful triggers:
- schedule: for nightly backups or log cleanup.
- workflow_dispatch: to run the workflow manually from the Actions tab.
- release: to automatically publish to npm or Docker Hub on release creation.
Path filters matter: you can limit triggers to specific files with paths. For example, trigger deploy only if docker-compose.yml changes. We use this to avoid restarting the entire pipeline when you only change the README.
Jobs: The Separate Rooms of Your Automation
A workflow can have one or more jobs. Each job runs on an independent runner and, by default, in parallel. This is powerful: you can, for instance, run tests on three Node.js versions simultaneously without waiting.
Example matrix strategy:
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
node: [16, 18, 20]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
- run: npm test
Each job is isolated: if the “test on Node 16” job fails, the others continue. You can also define dependencies between jobs with needs. Example:
jobs:
test:
runs-on: ubuntu-latest
steps: [/* tests */]
deploy:
needs: test
runs-on: ubuntu-latest
steps: [/* deploy */]
Here deploy only starts if test completes successfully (by default, if a job fails, dependent jobs don’t run).
Job Runners and Environments
Each job runs on a runner. Official runners are ubuntu-latest, windows-latest, macos-latest (each with a predefined set of tools). You can also use self-hosted runners for specific needs (e.g., GPU, internal databases). We, at Meteora Web, set up a self-hosted runner for a client who needed to compile code on an on-premise server with special licenses. Security is paramount: the runner has access to your code, so only use trusted runners.
Steps: The Atomic Building Blocks
Inside each job you have steps. Each step performs an atomic action: it can be a shell command (run) or a predefined action (uses). Steps are sequential: if one fails (exit code ≠ 0), subsequent steps are skipped unless you use if: always() or if: failure().
Example using community actions:
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.2'
- name: Copy .env
run: cp .env.example .env
- name: Run Composer
run: composer install --no-interaction --prefer-dist
- name: Run tests
run: php artisan test
Each step has a name that appears in logs. Don’t underestimate clear names: when a workflow fails, a descriptive name saves you minutes of debugging.
Using Predefined Actions vs Raw Commands
Actions (from the Marketplace) are reusable packages. For common operations (checkout, language setup, cloud deployment) it’s better to use them. For custom logic (e.g., migration scripts), write a run command directly. We advise against writing hundreds of lines of inline shell: move the logic into a script in the repository and call it with run: bash scripts/deploy.sh.
Common Mistakes and How to Avoid Them
1. Malformed YAML: an indentation error breaks everything. Use yaml-lint or GitHub’s built-in validation. We run a workflow that lints all YAML files before every deploy.
2. Too broad triggers: if you run the workflow on every push to every branch, every commit triggers the pipeline — including a typo fix in the README. Filter with branches and paths-ignore.
3. Clear-text environment variables: never write passwords or tokens directly in the YAML file. Use GitHub secrets: ${{ secrets.MY_SECRET }}.
4. Wrong job dependencies: if you use needs, remember you cannot share data between jobs without artifacts or cache. To pass a file from one job to another, use actions/upload-artifact and actions/download-artifact.
Complete Example: CI + Conditional Deploy
Let’s put everything together in a realistic workflow for a Laravel app with tests and deploy to a VPS via SSH:
name: CI/CD Laravel
on:
push:
branches: ["main"]
pull_request:
branches: ["main"]
jobs:
test:
runs-on: ubuntu-latest
services:
mysql:
image: mysql:8.0
env:
MYSQL_DATABASE: app_test
MYSQL_USER: test
MYSQL_PASSWORD: test
MYSQL_ROOT_PASSWORD: root
ports:
- 3306:3306
options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: '8.2'
extensions: pdo, pdo_mysql
- run: cp .env.example .env
- run: composer install -q --no-interaction --prefer-dist
- run: php artisan key:generate
- run: php artisan migrate --force
- run: php artisan test
deploy:
needs: test
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Deploy via SSH
uses: easingthemes/ssh-deploy@v4
with:
ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }}
remote-host: ${{ secrets.SSH_HOST }}
remote-user: ${{ secrets.SSH_USER }}
source: "."
target: "/var/www/app"
- name: Run migrations and optimizations
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.SSH_HOST }}
username: ${{ secrets.SSH_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
script: |
cd /var/www/app
php artisan migrate --force
php artisan cache:clear
php artisan config:cache
Note: deploy only runs when push is on main and after tests pass. This is the pattern we recommend to every client starting with CI/CD.
In Summary — What to Do Now
- Create the file
.github/workflows/ci.ymlin your repository. That’s all you need to start. - Choose the right triggers: at least
pushon the main branch andpull_requeston that same branch. - Define at least one job that runs tests. If you use multiple language versions, leverage the matrix strategy.
- Separate CI and CD: one job for tests, one for deploy with
needs: testand a conditionalifon the branch. - Never expose secrets: always use
${{ secrets.NAME }}for passwords, tokens and SSH keys. - Check the logs after the first run: GitHub shows each step in real time. If something fails, the output tells you why.
If you need help setting up your pipeline or want us to clean up an existing workflow, talk to us. At Meteora Web we work on this stuff every day — from domain to revenue, a single point of contact.
Sponsored Protocol