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/workflowsthat 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:
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 tomainand 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 whycomposer.lockmust be committed to the repository. - In-memory SQLite: fast and sufficient for most tests, with no separate service. Job-level
envvariables 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:
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:
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:
- 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. Neverechoa 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
productionand 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
testjob 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
mainbefore 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.
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
.envfile, 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.lockisn't in the repository, or it changes on every run. - Floating action versions:
@v4picks 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.