Manually testing code, building the app, and deploying it by hand works until it doesn't. The process is slow, error-prone, and gets harder to maintain as a project grows. One missed step or a distracted moment during deployment can push a broken build to production.
CI/CD — Continuous Integration and Continuous Delivery — solves this by automating the sequence. Code gets pushed, tests run, the build is verified, and deployment happens without manual intervention. GitHub Actions brings this directly into GitHub, so there is no separate CI platform to set up or maintain.
This guide builds a working pipeline from scratch: a basic test workflow first, then an extended build-and-deploy workflow, then a set of additions worth knowing about once the foundation is in place.
What this covers:
Core CI/CD concepts and GitHub Actions terminology
Writing a basic test workflow
Extending it to build and deploy
Debugging and monitoring pipelines
Useful additions: caching, linting, conditional logic, notifications
Core Concepts Before Writing Code
GitHub Actions workflows are YAML files stored in the .github/workflows directory of a repository. Understanding five terms makes the rest of the guide easier to follow:
Term | What it means |
|---|---|
Workflow | The full automated process, defined in a single |
Job | A group of steps that run together on the same virtual machine |
Step | A single task within a job — either a shell command or a reusable action |
Action | A reusable unit of code that performs a specific function (e.g., checking out code) |
Runner | The virtual machine that executes the job — GitHub-hosted or self-hosted |
A workflow is analogous to a recipe. Jobs are the major stages (test, build, deploy). Steps are the individual instructions within each stage. The runner is the machine that carries them out.
GitHub Actions is free for public repositories and includes a generous monthly allocation of free minutes for private ones.
Step 1: Create the Workflow File
All workflows live in .github/workflows/ in the repository root. Create that directory if it does not already exist, then create a file called test.yml.
name: Run Tests
on:
push:
branches:
- main
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v4
- name: Set Up Node.js
uses: actions/setup-node@v3
with:
node-version: '18'
- name: Install Dependencies
run: npm install
- name: Run Tests
run: npm test
What this workflow does:
Triggers on every push to the
mainbranchSpins up an Ubuntu runner
Checks out the repository code
Installs Node.js 18
Installs project dependencies
Runs the test suite
The npm test command can be replaced with whatever runs your tests: pytest, phpunit, bundle exec rspec, or a custom shell script. The structure stays the same regardless of language.
Commit this file and push it. GitHub will detect the workflow automatically and run it against the push.
Step 2: Extend the Workflow to Build and Deploy
Once tests pass reliably, the next step is automating the build and deployment. The following workflow extends the test steps and adds SSH-based deployment to a remote server:
name: Build and Deploy
on:
push:
branches:
- main
jobs:
build-deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v4
- name: Set Up Node.js
uses: actions/setup-node@v3
with:
node-version: '18'
- name: Install Dependencies
run: npm install
- name: Run Tests
run: npm test
- name: Build App
run: npm run build
- name: Deploy to Server
uses: appleboy/ssh-action@master
with:
host: ${{ secrets.HOST }}
username: ${{ secrets.USERNAME }}
password: ${{ secrets.PASSWORD }}
port: 22
script: |
cd /var/www/myapp
git pull origin main
npm install
npm run build
pm2 restart dist/index.js
The ${{ secrets.HOST }}, ${{ secrets.USERNAME }}, and ${{ secrets.PASSWORD }} references pull from GitHub Secrets — encrypted values stored in the repository settings under Settings, Secrets and variables, Actions. Never hardcode credentials directly in a workflow file.
To add a secret: go to the repository settings, navigate to Secrets and variables, and click New repository secret. The value is encrypted and only exposed to workflow runs — it is never visible in logs.
Step 3: Monitor and Debug
Once the workflow file is pushed, every subsequent push to main triggers a run. The Actions tab in the repository shows the run history, status, and step-by-step logs.
Useful debugging approaches when a workflow fails:
Read the failed step's log output carefully — GitHub Actions logs are verbose and usually identify the exact line that failed
Add
echostatements to print environment variable values during a run if something is behaving unexpectedlyUse the
actCLI tool to run workflows locally before pushing, which speeds up the iteration cycle when debugging complex workflowsCheck that secrets are set correctly — a missing or misspelled secret name causes authentication steps to fail silently
Step 4: Useful Additions Once the Foundation Works
With a working test-build-deploy pipeline in place, the following additions improve reliability and developer experience.
Caching Dependencies
Re-downloading node_modules on every run adds time. Caching stores the result of the install step and reuses it when the lockfile has not changed:
- name: Cache node modules
uses: actions/cache@v3
with:
path: ~/.npm
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-
Place this step before the install step. On cache hits, the install step completes in seconds rather than minutes.
Linting and Formatting Checks
Running a linter before tests catches style and syntax issues early and prevents them from reaching review:
- name: Run Linter
run: npx eslint .
This works with any linter. Replace the command with black --check . for Python, rubocop for Ruby, or phpcs for PHP.
Conditional Deployment
Deploying only when pushing to main (not feature branches) prevents unintended deployments during development:
if: github.ref == 'refs/heads/main'
Add this condition at the job level to skip the deployment job on pushes to other branches while still running tests.
Build Failure Notifications
Getting a notification when a build fails without checking the Actions tab manually is worth setting up early:
- name: Send Slack Notification
uses: rtCamp/action-slack-notify@v2
env:
SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }}
STATUS: ${{ job.status }}
Store the Slack webhook URL as a secret. The job.status variable reflects whether the preceding steps succeeded or failed.
Key Takeaways
CI/CD automates the test-build-deploy sequence, removing the manual steps where errors most commonly occur.
GitHub Actions workflows are YAML files in
.github/workflows/— no external CI platform is required.Sensitive credentials belong in GitHub Secrets, never in the workflow file itself.
Caching dependencies reduces build time significantly for projects with large dependency trees.
Conditional jobs and branch filters prevent the deploy stage from running on pushes that should not trigger a deployment.
Linting as a workflow step enforces code quality standards automatically before code reaches review.
Conclusion
A CI/CD pipeline built with GitHub Actions does not require infrastructure expertize or external services. The core concepts are straightforward, the YAML syntax is readable once the structure is familiar, and the feedback loop it creates — push code, get results within minutes — improves the quality and predictability of every release.
The workflow in this guide is a starting point. As the project grows, the pipeline can grow with it: additional test stages, environment-specific deployments, scheduled jobs, and more — all defined in version-controlled YAML files alongside the code they automate.
Working with a different stack or deployment target? Share the details in the comments.




