:::note[TL;DR]
- Workflows are YAML files in
.github/workflows/— triggered by push, PR, schedule, or manual dispatch - Use
needs:to chain jobs so deploy only runs after tests pass - Gate production deploys with
if: github.ref == 'refs/heads/main'— PRs only hit staging - Never hardcode secrets — store them in Settings → Secrets → Actions and reference as
${{ secrets.NAME }} - Caching with
actions/setup-node@v4+cache: 'npm'eliminates redundant installs on every run :::
Prerequisites
- A GitHub repository (public or private)
- Your project has a test script in
package.json(or equivalent for your language) - Basic familiarity with YAML syntax
GitHub Actions is GitHub’s built-in CI/CD system. Every push, pull request, or schedule can trigger a workflow — run tests, build a Docker image, deploy to your server. It’s free for public repos and has a generous free tier for private ones.
This guide goes from zero to a working pipeline: tests on every PR, deploy to production on merge to main.
How GitHub Actions works
Workflows are YAML files in .github/workflows/. Each file defines:
- Triggers (
on) — what events run the workflow - Jobs — parallel groups of steps
- Steps — individual commands or pre-built actions
GitHub spins up a fresh VM for each job, runs the steps, and reports pass/fail.
Step 1: Create your first workflow
Create .github/workflows/ci.yml:
name: CI
on:
push:
branches: [main]
pull_request:
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@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm test
- name: Run build
run: npm run build
Push this to GitHub. The Actions tab will show the workflow running on every push and PR.
Step 2: Add environment variables
For environment-specific values that aren’t secret (like API base URLs):
jobs:
test:
runs-on: ubuntu-latest
env:
NODE_ENV: test
API_URL: https://api.staging.example.com
steps:
- ...
Step 3: Add secrets
For API keys, passwords, and tokens — never hardcode them. Add secrets in: Repository → Settings → Secrets and variables → Actions
Then reference them in the workflow:
steps:
- name: Deploy
env:
DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }}
DATABASE_URL: ${{ secrets.DATABASE_URL }}
run: ./scripts/deploy.sh
:::warning Never hardcode secrets directly in workflow YAML — even in private repos. If the repo ever becomes public, or a contributor forks it, the secret is exposed. Secrets in commit history are permanent: rotating the credential doesn’t remove it from git history. Store all sensitive values in Settings → Secrets and variables → Actions. :::
The scenario: You’re setting up CI for a side project at 11pm before a demo. You paste the API key directly in the YAML, push, it works. Two days later you realize the key is visible in your public repo’s commit history. Now you’re rotating credentials at midnight. Use secrets. Always.
Step 4: Add a deployment job
name: CI/CD
on:
push:
branches: [main]
pull_request:
branches: [main]
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
deploy:
runs-on: ubuntu-latest
needs: test # only run if test passes
if: github.ref == 'refs/heads/main' # only on main branch
steps:
- uses: actions/checkout@v4
- name: Deploy to server
env:
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
SERVER_HOST: ${{ secrets.SERVER_HOST }}
run: |
echo "$SSH_PRIVATE_KEY" > /tmp/key
chmod 600 /tmp/key
ssh -i /tmp/key -o StrictHostKeyChecking=no user@$SERVER_HOST \
'cd /app && git pull && npm ci && pm2 restart app'
needs: test ensures deploy only runs after tests pass. if: github.ref == 'refs/heads/main' skips deploy on PRs.
:::tip
Always use needs: test before any deploy job. Without it, a deploy can run in parallel with tests — meaning broken code ships before the test failure is even reported. The needs: key creates an explicit dependency and blocks the deploy until all listed jobs succeed.
:::
Step 5: Add a staging deployment
Common pattern — deploy PRs to a staging environment, main to production:
deploy-staging:
needs: test
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Deploy to staging
run: ./scripts/deploy-staging.sh
env:
STAGING_URL: ${{ secrets.STAGING_URL }}
deploy-production:
needs: test
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Deploy to production
run: ./scripts/deploy-prod.sh
env:
PROD_URL: ${{ secrets.PROD_URL }}
Step 6: Cache dependencies
Without caching, npm install runs from scratch on every workflow. With caching, it reuses the node_modules cache when package-lock.json hasn’t changed:
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm' # handles caching automatically
For pnpm:
- uses: pnpm/action-setup@v4
with:
version: 10
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'pnpm'
Useful actions
| Action | What it does |
|---|---|
actions/checkout@v4 | Check out your repo |
actions/setup-node@v4 | Set up Node.js |
actions/setup-python@v5 | Set up Python |
docker/build-push-action@v5 | Build and push Docker image |
actions/upload-artifact@v4 | Save build output |
actions/cache@v4 | Manual dependency caching |
peter-evans/create-pull-request@v6 | Create a PR from a workflow |
Common triggers
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
schedule:
- cron: '0 9 * * 1' # every Monday at 9am UTC
workflow_dispatch: # manual trigger from UI
release:
types: [published]
Debugging a failing workflow
- Click the failed job in the Actions tab — each step shows its output
- Add
ACTIONS_RUNNER_DEBUG: trueto repo secrets to get verbose logs - Use
tmateaction for SSH access into a running job (for tricky issues) - Add
echostatements to print variable values mid-run
- name: Debug
run: |
echo "Node version: $(node -v)"
echo "Branch: $GITHUB_REF"
echo "Event: $GITHUB_EVENT_NAME"
Related: GitHub Actions Secrets Best Practices
Summary
- GitHub Actions workflows live in
.github/workflows/as YAML files triggered by push, PR, schedule, or manual dispatch - Use
needs:to chain jobs so deploy only runs after tests pass - Use
if: github.ref == 'refs/heads/main'to gate production deploys to the main branch only - Never hardcode secrets — use GitHub’s encrypted secrets and reference them as
${{ secrets.NAME }} - Caching dependencies (
actions/setup-node@v4withcache: 'npm') can cut install time significantly
Frequently Asked Questions
How do I run a workflow only on certain file changes?
Use paths in the trigger:
on:
push:
paths:
- 'src/**'
- 'package.json'
The workflow only runs when those paths have changes. Useful for monorepos.
Why is my workflow triggered but the job is skipped?
Check your if: conditions. A common gotcha: if: github.ref == 'refs/heads/main' won’t match on a pull_request event because the ref is the PR branch. Use github.base_ref == 'main' for PRs or check github.event_name.
How do I share data between jobs?
Use actions/upload-artifact to save files from one job and actions/download-artifact to retrieve them in another. For simple values, use job outputs via echo "value=x" >> $GITHUB_OUTPUT and reference with needs.job_name.outputs.value.
What to Read Next
- GitHub Actions Secrets Best Practices — the most important security rules for your CI/CD setup
- Docker Compose in Production — what to configure before your Actions workflow deploys to a server