TL;DR — Quick Summary
GitHub Actions deployment guide: automate pipelines with environment protection, secrets, multi-target deploys, caching, and rollback strategies.
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:
- Go to Settings → Environments in your repository
- Click New environment and name it
production - Enable Required reviewers — add one or more GitHub users or teams
- Optionally set a Wait timer (e.g., 10 minutes) before the job is allowed to run
- 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:
- Go to Settings → Actions → Runners → New self-hosted runner
- Download and install the runner application on the Windows server
- 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
| Feature | GitHub Actions | GitLab CI | Jenkins |
|---|---|---|---|
| Hosting | GitHub-hosted (SaaS) | GitLab-hosted or self-hosted | Self-hosted only |
| Config file | .github/workflows/*.yml | .gitlab-ci.yml | Jenkinsfile (Groovy) |
| Free tier | 2,000 min/month (private) | 400 min/month (private) | Unlimited (your infra) |
| Marketplace | 20,000+ actions | Limited | 1,800+ plugins |
| Environment gates | Built-in (free for public) | Built-in (requires Ultimate for rules) | Via plugins |
| OIDC | AWS, GCP, Azure, Cloudflare | AWS, GCP, Azure | Via plugins |
| Self-hosted runners | Yes (any OS) | Yes (any OS) | Yes |
| Matrix builds | Native YAML | Native YAML | Via plugins |
| Learning curve | Low | Low | High (Groovy DSL) |
| Best for | GitHub-hosted projects | GitLab monorepos | Legacy 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:

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
needsto enforce order - Use
environment: productionwith 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
concurrencygroups withcancel-in-progress: trueto prevent overlapping deployments - Cache dependencies with
actions/setup-node cache: npmoractions/cachefor 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