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:

  1. Go to Repository → Settings → Secrets → Actions
  2. Add your secrets (AWS keys, API tokens, etc.)
  3. 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.