GitHub Actions Cheatsheet
What it is
GitHub Actions is GitHub's built-in CI/CD platform — YAML files in .github/workflows/ describe pipelines that trigger on pushes, pull requests, tags, schedules, and manual dispatches. Each workflow runs jobs on hosted runners (Ubuntu, macOS, Windows) or self-hosted ones, with first-class access to your repository, your release artifacts, and the GitHub API.
This sheet covers the syntax you reach for when GitHub Actions is part of a real deploy pipeline — the trigger patterns that fire on the right events, the caching strategies that turn a 5-minute npm install into a 10-second restore, the OIDC patterns that authenticate to AWS/GCP without long-lived secrets, and the matrix builds + reusable workflows that keep complex pipelines maintainable.
Quick reference
Workflow file structure
A workflow lives at .github/workflows/<name>.yml. The minimum viable file:
name: CI
on:
push:
branches: [main]
pull_request:
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
Three properties of this structure that matter throughout the rest of the sheet:
onlists the events that trigger the workflow.jobsare units of parallelism — each runs on its own runner unless you wire dependencies withneeds:.stepswithin a job run sequentially on the same runner. They share the working directory.
Triggers (on:)
on: push # any push to any branch
on:
push:
branches: [main, 'release/**'] # branch filter (glob supported)
tags: ['v*'] # tag filter
paths: ['src/**', '!**/*.md'] # path filter (exclamation = exclude)
pull_request:
types: [opened, synchronize, reopened] # PR lifecycle events
branches: [main]
schedule:
- cron: '0 6 * * *' # daily at 06:00 UTC
workflow_dispatch: # manual run from UI/CLI
inputs:
environment:
type: choice
options: [staging, production]
required: true
workflow_call: # callable from other workflows
release:
types: [published]
issues:
types: [opened]
workflow_dispatch is the trigger you actually want for production deploys — it requires a human to click the button (or call gh workflow run), so a force-push can't accidentally ship to prod. The Cron and crontab cheatsheet has the cron syntax reference if schedule: doesn't fire when you expected.
Permissions (least privilege)
# At workflow or job level
permissions:
contents: read # default for all unspecified scopes
pull-requests: write # write to PRs (comments, labels)
issues: write
id-token: write # required for OIDC to cloud providers
packages: write # push to GitHub Packages / GHCR
actions: read
deployments: write
pages: write
# Or grant nothing by default
permissions: {}
# Or grant everything (dangerous; only for trusted org-owned workflows)
permissions: write-all
Set permissions: explicitly at workflow level. The default GITHUB_TOKEN permissions changed in 2023 — older workflows may rely on broader defaults that no longer exist, and pinning them avoids the trap.
Concurrency (cancel in-progress runs)
# Cancel any in-progress run of the same workflow on the same branch
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
# Never cancel — queue instead
concurrency:
group: production-deploy
cancel-in-progress: false
For PR test runs: cancel-in-progress so pushing a fix kills the run for the old commit. For production deploys: never cancel — let each deploy finish in order.
Environments (deploy gates + secrets)
jobs:
deploy:
runs-on: ubuntu-latest
environment:
name: production # references repo Settings → Environments
url: https://app.example.com
steps:
- run: ./deploy.sh
Per-environment secrets (e.g. production vs staging AWS keys) live in GitHub Settings → Environments. Adding required reviewers to the production environment turns every deploy into a one-approval gate.
Matrix builds
jobs:
test:
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false # don't cancel siblings on one failure
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
node: ['18', '20', '22']
include:
- os: ubuntu-latest
node: '22'
coverage: true # extra var on one cell
exclude:
- os: windows-latest
node: '18' # skip this combination
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
- run: npm ci
- run: npm test
fail-fast: false is the right default for a test matrix — when Node 20 breaks but Node 22 is fine, you want both signals, not one.
Caching
- uses: actions/cache@v4
with:
path: |
~/.npm
node_modules
key: ${{ runner.os }}-node-${{ hashFiles('package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-
# Most language-specific setup actions have built-in cache support
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm' # auto-keyed off package-lock.json
- uses: actions/setup-python@v5
with:
python-version: '3.12'
cache: 'pip'
- uses: actions/setup-go@v5
with:
go-version: '1.22'
cache: true
- uses: ruby/setup-ruby@v1
with:
ruby-version: '3.3'
bundler-cache: true
The cache key MUST include the hash of the lockfile (package-lock.json, requirements.txt, Gemfile.lock). Keying off package.json alone misses dep changes that don't touch the manifest; keying off the branch name keeps serving stale caches forever. The same logic from the Composer cheatsheet's caching section applies here.
Secrets and contexts
steps:
- name: Use a secret
env:
DB_URL: ${{ secrets.DATABASE_URL }} # repo or org secret
AWS_KEY: ${{ secrets.AWS_ACCESS_KEY_ID }}
run: ./deploy.sh
- name: Use vars (non-secret config)
env:
APP_ENV: ${{ vars.APP_ENV }} # repo or org variable
run: echo "deploying to $APP_ENV"
# Common context values
- run: |
echo "SHA: ${{ github.sha }}"
echo "Ref: ${{ github.ref }}"
echo "Actor: ${{ github.actor }}"
echo "Event: ${{ github.event_name }}"
echo "Run: ${{ github.run_id }}/${{ github.run_attempt }}"
Secrets are masked in logs automatically. Echoing a secret to stdout still hides the value behind *** — but don't rely on that as security. Treat the secret as compromised the moment it touches echo.
Outputs between steps and jobs
jobs:
build:
runs-on: ubuntu-latest
outputs:
image_tag: ${{ steps.tag.outputs.tag }}
steps:
- id: tag
run: echo "tag=build-${GITHUB_SHA::8}" >> "$GITHUB_OUTPUT"
deploy:
needs: build
runs-on: ubuntu-latest
steps:
- run: echo "deploying ${{ needs.build.outputs.image_tag }}"
$GITHUB_OUTPUT is the file-based replacement for the deprecated ::set-output:: syntax — write key=value lines to it, and they surface as steps.<id>.outputs.<key>.
For environment variables that propagate between steps (not jobs), use $GITHUB_ENV:
- run: echo "RELEASE_TAG=v$(date +%Y%m%d)" >> "$GITHUB_ENV"
- run: echo "tagging release $RELEASE_TAG"
Artifacts and uploads
- uses: actions/upload-artifact@v4
with:
name: build-output
path: dist/
retention-days: 7 # default is 90; pick something sensible
# In a later job:
- uses: actions/download-artifact@v4
with:
name: build-output
path: dist/
Artifacts persist beyond the workflow run — useful for handing the built bundle from a build job to a deploy job without rebuilding. They're stored in GitHub and count against your storage quota, so trim retention aggressively.
Containers and services
jobs:
test:
runs-on: ubuntu-latest
container: node:20-alpine # run all steps INSIDE this container
services:
postgres:
image: postgres:16
env:
POSTGRES_PASSWORD: postgres
ports: ['5432:5432']
options: >-
--health-cmd "pg_isready -U postgres"
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- run: psql -h postgres -U postgres -c 'SELECT 1'
The services block is the cleanest way to test against real databases — Postgres, MySQL, Redis, RabbitMQ — without mocking. The --health-* options gate steps on the service being ready before they run.
Conditionals
jobs:
deploy:
runs-on: ubuntu-latest
# Skip on PRs from forks
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4
- name: Deploy to staging
if: github.ref == 'refs/heads/develop'
run: ./deploy-staging.sh
- name: Deploy to production
if: startsWith(github.ref, 'refs/tags/v')
run: ./deploy-production.sh
- name: Notify on failure
if: failure()
run: ./notify.sh
Common condition functions: success() (default for every step), failure(), cancelled(), always().
Reusable workflows (workflow_call)
# .github/workflows/deploy-reusable.yml
on:
workflow_call:
inputs:
environment:
type: string
required: true
secrets:
DEPLOY_KEY:
required: true
jobs:
deploy:
runs-on: ubuntu-latest
environment: ${{ inputs.environment }}
steps:
- uses: actions/checkout@v4
- run: ./deploy.sh
env:
KEY: ${{ secrets.DEPLOY_KEY }}
# .github/workflows/deploy-staging.yml — caller
on:
push:
branches: [main]
jobs:
deploy:
uses: ./.github/workflows/deploy-reusable.yml
with:
environment: staging
secrets:
DEPLOY_KEY: ${{ secrets.STAGING_DEPLOY_KEY }}
Reusable workflows are how you keep deploy-staging.yml, deploy-production.yml, and deploy-eu.yml from copy-pasting 200 lines each.
Composite actions (reuse step blocks)
# .github/actions/install-deps/action.yml
name: 'Install Deps'
description: 'npm install with cache + audit'
runs:
using: 'composite'
steps:
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
shell: bash
- run: npm audit --audit-level high
shell: bash
# In a workflow
- uses: ./.github/actions/install-deps
Composite actions encapsulate sequences of steps that show up in many workflows — npm install + audit + lint, Docker build + scan + push, etc.
Self-hosted runners
jobs:
build:
runs-on: [self-hosted, linux, x64, gpu] # match labels you assigned to runners
steps:
- run: nvidia-smi
Self-hosted runners are required for: GPU workloads, deploy targets behind a VPN, code that needs access to internal infrastructure, and any minute-budget-constrained shop running >50k CI minutes/month.
Deployment workflows (the moat)
The reference above is portable — every GitHub Actions tutorial has those snippets. The patterns below are the ones that matter once a workflow stops being CI and becomes a production deployer.
1. The minimal trustworthy deploy pipeline
A deploy workflow should: gate on a successful test run, pin to a specific environment with required reviewers, build and tag an immutable artifact, and roll back on failure. The minimum viable shape:
name: Deploy
on:
push:
tags: ['v*'] # deploy on every vX.Y.Z tag
workflow_dispatch:
inputs:
tag:
description: 'tag to deploy (e.g. v1.4.2)'
required: true
concurrency:
group: production-deploy
cancel-in-progress: false # NEVER cancel a deploy in progress
permissions:
contents: read
id-token: write # for OIDC
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:
needs: test
runs-on: ubuntu-latest
environment:
name: production
url: https://app.example.com
steps:
- uses: actions/checkout@v4
with:
ref: ${{ inputs.tag || github.ref }}
- name: Configure AWS via OIDC
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::111111111111:role/GitHubActionsDeploy
aws-region: us-east-1
- name: Build & push image
run: |
SHA="${GITHUB_SHA::8}"
docker build -t myapp:$SHA .
aws ecr get-login-password | docker login --password-stdin "$ECR_REGISTRY"
docker tag myapp:$SHA "$ECR_REGISTRY/myapp:$SHA"
docker push "$ECR_REGISTRY/myapp:$SHA"
- name: Update ECS
run: ./scripts/deploy-ecs.sh "$SHA"
- name: Smoke test
run: ./scripts/smoke-test.sh https://app.example.com
- name: Rollback on failure
if: failure()
run: ./scripts/rollback-ecs.sh
Five non-obvious decisions in this file:
tags: ['v*']ties the deploy to immutable Git tags (see the Git cheatsheet's tag-based releases pattern). A force-push tomaincan't trigger a production deploy.concurrency.cancel-in-progress: falsequeues deploys in order. The PR-test convention ofcancel-in-progress: truewould be catastrophic mid-deploy.environment: productionis what hooks up required-reviewer gates. Without an environment, anyone with push access can ship.needs: testblocks deploy on green CI. Don't trustif: success()on a workflow-level — make the dependency explicit.if: failure()rollback step runs only when an earlier step in the same job failed. Cheap insurance against a half-applied release.
2. OIDC instead of long-lived cloud credentials
Long-lived AWS keys in secrets.AWS_ACCESS_KEY_ID are a leak waiting to happen — they don't rotate, they show up in aws sts get-caller-identity logs, and a compromised runner exposes them indefinitely. OIDC replaces them with short-lived tokens that GitHub mints per workflow run.
permissions:
id-token: write # MANDATORY for OIDC
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::111111111111:role/GitHubActionsDeploy
aws-region: us-east-1
- run: aws sts get-caller-identity
The AWS-side setup is one-time: register GitHub as an OIDC provider in IAM, create a role with a trust policy that allows repo:org/app:ref:refs/heads/main (or repo:org/app:environment:production) to assume it. Now the runner authenticates as that role for 60 minutes — no secrets to rotate, no keys to leak.
Same pattern works for GCP and Azure. For the deeper AWS pattern, see the AWS CLI cheatsheet's multi-account section.
3. Build cache: lockfile-keyed, never branch-keyed
The single highest-ROI optimisation in any GitHub Actions workflow is correctly caching dependencies:
# Right: lockfile-keyed
- uses: actions/cache@v4
with:
path: ~/.npm
key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
restore-keys: npm-${{ runner.os }}-
# Wrong: branch-keyed (serves stale caches forever)
- uses: actions/cache@v4
with:
path: ~/.npm
key: npm-${{ runner.os }}-${{ github.ref }}
A cold npm ci on a real Node project is 60-180 seconds. A warm cache restore is 2-10 seconds. Multiplied across every CI run, every PR push, every deploy, this is one of the few optimisations that pays for itself in days.
For Docker builds, use docker/build-push-action with BuildKit cache:
- uses: docker/setup-buildx-action@v3
- uses: docker/build-push-action@v6
with:
context: .
push: true
tags: myapp:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
The type=gha cache backend stores BuildKit layers in GitHub's cache, scoped per workflow. First build is slow; every subsequent one is layer-by-layer fast.
4. Matrix tests + a single deploy
The common shape: parallel test matrix, single deploy job that depends on the whole matrix:
jobs:
test:
strategy:
matrix:
node: ['18', '20', '22']
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
cache: 'npm'
- run: npm ci
- run: npm test
deploy:
needs: test # waits for ALL matrix cells
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- run: ./deploy.sh
needs: test blocks deploy until every matrix cell passes. A single failure on Node 18 blocks the prod deploy until you fix or drop the version.
5. Reusable deploy workflow per environment
For multi-environment apps (staging + production + EU), put the deploy logic in one reusable workflow and call it with environment-specific inputs:
# .github/workflows/deploy.yml — the reusable engine
on:
workflow_call:
inputs:
environment:
type: string
required: true
url:
type: string
required: true
aws_role:
type: string
required: true
permissions:
id-token: write
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
environment:
name: ${{ inputs.environment }}
url: ${{ inputs.url }}
steps:
- uses: actions/checkout@v4
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ inputs.aws_role }}
aws-region: us-east-1
- run: ./scripts/deploy.sh ${{ inputs.environment }}
# .github/workflows/deploy-staging.yml — caller
on:
push:
branches: [main]
jobs:
deploy:
uses: ./.github/workflows/deploy.yml
with:
environment: staging
url: https://staging.example.com
aws_role: arn:aws:iam::111111111111:role/StagingDeploy
# .github/workflows/deploy-production.yml — caller
on:
push:
tags: ['v*']
workflow_dispatch:
jobs:
deploy:
uses: ./.github/workflows/deploy.yml
with:
environment: production
url: https://app.example.com
aws_role: arn:aws:iam::222222222222:role/ProdDeploy
The reusable workflow stays a single source of truth. Adding a new environment is a 10-line caller, not a 100-line copy.
6. The gh CLI for ad-hoc + scripted control
GitHub's gh CLI is the missing piece for treating Actions as scriptable infrastructure:
# Trigger a workflow manually
gh workflow run deploy.yml -f environment=production -f version=v1.4.2
# Watch a run live
gh run watch # picks the most recent
gh run view 123456789 --log
# Rerun a failed job
gh run rerun 123456789 --failed # only failed jobs
gh run rerun 123456789 # all jobs
# Cancel a stuck run
gh run cancel 123456789
# List runs for a workflow
gh run list --workflow deploy.yml --limit 20
# Download an artifact
gh run download 123456789 --name build-output
gh workflow run is the right interface for production deploys triggered from a release-management bot, a Slack /deploy slash command, or just a developer's ~/.bin/deploy-prod script.
7. Pin actions by SHA, not by tag
The supply-chain risk every Actions repo faces: a third-party action gets compromised, the attacker pushes a new commit and force-pushes the tag you reference, and your next workflow run executes their code with your secrets.
# Risky: tag is mutable
- uses: actions/checkout@v4
- uses: my-org/some-action@v1
# Safer: SHA is immutable
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
- uses: my-org/some-action@a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0
GitHub itself recommends SHA-pinning for third-party actions. Tools like pin-github-action automate this — they take a @v1 reference, resolve it to a SHA, and rewrite the file with a comment showing the original tag.
First-party actions (actions/checkout, actions/cache) are lower-risk but the same logic applies for high-stakes deploy workflows.
8. Status badges and PR gates
Wire branch protection to required status checks so a PR can't merge until the workflow passes:
# README badge
[](https://github.com/org/app/actions/workflows/ci.yml)
# Configure branch protection (or use the UI: Settings → Branches → Branch protection rules)
gh api repos/org/app/branches/main/protection \
-X PUT \
-F required_status_checks.strict=true \
-F required_status_checks.contexts[]=test \
-F enforce_admins=true \
-F required_pull_request_reviews.required_approving_review_count=1
enforce_admins=true is the line most teams skip — without it, admins can bypass the very protections that exist to catch them on a bad day.
9. Triggering a DeployHQ deploy from GitHub Actions
If GitHub Actions handles your CI but you want a real deploy system handling the SSH/rsync/symlink mechanics, two integration patterns wire them together: the dhq CLI (full control, synchronous status) and the project webhook (zero-install, fire-and-forget).
Pattern A — dhq CLI with synchronous wait. The right choice for production where the workflow result should reflect the deploy outcome:
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:
needs: test
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
environment:
name: production
url: https://app.example.com
steps:
- name: Install dhq
run: curl -sSL https://www.deployhq.com/install/cli | sh
- name: Trigger DeployHQ deploy
env:
DHQ_EMAIL: ${{ secrets.DHQ_EMAIL }}
DHQ_API_KEY: ${{ secrets.DHQ_API_KEY }}
run: dhq deploy create -p myapp -s production --wait --json
--wait blocks the step until DeployHQ reports the deploy as complete or failed — so the GitHub Actions job result is a faithful signal of the deploy outcome (instead of just "the webhook fired"). --json gives you structured output to parse for Slack notifications, GitHub Deployment status updates, or release-note generation.
For multi-environment branching:
- name: Trigger deploy
env:
DHQ_EMAIL: ${{ secrets.DHQ_EMAIL }}
DHQ_API_KEY: ${{ secrets.DHQ_API_KEY }}
run: |
case "${{ github.ref }}" in
refs/heads/main) dhq deploy create -p myapp -s production --wait ;;
refs/heads/develop) dhq deploy create -p myapp -s staging --wait ;;
refs/tags/v*) dhq deploy create -p myapp -s production --wait ;;
*) echo "no deploy for $GITHUB_REF"; exit 0 ;;
esac
Alternative install on a hosted runner where Homebrew is already present:
- run: brew install deployhq/tap/dhq
Pattern B — project webhook. The lowest-friction integration. Every DeployHQ project has a unique webhook URL that triggers a deploy when posted to:
- name: Trigger DeployHQ webhook
run: |
curl -fsS -X POST "${{ secrets.DEPLOYHQ_WEBHOOK_URL }}" \
-H "Content-Type: application/json" \
-d "{\"ref\":\"${GITHUB_SHA}\"}"
The webhook returns as soon as DeployHQ accepts the trigger — not when the deploy completes. Right for staging environments or background auto-deploys where synchronous feedback in the workflow isn't required.
Why split CI and deploy. GitHub Actions is excellent at the parallel-burst workload CI demands. Deploys are sequential, stateful, and want long-lived SSH keys, atomic release directories, and per-environment server inventory — exactly what DeployHQ is built for. The CLI/webhook integration lets you keep the CI minutes you've already paid for while keeping production SSH keys out of GitHub Actions' secret store entirely. The full trade-off is in DeployHQ vs GitHub Actions; the agents page covers the CLI's full command set.
Common errors and fixes
| Error / symptom | Cause | Fix |
|---|---|---|
Error: Resource not accessible by integration |
GITHUB_TOKEN lacks the scope you're using |
Add the required scope to permissions: at workflow or job level |
| Workflow doesn't trigger on push | paths: filter excluded all changed files, or branch filter doesn't match |
Inspect git log --name-only -1 against your paths: config; paths-ignore is the common gotcha |
Error: The process '/usr/bin/git' failed with exit code 128 |
Token lacks contents: read, or the repo is on a fork PR |
Set permissions.contents: read. For fork PRs, you can't access secrets — gate deploy on github.event.pull_request.head.repo.full_name == github.repository |
| Cache hit rate is 0% | Cache key includes a value that changes every run (e.g. ${{ github.sha }}) |
Use hashFiles('package-lock.json') instead of branch/SHA in the key |
Error: Container action is only supported on Linux |
A Docker-based action triggered on macOS/Windows runner | Use composite actions instead, or split that job to a Linux runner |
Error: This run might fail because the previous attempt was a different commit |
Reran a workflow attempt on a different SHA | Click "Re-run all jobs" instead of "Re-run failed jobs" when the underlying SHA matters |
Secrets show as *** even when used incorrectly |
GitHub masks every secret value in logs, but the workflow still runs with the wrong value | Print hashFiles() on a path containing the secret, or compute a checksum — never echo the raw value |
| Job stuck in queue for hours | Quota exhausted or self-hosted runner offline | Check Settings → Actions → Runners. Public repos have 20-runner concurrent limit on free plans |
Error: The runner failed to send the API request to the server |
Transient GitHub Actions infrastructure issue | Re-run the failed job. If recurring, status.github.com will show it |
OIDC token retrieval failed |
permissions.id-token: write missing, or trust policy mismatched |
Add the permission; verify the trust policy's sub matches repo:org/app:ref:refs/heads/main |
npm ci slower than cold install |
Cache is hitting but node_modules isn't being restored — you cached ~/.npm but npm ci rebuilds |
Either cache node_modules directly (with the right key), or rely on actions/setup-node's built-in cache: 'npm' |
bash: command not found for an installed tool |
The install step modified $PATH, but the new step's shell didn't pick it up |
Append to $GITHUB_PATH: echo "/opt/mytool/bin" >> "$GITHUB_PATH" |
Artifact upload fails: Argument list too long |
Glob expanded to thousands of files | Tar before uploading: tar -czf out.tar.gz dist/; upload the tarball |
Error: cannot find module 'X' in Action |
Action's node_modules wasn't committed |
Action authors must commit node_modules/ for Node actions. Use a maintained alternative |
| Deploy succeeded but production didn't change | Deploy ran in staging environment instead of production |
Check environment: value in the deploy job and the if: gate (typical fail: if: github.ref == 'refs/heads/master' when the branch was renamed to main) |
| Workflow file edits don't take effect | YAML syntax error parses as no triggers | gh workflow view <name> shows the parsed config; actionlint catches most YAML+expression bugs locally |
Error: Process completed with exit code 1 and nothing else |
set -e in a run: script masked the actual error |
Add set -euxo pipefail at the top of the run block; rerun |
Companion: GitHub Actions or DeployHQ?
GitHub Actions is excellent at CI — running your test matrix, building artifacts, validating PRs. It works as a deployer too, but the trade-off becomes clearer the more environments and servers you operate:
- CI is bursty and parallel. GitHub Actions' free minute pool, hosted runners, and matrix builds are tuned for this.
- Deploys are serial and stateful. They need SSH access to long-lived servers, atomic release directories, predictable rollback, and (in agency contexts) per-client server credentials managed by humans, not committed YAML.
DeployHQ specialises in the deploy side: project-scoped SSH keys per server, zero-downtime atomic releases, one-click rollback, per-client server inventory, and a build pipeline that runs the same build container on every deploy — without exposing your production SSH keys to GitHub Actions secrets.
The common pattern: GitHub Actions runs CI (tests, lint, type-check), then triggers DeployHQ via webhook for the deploy. You keep the CI minutes you've already paid for, you keep deploy credentials out of GitHub Actions' secret store, and DeployHQ handles the SSH/rsync/symlink-swap mechanics that GitHub Actions deploys re-invent badly.
The full comparison — strengths, gaps, and the integration pattern — is in DeployHQ vs GitHub Actions. The build pipelines page covers how the build-then-deploy split works in practice. Setup walkthroughs for the GitHub side are in deploy from GitHub to your server.
Start a free DeployHQ trial to wire GitHub Actions CI to a real deploy pipeline.
Related cheatsheets
- Git cheatsheet — for the tag-based release pattern every production deploy workflow keys off.
- Docker cheatsheet — for the multi-stage builds and
docker/build-push-actioncache strategies behind every container deploy. - AWS CLI cheatsheet — for the multi-account OIDC pattern and ECS/CloudFormation steps Actions deploy workflows call.
- Bash scripting cheatsheet — for the
set -euxo pipefailwrappers that turnrun:blocks into reliable scripts. - Cron and crontab cheatsheet — for the
schedule:trigger's cron syntax. - Cheatsheets hub — every DeployHQ cheatsheet in one place.
Need help? Email support@deployhq.com or follow @deployhq on X.