Every push should trigger a build. Every merge should run tests. Every release should deploy automatically. This is CI/CD, and GitHub Actions makes it surprisingly easy.
Why GitHub Actions?#
Before Actions, you needed separate CI/CD services — Jenkins, CircleCI, Travis CI. They work fine, but they’re another thing to manage, another place to configure, another dashboard to check.
GitHub Actions lives in your repository. Your workflows are code, versioned alongside your application. No context switching. No separate accounts. No webhooks to configure.
Your First Workflow#
Create .github/workflows/ci.yml:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
| name: CI
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
- name: Run tests
run: pytest --verbose
|
Push this file, and GitHub automatically runs your tests on every push and PR. That’s it. CI is done.
Understanding the Structure#
Triggers (on): When should this workflow run?
1
2
3
4
5
6
7
| on:
push:
branches: [main]
pull_request:
schedule:
- cron: '0 0 * * *' # Daily at midnight
workflow_dispatch: # Manual trigger button
|
Jobs: Independent units of work that run in parallel (by default):
1
2
3
4
5
6
7
8
9
10
11
12
13
| jobs:
test:
runs-on: ubuntu-latest
# ...
lint:
runs-on: ubuntu-latest
# ...
build:
runs-on: ubuntu-latest
needs: [test, lint] # Wait for these to pass
# ...
|
Steps: Sequential commands within a job:
1
2
3
4
5
| steps:
- uses: actions/checkout@v4 # Use a pre-built action
- run: echo "Hello, World!" # Run a shell command
- name: Named step # Step with a name
run: npm install
|
Real-World Examples#
Node.js with Caching#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
| name: Node.js CI
on: [push, pull_request]
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [18.x, 20.x, 22.x]
steps:
- uses: actions/checkout@v4
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
- run: npm ci
- run: npm run build --if-present
- run: npm test
|
This tests against multiple Node versions in parallel, with dependency caching for speed.
Docker Build and Push#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
| name: Docker
on:
push:
tags: ['v*']
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v5
with:
push: true
tags: myuser/myapp:${{ github.ref_name }}
|
Push a tag like v1.2.3, and it automatically builds and pushes to Docker Hub.
Deploy to AWS S3#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
| name: Deploy
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build site
run: npm run build
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: us-east-1
- name: Sync to S3
run: aws s3 sync ./dist s3://my-bucket --delete
- name: Invalidate CloudFront
run: |
aws cloudfront create-invalidation \
--distribution-id ${{ secrets.CF_DISTRIBUTION_ID }} \
--paths "/*"
|
Secrets Management#
Never hardcode credentials. Use GitHub Secrets:
- Go to Repository → Settings → Secrets → Actions
- Add your secrets (AWS keys, API tokens, etc.)
- Access them via
${{ secrets.SECRET_NAME }}
Secrets are encrypted, masked in logs, and only available to workflows.
Environments and Approvals#
For production deploys, add manual approval:
1
2
3
4
5
6
7
8
9
10
11
| jobs:
deploy-staging:
runs-on: ubuntu-latest
environment: staging
# ...
deploy-production:
runs-on: ubuntu-latest
environment: production # Requires approval
needs: deploy-staging
# ...
|
Configure environments in Repository → Settings → Environments. Add required reviewers for production.
Reusable Workflows#
Don’t repeat yourself. Create reusable workflows:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
| # .github/workflows/reusable-deploy.yml
name: Reusable Deploy
on:
workflow_call:
inputs:
environment:
required: true
type: string
secrets:
AWS_ACCESS_KEY_ID:
required: true
AWS_SECRET_ACCESS_KEY:
required: true
jobs:
deploy:
runs-on: ubuntu-latest
environment: ${{ inputs.environment }}
steps:
# ... deployment steps
|
Call it from other workflows:
1
2
3
4
5
6
| jobs:
deploy:
uses: ./.github/workflows/reusable-deploy.yml
with:
environment: production
secrets: inherit
|
Pro Tips#
1. Use workflow_dispatch for manual triggers:
1
2
3
4
5
6
7
| on:
workflow_dispatch:
inputs:
environment:
description: 'Environment to deploy'
required: true
default: 'staging'
|
2. Cache aggressively:
1
2
3
4
| - uses: actions/cache@v4
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }}
|
3. Fail fast in matrix builds:
1
2
3
4
| strategy:
fail-fast: true
matrix:
# ...
|
4. Use job outputs to pass data:
1
2
3
4
5
6
7
| jobs:
build:
outputs:
version: ${{ steps.version.outputs.version }}
steps:
- id: version
run: echo "version=$(cat VERSION)" >> $GITHUB_OUTPUT
|
The Bottom Line#
GitHub Actions removes the friction from CI/CD. Your pipelines live with your code, use the same PR review process, and scale automatically.
Start simple: add a test workflow. Then iterate. Before you know it, you’ll have fully automated builds, tests, and deployments — all triggered by a git push.
Running into issues with GitHub Actions? Drop a question on Twitter.