CI/CD means automating the path your code takes from commit to server. CI (continuous integration) means that every time someone pushes code, an automated system builds the project and runs the tests, so breakage is caught immediately rather than a week later. CD (continuous delivery or deployment) means code that passes CI goes to the server through a defined, repeatable step, or fully automatically.

GitHub Actions is the easiest place to start because it is built into GitHub and there is nothing to install: you add a YAML file under .github/workflows, and GitHub runs it on every push or pull request. This article builds a real pipeline for a Laravel project, shows the Python equivalent, and then covers secrets, branch protection and deployment.

Short answer: CI/CD automates the path from commit to server: CI builds the project and runs the tests on every push, and CD deploys code that passed those tests through a repeatable process. In GitHub Actions, you do this with a YAML file in .github/workflows that GitHub runs on every push or pull request.

The basics, without the jargon

A pipeline is an assembly line: code goes in at one end and passes through several stations. If any station finds a problem, the line stops and you get notified.

In GitHub Actions, that line is built from a few parts:

Term Meaning
workflow A YAML file that defines the whole process
event What triggers the workflow, such as push, pull_request or creating a tag
job A set of steps that run on one machine; jobs run in parallel by default
step A shell command or a ready-made action
action A reusable building block, such as actions/checkout
runner The machine a job runs on, e.g. ubuntu-latest

The value of CI is that "it worked on my machine" stops being an excuse. Tests run on a clean, identical machine, every time.

Your first workflow for Laravel

Assume a Laravel 13 project whose tests run with php artisan test. Create this file:

.github/workflows/tests.yml
name: tests

on:
  push:
    branches: [main]
  pull_request:

jobs:
  test:
    runs-on: ubuntu-latest

    env:
      DB_CONNECTION: sqlite
      DB_DATABASE: ":memory:"

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Set up PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: "8.3"
          extensions: mbstring, pdo_sqlite, bcmath, intl
          coverage: none

      - name: Get Composer cache directory
        id: composer-cache
        run: echo "dir=$(composer config cache-files-dir)" >> "$GITHUB_OUTPUT"

      - name: Cache Composer packages
        uses: actions/cache@v4
        with:
          path: ${{ steps.composer-cache.outputs.dir }}
          key: composer-${{ runner.os }}-${{ hashFiles('composer.lock') }}
          restore-keys: composer-${{ runner.os }}-

      - name: Install dependencies
        run: composer install --no-interaction --prefer-dist --no-progress

      - name: Prepare environment
        run: |
          cp .env.example .env
          php artisan key:generate

      - name: Run tests
        run: php artisan test

A few notes on this file:

  • on: the workflow runs on every push to main and on every pull request, so you know whether the code is healthy before you merge.
  • setup-php: pins the PHP version and extensions. Ideally, match what you run in production.
  • Composer cache: the cache key is derived from the hash of composer.lock. As long as dependencies don't change, packages come from the cache and installs are much faster. That's why composer.lock must be committed to the repository.
  • In-memory SQLite: fast and sufficient for most tests, with no separate service. Job-level env variables override the values in .env.

If your views use @vite and your feature tests render them, either call $this->withoutVite() in your tests or add npm ci and npm run build steps before the tests; otherwise the tests fail because the manifest is missing.

When tests need a real MySQL

If you rely on features that behave differently in SQLite, you can run a MySQL service alongside the job:

yaml
    services:
      mysql:
        image: mysql:8.4
        env:
          MYSQL_DATABASE: testing
          MYSQL_ROOT_PASSWORD: password
        ports:
          - 3306:3306
        options: >-
          --health-cmd="mysqladmin ping -h 127.0.0.1"
          --health-interval=10s
          --health-timeout=5s
          --health-retries=5

Then change the job variables to DB_CONNECTION: mysql, DB_HOST: 127.0.0.1, DB_DATABASE: testing, DB_USERNAME: root and DB_PASSWORD: password. That password belongs to a throwaway container that disappears after the job, so having it in the file is fine. Add the pdo_mysql extension to setup-php as well.

The same idea for Python

For a Python project with pytest, the structure is almost identical, and setup-python handles the pip cache for you:

.github/workflows/tests.yml
name: tests

on:
  push:
    branches: [main]
  pull_request:

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"
          cache: pip

      - run: pip install -r requirements.txt

      - run: pytest

For a broader comparison of the two languages on the backend, see PHP or Python for the backend.

Secrets: credentials don't belong in files

Anything confidential, such as an API key or a deploy SSH key, must stay out of the repository. In GitHub, create a secret under Settings → Secrets and variables → Actions and read it in the workflow like this:

yaml
      - name: Call external service
        run: ./scripts/notify.sh
        env:
          API_TOKEN: ${{ secrets.API_TOKEN }}

A few rules:

  • GitHub masks secret values as *** in logs, but if you transform the value (base64-encode it, for example) and print it, the masking no longer works. Never echo a secret.
  • Workflows triggered by pull requests from forks don't get access to secrets; this is deliberate and correct.
  • For deployment, use Environments: give production secrets only to an environment named production and require manual approval for it.

Protecting the main branch

CI is only truly valuable if it can't be bypassed. Under Settings → Branches (or Rulesets), add rules for main:

  • Require a pull request before merging: nobody pushes directly to main.
  • Require status checks to pass: select the test job as a required check. The merge button stays disabled until the tests are green.
  • Require branches to be up to date: the branch must have been tested against the latest main before merging.

If branches and pull requests are still new to you, see Essential git commands.

What CD can look like

The simplest deployment pattern that is both safe and controllable is tag-based deployment. Whenever you decide to release, you create and push a tag such as v1.4.0; the deploy workflow runs only on tags.

.github/workflows/deploy.yml
name: deploy

on:
  push:
    tags: ["v*"]

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: production
    steps:
      - uses: actions/checkout@v4

      - name: Deploy
        run: ./scripts/deploy.sh
        env:
          DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }}
          DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}

What goes in deploy.sh depends on your infrastructure, but for a Laravel project on a VPS it typically connects to the server with a dedicated deploy SSH key (SSH keys), checks out the tagged code, runs composer install --no-dev --optimize-autoloader, runs php artisan migrate --force, builds the config and route caches, and finally reloads the queue workers with php artisan queue:restart.

Some advice for CD:

  • CI first, then CD. Automated deployment without automated tests just ships bugs to users faster.
  • Use a separate deploy key with the least privilege it needs, not your personal key.
  • Have a way back: if a new release is broken, redeploying the previous tag should be a single command. Back up before risky migrations, too (the 3-2-1 rule).
  • If your app runs in Docker, CD can build an image and have the server pull it instead of copying code; the groundwork is in Docker Compose.

Common problems

  • Tests pass locally but fail in CI: usually caused by reliance on a local .env file, test execution order, or a PHP extension that isn't installed on the runner.
  • The workflow never runs: the file must be exactly under .github/workflows/, and the YAML must be indented correctly.
  • The cache is never hit: composer.lock isn't in the repository, or it changes on every run.
  • Floating action versions: @v4 picks up minor updates automatically. If supply-chain security matters to you, pin actions to a specific commit hash.

Frequently asked questions

What is the difference between CI and CD?

CI (continuous integration) means every code change is automatically built and tested so breakage is caught immediately. CD (continuous delivery or deployment) means code that passes CI goes to the server through a repeatable step or fully automatically. CD without CI only gets bugs to users faster.

Where do GitHub Actions workflow files go?

Each workflow is a YAML file that must live in the .github/workflows folder at the root of the repository, for example .github/workflows/tests.yml. If the path or the YAML indentation is wrong, GitHub won't run it.

How do I store secrets in GitHub Actions?

Create a secret under Settings → Secrets and variables → Actions and pass it to a step as an environment variable using the secrets context and its name. GitHub masks the value in logs, but never echo it, and give production secrets only to a dedicated production environment.

How do I block merging a pull request when tests fail?

Add a rule for the main branch under Settings → Branches or Rulesets, enable Require status checks to pass, and select your test job as a required check. From then on, the merge button stays disabled until the tests are green.

Why do my tests pass locally but fail in GitHub Actions?

Usually because the tests depend on a local .env file, on test execution order, or on a PHP extension that isn't installed on the runner. If your views use Vite, a missing manifest is another common cause, fixed with withoutVite or by building assets before the tests.

Wrap-up

CI means every push is tested automatically; CD means healthy code reaches the server through a repeatable step. Start with a tests.yml file, run the tests on every pull request, and use branch rules to make merging red code impossible. Once you trust that pipeline, tag-based deployment with production environment secrets is the natural next step. The full documentation is at docs.github.com/actions.