TL;DR — Quick Summary

GitHub Actions deployment guide: automate pipelines with environment protection, secrets, multi-target deploys, caching, and rollback strategies.

AUTOMATED DEPLOYMENT PIPELINE — GITHUB ACTIONS git push trigger Lint + Test quality gate Build artifacts Approval environment gate Deploy production Notify Slack/Teams push → lint → test → build → approve → deploy → notify

Deploying by hand — SSHing into a server, running build commands, crossing your fingers — is error-prone, unauditable, and doesn’t scale. GitHub Actions lets you define your entire deployment pipeline as code: lint, test, build, approve, deploy, and notify — all triggered automatically on every push to main. This guide covers how to build a production-grade automated deployment workflow, from the first YAML file to multi-target deploys, environment protection, and rollback strategies.

Prerequisites

Before creating your deployment workflow, have the following in place:

  • A GitHub repository with application code (Node.js examples used throughout)
  • A target environment — Cloudflare Workers account, a VPS with SSH access, or a self-hosted Windows runner for IIS
  • Basic YAML familiarity — GitHub Actions workflows are YAML files; indentation matters
  • Access to GitHub repository settings to store secrets and create environments

GitHub Actions Basics

A GitHub Actions workflow is a YAML file stored in .github/workflows/. It is triggered by one or more events (push, pull request, schedule, manual trigger). A workflow contains one or more jobs. Each job runs on a runner — a virtual machine hosted by GitHub (Ubuntu, Windows, macOS) or self-hosted. Each job contains sequential steps, each of which runs either a shell command (run) or a reusable unit called an action (uses).

The key building blocks:

name: Deploy                      # workflow name shown in the Actions tab

on:                               # trigger
  push:
    branches: [main]

jobs:
  deploy:                         # job ID
    runs-on: ubuntu-latest        # runner
    steps:
      - uses: actions/checkout@v4 # action (checks out source)
      - run: npm ci               # shell command
      - run: npm run build

Jobs run in parallel by default. Use needs: [job-id] to enforce ordering.

Your First Deployment Workflow

Here is a complete workflow for a Node.js project that deploys to Cloudflare Workers using Wrangler. It runs lint and tests first, then builds, then deploys — only on pushes to main.

name: Deploy to Cloudflare Workers

on:
  push:
    branches: [main]

concurrency:
  group: deploy-${{ github.ref }}
  cancel-in-progress: true

jobs:
  lint-and-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: "22"
          cache: "npm"

      - run: npm ci
      - run: npm run lint
      - run: npm test

  build:
    needs: lint-and-test
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: "22"
          cache: "npm"

      - run: npm ci
      - run: npm run build

      - uses: actions/upload-artifact@v4
        with:
          name: dist
          path: dist/

  deploy:
    needs: build
    runs-on: ubuntu-latest
    environment: production
    steps:
      - uses: actions/checkout@v4

      - uses: actions/download-artifact@v4
        with:
          name: dist
          path: dist/

      - name: Deploy with Wrangler
        uses: cloudflare/wrangler-action@v3
        with:
          apiToken: ${{ secrets.CF_API_TOKEN }}
          accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}

      - name: Notify Slack on success
        if: success()
        run: |
          curl -s -X POST "${{ secrets.SLACK_WEBHOOK }}" \
            -H "Content-Type: application/json" \
            -d "{\"text\":\"Deployed ${{ github.sha }} to production by ${{ github.actor }}\"}"

The concurrency block at the top prevents two deployments from overlapping — if you push twice quickly, the first run is cancelled before the second begins.

Environment Protection Rules

Pushing directly to production without a human check is risky for critical systems. GitHub environments let you add protection rules to any job that targets them.

To configure environment protection:

  1. Go to Settings → Environments in your repository
  2. Click New environment and name it production
  3. Enable Required reviewers — add one or more GitHub users or teams
  4. Optionally set a Wait timer (e.g., 10 minutes) before the job is allowed to run
  5. Optionally restrict which branches can deploy to this environment (e.g., only main)

Once configured, any workflow job with environment: production will pause and wait for a reviewer to approve before proceeding. The reviewer sees the pending deployment in the GitHub UI and can approve or reject it with a comment.

deploy:
  needs: build
  environment: production       # triggers protection rules
  runs-on: ubuntu-latest
  steps:
    - name: Deploy
      run: echo "Deploying..."

Environment protection rules are free for public repositories and available on GitHub Team and Enterprise plans for private repositories.

Managing Secrets

Never hardcode API tokens, SSH keys, or passwords in workflow files. GitHub provides three tiers of secret storage:

Repository secrets — Available to all workflows in the repo. Go to Settings → Secrets and variables → Actions → New repository secret.

Environment secrets — Scoped to a specific environment (e.g., production). Only exposed when a job targets that environment, adding an extra layer of protection.

Organization secrets — Shared across multiple repositories in a GitHub organization, ideal for tokens used by many projects (e.g., a shared CF_API_TOKEN).

Reference secrets in workflows using the ${{ secrets.SECRET_NAME }} syntax:

- name: Deploy with Wrangler
  uses: cloudflare/wrangler-action@v3
  with:
    apiToken: ${{ secrets.CF_API_TOKEN }}
    accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}

OIDC (OpenID Connect) is an alternative to long-lived secrets for cloud providers that support it (AWS, GCP, Azure). Instead of storing a static token, the runner requests a short-lived credential from the cloud provider at runtime:

permissions:
  id-token: write
  contents: read

steps:
  - uses: aws-actions/configure-aws-credentials@v4
    with:
      role-to-assume: arn:aws:iam::123456789012:role/github-actions-deploy
      aws-region: us-east-1

The cloud provider validates that the request originates from your specific repository and branch — no stored secret required.

Deploying to Different Targets

Cloudflare Workers via Wrangler

The cloudflare/wrangler-action handles authentication and runs any Wrangler command:

- uses: cloudflare/wrangler-action@v3
  with:
    apiToken: ${{ secrets.CF_API_TOKEN }}
    accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
    command: deploy --env production

Ensure wrangler.toml is committed with the correct name and main fields. The action installs Wrangler, authenticates, and runs wrangler deploy (or any command you specify).

VPS via SSH and rsync

For a Linux VPS, use SSH to connect and rsync to transfer files:

- name: Deploy to VPS
  uses: appleboy/ssh-action@v1
  with:
    host: ${{ secrets.DEPLOY_HOST }}
    username: ${{ secrets.DEPLOY_USER }}
    key: ${{ secrets.DEPLOY_SSH_KEY }}
    script: |
      cd /var/www/myapp
      rsync -az --delete dist/ /var/www/myapp/dist/
      systemctl restart myapp

Store the private SSH key in GitHub secrets. Add the corresponding public key to ~/.ssh/authorized_keys on the server. Use a dedicated deploy user with minimal permissions — not root.

IIS via Self-Hosted Runner

For Windows IIS deployments, run a self-hosted runner directly on the server:

  1. Go to Settings → Actions → Runners → New self-hosted runner
  2. Download and install the runner application on the Windows server
  3. Register it with a descriptive label (e.g., iis-deploy)
deploy-iis:
  needs: build
  runs-on: [self-hosted, iis-deploy]
  environment: production
  steps:
    - uses: actions/download-artifact@v4
      with:
        name: dist
        path: C:\inetpub\wwwroot\myapp

    - name: Restart IIS site
      run: |
        Import-Module WebAdministration
        Restart-WebItem 'IIS:\Sites\MyApp'
      shell: powershell

The self-hosted runner executes commands with the permissions of the Windows service account it runs under. Never run the runner as a local administrator on internet-exposed servers.

Caching and Performance

Dependency Caching

Re-downloading npm packages on every run wastes time. Cache them:

- uses: actions/setup-node@v4
  with:
    node-version: "22"
    cache: "npm"           # built-in cache tied to package-lock.json

For Python:

- uses: actions/setup-python@v5
  with:
    python-version: "3.12"
    cache: "pip"           # keyed on requirements*.txt

For .NET:

- uses: actions/cache@v4
  with:
    path: ~/.nuget/packages
    key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj') }}
    restore-keys: ${{ runner.os }}-nuget-

Concurrency Groups

Concurrency groups prevent parallel deploys — a common source of race conditions:

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

This is safe for deployments: if you push a fix immediately after a bad commit, the bad deploy is cancelled before it finishes, and your fix deploys instead.

For pull request previews, use a different group per PR so they don’t cancel each other:

concurrency:
  group: preview-${{ github.event.pull_request.number }}
  cancel-in-progress: true

Comparison: GitHub Actions vs GitLab CI vs Jenkins

FeatureGitHub ActionsGitLab CIJenkins
HostingGitHub-hosted (SaaS)GitLab-hosted or self-hostedSelf-hosted only
Config file.github/workflows/*.yml.gitlab-ci.ymlJenkinsfile (Groovy)
Free tier2,000 min/month (private)400 min/month (private)Unlimited (your infra)
Marketplace20,000+ actionsLimited1,800+ plugins
Environment gatesBuilt-in (free for public)Built-in (requires Ultimate for rules)Via plugins
OIDCAWS, GCP, Azure, CloudflareAWS, GCP, AzureVia plugins
Self-hosted runnersYes (any OS)Yes (any OS)Yes
Matrix buildsNative YAMLNative YAMLVia plugins
Learning curveLowLowHigh (Groovy DSL)
Best forGitHub-hosted projectsGitLab monoreposLegacy enterprise / complex pipelines

Real-World Scenario: Full Pipeline

A production Node.js app deploying to Cloudflare Workers with Slack notifications:

name: Production Deploy

on:
  push:
    branches: [main]

concurrency:
  group: production-deploy
  cancel-in-progress: true

jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: "22"
          cache: "npm"
      - run: npm ci
      - run: npm run lint

  test:
    needs: lint
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: "22"
          cache: "npm"
      - run: npm ci
      - run: npm test -- --coverage

  build:
    needs: test
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: "22"
          cache: "npm"
      - run: npm ci
      - run: npm run build
      - uses: actions/upload-artifact@v4
        with:
          name: dist-${{ github.sha }}
          path: dist/
          retention-days: 14

  deploy:
    needs: build
    runs-on: ubuntu-latest
    environment: production
    steps:
      - uses: actions/checkout@v4
      - uses: actions/download-artifact@v4
        with:
          name: dist-${{ github.sha }}
          path: dist/

      - uses: cloudflare/wrangler-action@v3
        with:
          apiToken: ${{ secrets.CF_API_TOKEN }}
          accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}

      - name: Notify success
        if: success()
        run: |
          curl -s -X POST "${{ secrets.SLACK_WEBHOOK }}" \
            -d "{\"text\":\"✅ Deployed <https://github.com/${{ github.repository }}/commit/${{ github.sha }}|${{ github.sha }}> by ${{ github.actor }}\"}"

      - name: Notify failure
        if: failure()
        run: |
          curl -s -X POST "${{ secrets.SLACK_WEBHOOK }}" \
            -d "{\"text\":\"❌ Deploy FAILED for ${{ github.sha }} — check Actions tab\"}"

The artifact is named with the commit SHA (dist-${{ github.sha }}), which makes rollbacks straightforward: re-run the workflow on any previous commit and the correct artifact is retrieved.

Status Badges

Add a live build status badge to your README:

![Deploy](https://github.com/your-org/your-repo/actions/workflows/deploy.yml/badge.svg)

The badge automatically reflects the last run status — green for passing, red for failing. This gives instant visibility to everyone looking at the repository.

Gotchas and Edge Cases

Secret masking only works for exact matches. GitHub masks the literal secret value in logs, but if your secret is base64-encoded or URL-encoded before use, the original value appears unmasked. Always avoid echoing secrets, even for debugging.

Runner image changes break pinned tool versions. GitHub updates ubuntu-latest periodically. If your workflow relies on a specific pre-installed tool version (e.g., Python 3.11), explicitly install it with a setup action rather than relying on the runner image.

Concurrency cancel-in-progress: true cancels the current run, not the incoming one. The new push wins. This is almost always what you want for deployments, but can surprise you for long test suites — consider using cancel-in-progress: false for CI-only jobs.

Environment secrets are not available to fork pull requests. This is intentional — forks cannot access secrets to prevent exfiltration. If you need to run integration tests on PRs from forks, use a separate workflow triggered by pull_request_target (carefully — this has security implications).

needs creates implicit dependencies but not data sharing. Use outputs on a job and reference needs.job-id.outputs.key to pass data between jobs. Artifacts are the right tool for passing files.

Troubleshooting

Workflow does not trigger on push to main. Check the on.push.branches filter — branch name must match exactly. Also verify the workflow file is in .github/workflows/ (not .github/workflow/).

Secret is available but Wrangler authentication fails. Confirm the secret name matches exactly (case-sensitive). Verify the API token has the correct Cloudflare permissions: Workers Scripts: Edit and Account: Read.

Runner is offline. For self-hosted runners, check that the runner service is running: services.msc on Windows or systemctl status actions.runner.* on Linux. Ensure the runner has outbound HTTPS access to github.com and *.actions.githubusercontent.com.

Job waits indefinitely for environment approval. Verify you are listed as a required reviewer for the environment. Check that the environment name in the workflow YAML matches the name in Settings exactly (case-sensitive).

Concurrency group cancels unexpectedly. If multiple workflows share the same group string, they cancel each other. Use ${{ github.workflow }} in the group name to scope it to the workflow file.

Summary

  • Define workflows as YAML files in .github/workflows/ — they are committed alongside your code and versioned with it
  • Structure pipelines as sequential jobs: lint → test → build → deploy, using needs to enforce order
  • Use environment: production with required reviewers for human approval gates on critical deployments
  • Store all secrets in GitHub’s secrets store — never in the YAML file itself; prefer environment secrets for production credentials
  • Cloudflare Workers deploys use cloudflare/wrangler-action; VPS targets use SSH/rsync; IIS uses a self-hosted runner registered on the server
  • Add concurrency groups with cancel-in-progress: true to prevent overlapping deployments
  • Cache dependencies with actions/setup-node cache: npm or actions/cache for faster runs
  • Add a Slack or Teams notification step at the end of your deploy job — include the commit SHA and actor for traceability
  • Name build artifacts with ${{ github.sha }} to enable easy rollback by re-running the workflow on any previous commit