How to Build a CI/CD Pipeline With GitHub Actions: Step-by-Step (2026)

Oct 31, 2026 · 18 min · Ajay Kumar

This guide is for developers who have a repository on GitHub and want a real CI/CD pipeline, not a single workflow that runs npm test and hopes. By the end you will have a pipeline that lints and tests every pull request, builds a Docker image once with Buildx and the GitHub Actions cache, pushes it to GitHub Container Registry (GHCR), deploys that exact digest to staging, waits for a human to approve production, and deploys the same digest there, with every action pinned to a commit SHA. Versions are the latest releases as of 31 October 2026: actions/checkout v7.0.1, actions/setup-node v7.1.0, docker/setup-buildx-action v4.4.1, docker/login-action v4.7.0, docker/metadata-action v6.2.0, docker/build-push-action v7.4.0 and actions/attest v4.2.2, on ubuntu-24.04 runners with Node.js 24.22.2 LTS. I ran the sample app's tests and the server-side deploy guard locally and parsed both workflow files; hostnames are placeholders. You need admin on a GitHub repository, the gh CLI, and a Linux server with Docker Engine and the Compose plugin. If you want the concepts first, read What Is CI/CD? Continuous Integration vs Delivery vs Deployment.

The pipeline in this guide: one image digest from pull request to production pull_request runs these two (no push) push to main only: deploy the same digest testnpm ci, lint, testevery PR and push buildbuildx, gha cachepush, attest: main deploy-stagingenv: stagingssh + smoke test approval gaterequired reviewerenv: production deploy-productionsame digestssh + smoke test Workflow concurrency: head_ref or run_id group, cancel-in-progress, so a new PR push cancels its old run Job concurrency: one deploy per environment at a time, cancel-in-progress false Every uses: line pinned to a full commit SHA; Dependabot bumps the pins weekly after a 7-day cooldown Server: a restrict,command= key that accepts only ghcr.io/owner/repo@sha256:... and runs compose up --wait
Jobs and gates in the two workflow files built below. The approval gate is the production environment's required-reviewer rule, applied before the deploy-production job starts.

Step 1: Create a small app with tests, lint and a Dockerfile

If you already have an app with npm test, npm run lint and a Dockerfile, skip to Step 2. Otherwise, this is the smallest Node.js service that gives the pipeline something real to do. It uses Node's built-in test runner, so the only dev dependencies are ESLint and its config.

hello-ci/
├── .github/workflows/ci-cd.yml   (Steps 2, 3 and 6)
├── .github/workflows/deploy.yml  (Step 6)
├── .github/dependabot.yml        (Step 7)
├── Dockerfile
├── .dockerignore
├── eslint.config.js
├── package.json
├── src/app.js
├── src/server.js
└── test/app.test.js
{
  "name": "hello-ci",
  "version": "1.0.0",
  "private": true,
  "type": "module",
  "engines": { "node": ">=24" },
  "scripts": {
    "start": "node src/server.js",
    "lint": "eslint .",
    "test": "node --test"
  },
  "devDependencies": {
    "@eslint/js": "10.0.1",
    "eslint": "10.13.0",
    "globals": "17.13.0"
  }
}
// src/app.js: the handler is separate from the listener so tests can call it directly.
export function handler(req, res) {
  if (req.url === '/healthz') {
    res.writeHead(200, { 'content-type': 'text/plain' });
    return res.end('ok');
  }
  res.writeHead(200, { 'content-type': 'application/json' });
  res.end(JSON.stringify({ service: 'hello-ci', version: process.env.APP_VERSION ?? 'dev' }));
}

// src/server.js
import { createServer } from 'node:http';
import { handler } from './app.js';
const port = Number(process.env.PORT ?? 3000);
createServer(handler).listen(port, () => console.log(`listening on :${port}`));
// test/app.test.js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createServer } from 'node:http';
import { handler } from '../src/app.js';

async function get(path) {
  const server = createServer(handler).listen(0); // ephemeral port
  const { port } = server.address();
  try {
    const res = await fetch(`http://127.0.0.1:${port}${path}`);
    return { status: res.status, body: await res.text() };
  } finally {
    server.close();
  }
}

test('healthz returns ok', async () => {
  const { status, body } = await get('/healthz');
  assert.equal(status, 200);
  assert.equal(body, 'ok');
});

test('root returns service name', async () => {
  const { body } = await get('/');
  assert.equal(JSON.parse(body).service, 'hello-ci');
});
// eslint.config.js (ESLint 10 flat config)
import js from '@eslint/js';
import globals from 'globals';
export default [js.configs.recommended, { languageOptions: { globals: globals.node } }];

The Dockerfile pins the base image by version and digest so a rebuild next month produces the same base layer. Dependabot will bump both in Step 7.

# syntax=docker/dockerfile:1
FROM node:24.22.2-alpine3.24@sha256:8a32c8cf649a49915a66e81e7a609223c708d0adabf288ee3f4077d765542f85 AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev

FROM node:24.22.2-alpine3.24@sha256:8a32c8cf649a49915a66e81e7a609223c708d0adabf288ee3f4077d765542f85
ENV NODE_ENV=production
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY package.json ./
COPY src ./src
ARG APP_VERSION=dev
ENV APP_VERSION=$APP_VERSION
USER node
EXPOSE 3000
CMD ["node", "src/server.js"]
# .dockerignore
node_modules
.git
.github
test

Generate the lockfile once (npm ci refuses to run without it), then run the tests. Your timings will differ:

npm install
npm test
✔ healthz returns ok (35.054917ms)
✔ root returns service name (4.467417ms)
ℹ tests 2
ℹ suites 0
ℹ pass 2
ℹ fail 0

Commit package-lock.json. Node 24's default reporter is spec even when output is not a terminal, so CI logs look the same as the above.

Step 2: Add the CI job that lints and tests every pull request

Create .github/workflows/ci-cd.yml. The top of the file sets three things that matter more than they look: a read-only default token, a concurrency group, and pinned actions.

name: ci-cd

on:
  pull_request:
    branches: [main]
  push:
    branches: [main]

# Default for every job: read the repo, nothing else. Jobs opt in to more.
permissions:
  contents: read

# PRs: a new push to the branch cancels its older run.
# main: github.head_ref is empty, so each run gets its own group (run_id) and nothing is cancelled.
concurrency:
  group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
  cancel-in-progress: true

env:
  REGISTRY: ghcr.io

jobs:
  test:
    runs-on: ubuntu-24.04
    timeout-minutes: 10
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          persist-credentials: false   # no job here needs to push to git
      - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
        with:
          node-version: 24
          cache: npm                   # caches ~/.npm keyed on package-lock.json
      - run: npm ci
      - run: npm run lint
      - run: npm test

Why these choices. The workflow syntax docs say that once you specify any permission, every unspecified one is set to none, so contents: read at the top is a real least-privilege default. timeout-minutes: 10 replaces the default of 360, which is six hours of billing for a hung test. The concurrency expression is the one GitHub's concurrency docs recommend: github.head_ref only exists on pull request events, so PR runs share a group per branch and cancel each other, while pushes to main fall back to the unique run_id and never cancel a run that might be mid-deploy. Grouping on github.ref with cancel-in-progress: true would cancel main-branch deploys, the most common concurrency mistake I see. If dependency installs are your bottleneck, I covered cache keys and sharding in How to Speed Up GitHub Actions CI.

Step 3: Build with Buildx and push the image to GHCR

Add the build job under jobs:. On pull requests it builds the image without pushing (proving the Dockerfile still works); on main it logs in, pushes, and records the digest.

  build:
    needs: test
    runs-on: ubuntu-24.04
    timeout-minutes: 20
    permissions:
      contents: read
      packages: write          # push to ghcr.io with GITHUB_TOKEN
      id-token: write          # attestation: OIDC token for Sigstore
      attestations: write      # attestation: store it
      artifact-metadata: write # attestation: storage record (org-owned repos)
    outputs:
      image: ${{ steps.name.outputs.image }}
      digest: ${{ steps.push.outputs.digest }}
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          persist-credentials: false

      # ghcr.io rejects upper-case names: OWNER/Repo becomes owner/repo
      - id: name
        run: echo "image=${REGISTRY}/${GITHUB_REPOSITORY,,}" >> "$GITHUB_OUTPUT"

      - uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1

      - if: github.event_name == 'push'
        uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - id: meta
        uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
        with:
          images: ${{ steps.name.outputs.image }}
          tags: |
            type=sha,format=long
            type=ref,event=branch
            type=ref,event=pr

      - id: push
        uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
        with:
          context: .
          push: ${{ github.event_name == 'push' }}
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          build-args: APP_VERSION=${{ github.sha }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

      - if: github.event_name == 'push'
        uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
        with:
          subject-name: ${{ steps.name.outputs.image }}
          subject-digest: ${{ steps.push.outputs.digest }}
          push-to-registry: true

A few details are easy to get wrong. GitHub's Container registry docs say a package published from a workflow with GITHUB_TOKEN is linked to that repository automatically, its first visibility is private, and it inherits the repository's access permissions. The type=gha cache comes from Docker's GitHub Actions cache docs, which also note that only the Cache service API v2 has worked since 15 April 2025, so self-hosted runners need Buildx 0.21.0 and BuildKit 0.20.0 or newer. The digest output of build-push-action is the value the deploy jobs use; tags are for humans.

The attestation step is optional. The actions/attest README lists the three permissions it needs, and GitHub's attestation docs say attestations alone give SLSA v1.0 Build Level 2. If the step fails on a private repository, check that your plan supports attestations there or remove it. To verify an image later:

gh attestation verify oci://ghcr.io/owner/hello-ci@sha256:<digest> --repo owner/hello-ci

Step 4: Create staging and production environments with required reviewers

Environments are where protection rules and deploy secrets live. In the UI it is Settings, Environments, New environment. The same thing with gh, run as a repository admin:

REPO=owner/hello-ci

# staging: no reviewers, deployable from main only
gh api --method PUT "repos/$REPO/environments/staging" --input - <<'JSON'
{"deployment_branch_policy": {"protected_branches": false, "custom_branch_policies": true}}
JSON
gh api --method POST "repos/$REPO/environments/staging/deployment-branch-policies" -f name=main -f type=branch

# production: one required reviewer, main only
REVIEWER_ID=$(gh api users/YOUR_GITHUB_LOGIN --jq .id)
gh api --method PUT "repos/$REPO/environments/production" --input - <<JSON
{
  "prevent_self_review": false,
  "reviewers": [{"type": "User", "id": $REVIEWER_ID}],
  "deployment_branch_policy": {"protected_branches": false, "custom_branch_policies": true}
}
JSON
gh api --method POST "repos/$REPO/environments/production/deployment-branch-policies" -f name=main -f type=branch

The environments REST API accepts up to six reviewers, any one of whom can approve, and requires protected_branches and custom_branch_policies to be opposite values. Set prevent_self_review to true once you have two or more reviewers; with it on and only one reviewer, nobody can approve your own pushes. Check your plan before relying on this: the deployments and environments reference says required reviewers and wait timers on Free, Pro and Team plans work only in public repositories, and environment secrets in private repositories need Pro, Team or Enterprise. A job cannot read environment secrets until a required reviewer approves it.

Step 5: Prepare the server with a deploy key that can only deploy

The target here is a Linux VM running Docker Compose. Cloud targets follow the same shape but should use OIDC instead of stored keys, as in How to Use GitHub Actions OIDC With AWS, GCP and Azure.

On the server, as root:

useradd --create-home --shell /bin/bash deploy
usermod -aG docker deploy   # the docker group is root-equivalent, which is why the key is fenced below
install -d -o deploy -g deploy -m 750 /opt/hello-ci

/opt/hello-ci/compose.yaml:

services:
  web:
    image: ${IMAGE:?set IMAGE to an image digest}
    restart: unless-stopped
    ports: ["127.0.0.1:3000:3000"]   # put your reverse proxy in front
    healthcheck:
      test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/healthz"]
      interval: 10s
      timeout: 3s
      retries: 3

/usr/local/bin/hello-ci-deploy (owned by root, mode 755, outside the directory deploy can write to). It runs as the SSH forced command, so the only thing CI can send is an image reference, and the script refuses anything that is not ghcr.io/<owner>/<repo>@sha256:<64 hex>:

#!/usr/bin/env bash
set -euo pipefail
ref="${SSH_ORIGINAL_COMMAND:-}"

if [[ ! "$ref" =~ ^ghcr\.io/[a-z0-9._-]+/[a-z0-9._-]+@sha256:[a-f0-9]{64}$ ]]; then
  echo "refusing image reference: '$ref'" >&2
  exit 64
fi

cd /opt/hello-ci
docker pull "$ref"
IMAGE="$ref" docker compose up --wait --wait-timeout 60   # waits for the healthcheck
echo "$(date -u +%FT%TZ) $ref" >> deploy-history.log
echo "deployed $ref"

Create one key pair per environment on your workstation, record the server's host key, and install the public key with OpenSSH's restrict and command= options. The sshd manual says restrict disables port, agent and X11 forwarding, PTY allocation and ~/.ssh/rc, and command= ignores whatever the client asked to run, passing it in SSH_ORIGINAL_COMMAND instead.

ssh-keygen -t ed25519 -N '' -C 'ci deploy hello-ci staging' -f deploy_key_staging
ssh-keyscan -t ed25519 staging.example.com > known_hosts_staging
ssh-keygen -lf known_hosts_staging   # compare with: ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub on the server

# on the server, one line in ~deploy/.ssh/authorized_keys:
# restrict,command="/usr/local/bin/hello-ci-deploy" ssh-ed25519 AAAAC3...rest-of-key ci deploy hello-ci staging

The server must be able to pull the private image. GitHub Packages only accepts a personal access token (classic), not a fine-grained one, so create a classic token with only read:packages and log in once as the deploy user (or make the package public):

sudo -iu deploy sh -c 'docker login ghcr.io -u YOUR_GITHUB_LOGIN --password-stdin' < ghcr_read_token.txt

Test the fence before CI does. The guard output below is what the script printed when I ran it locally:

ssh -i deploy_key_staging deploy@staging.example.com 'ghcr.io/owner/hello-ci:latest'
# refusing image reference: 'ghcr.io/owner/hello-ci:latest'

Store the key and host details on the environment, then repeat for production with its own key:

gh secret set DEPLOY_SSH_KEY --env staging --repo "$REPO" < deploy_key_staging
gh variable set DEPLOY_HOST --env staging --repo "$REPO" --body staging.example.com
gh variable set DEPLOY_KNOWN_HOSTS --env staging --repo "$REPO" < known_hosts_staging

Step 6: Add the reusable deploy workflow and wire staging to production

Both environments deploy the same way, so the steps live in one reusable workflow. Create .github/workflows/deploy.yml:

name: deploy

on:
  workflow_call:
    inputs:
      environment: { type: string, required: true }
      url:         { type: string, required: true }
      image_ref:   { type: string, required: true }
    secrets:
      DEPLOY_SSH_KEY: { required: true }

permissions:
  contents: read

jobs:
  deploy:
    runs-on: ubuntu-24.04
    timeout-minutes: 15
    environment:
      name: ${{ inputs.environment }}   # protection rules and secrets come from here
      url: ${{ inputs.url }}
    concurrency:
      group: deploy-${{ inputs.environment }}  # one deploy per environment at a time
      cancel-in-progress: false                # never kill a deploy half-way
    steps:
      - name: Deploy image by digest
        env:
          ENV_NAME: ${{ inputs.environment }}
          SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
          KNOWN_HOSTS: ${{ vars.DEPLOY_KNOWN_HOSTS }}
          DEPLOY_HOST: ${{ vars.DEPLOY_HOST }}
          IMAGE_REF: ${{ inputs.image_ref }}
        run: |
          test -n "$SSH_KEY" || { echo "::error::DEPLOY_SSH_KEY is empty in $ENV_NAME"; exit 1; }
          install -m 700 -d ~/.ssh
          printf '%s\n' "$SSH_KEY" > ~/.ssh/deploy_key && chmod 600 ~/.ssh/deploy_key
          printf '%s\n' "$KNOWN_HOSTS" > ~/.ssh/known_hosts
          # The server's forced command reads the image ref from SSH_ORIGINAL_COMMAND.
          ssh -i ~/.ssh/deploy_key -o StrictHostKeyChecking=yes "deploy@${DEPLOY_HOST}" "$IMAGE_REF"

      - name: Smoke test
        env:
          APP_URL: ${{ inputs.url }}
        run: curl --fail --silent --show-error --retry 10 --retry-delay 3 --retry-all-errors "$APP_URL/healthz"

Then add the two calling jobs to the end of ci-cd.yml:

  deploy-staging:
    needs: build
    if: github.event_name == 'push' && github.ref == 'refs/heads/main'
    uses: ./.github/workflows/deploy.yml
    with:
      environment: staging
      url: https://staging.example.com
      image_ref: ${{ needs.build.outputs.image }}@${{ needs.build.outputs.digest }}
    secrets:
      DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}

  deploy-production:
    needs: [build, deploy-staging]
    uses: ./.github/workflows/deploy.yml
    with:
      environment: production
      url: https://www.example.com
      image_ref: ${{ needs.build.outputs.image }}@${{ needs.build.outputs.digest }}
    secrets:
      DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}

The secrets: block looks redundant, since the key only exists on the environment, and it is the line people delete. GitHub's reusable workflow docs say the caller must still pass an environment secret, by name or with secrets: inherit, and if it does not, the secret resolves to an empty string with no error. required: true in deploy.yml turns a missing pass into a validation error, but the docs note it only checks that the caller passes the secret, not that it has a value. The test -n guard catches an environment where nobody set the key. Every value interpolated from an expression goes through env: rather than straight into the script, which keeps a crafted input from becoming shell code.

Push to main: test, build and deploy-staging go green, and deploy-production waits with a "Review deployments" button for the reviewer. Both deploy jobs receive the same image_ref, so production gets the bytes staging tested. If you would rather queue every commit for production than let a newer pending run replace an older one, the concurrency docs describe queue: max (up to 100 waiting runs), which cannot be combined with cancel-in-progress: true.

Step 7: Pin every action to a commit SHA and keep the pins fresh

Tags are mutable. In March 2025 the tags of tj-actions/changed-files were repointed at a malicious commit, and CISA's alert covers what leaked. A full SHA cannot be repointed. This is how I looked up the pins used above (it follows annotated tags to the commit):

#!/usr/bin/env bash
pin() {
  local repo=$1 tag=$2 type sha
  read -r type sha < <(gh api "repos/$repo/git/ref/tags/$tag" --jq '.object.type + " " + .object.sha')
  if [ "$type" = tag ]; then sha=$(gh api "repos/$repo/git/tags/$sha" --jq '.object.sha'); fi
  echo "uses: $repo@$sha # $tag"
}
pin actions/checkout v7.0.1
pin docker/build-push-action v7.4.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0

Then let Dependabot open the bump PRs. The cooldown option waits a few days after a release before proposing it, which gives the community time to notice a compromised version; for github-actions only default-days is supported. In practice Dependabot rewrites the trailing # v7.0.1 comment along with the SHA, but nothing verifies the comment, so read the diff.

# .github/dependabot.yml
version: 2
updates:
  - package-ecosystem: "github-actions"
    directory: "/"            # must be / to find .github/workflows
    schedule:
      interval: "weekly"
    cooldown:
      default-days: 7
    groups:
      actions:
        patterns: ["*"]
  - package-ecosystem: "npm"
    directory: "/"
    schedule:
      interval: "weekly"
    cooldown:
      default-days: 7
  - package-ecosystem: "docker"
    directory: "/"
    schedule:
      interval: "weekly"

Organisation and repository admins can make this mandatory: since GitHub's August 2025 policy change, the allowed-actions policy has a checkbox that fails any workflow using an action not pinned to a full SHA. Two version notes. Checkout v7 now refuses to check out fork pull request code under pull_request_target or workflow_run unless you set allow-unsafe-pr-checkout. And GitHub set Node 20's removal from runners for 23 September 2026, so old pins to Node 20 releases are a liability; the docker actions moved to Node 24 in their March 2026 majors and checkout has run on Node 24 since v5. The wider hardening list is in GitHub Actions Security in 2026.

Step 8: Require the checks before anything merges

A pipeline nobody is forced to wait for is a suggestion. In Settings, Rules, Rulesets, create a branch ruleset targeting the default branch, enable "Require a pull request before merging" and "Require status checks to pass", and add the test and build checks (they appear in the picker after the workflow has run once). Now a red build blocks the merge button for everyone, including administrators unless you grant them bypass.

Troubleshooting

Symptom Likely cause Fix
denied or 403 when pushing to ghcr.io Build job lacks packages: write, or a package with that name exists and is not linked to this repo Add the permission; in the package's settings, grant this repository Actions write access
repository name must be lowercase Owner or repo contains capitals and github.repository was used raw Lowercase it with ${GITHUB_REPOSITORY,,} as in Step 3
failed to reserve cache or error writing layer blob The event only has read access to the Actions cache Keep cache-from, drop cache-to for that event, or add ignore-error=true
This legacy service is shutting down on a self-hosted runner Old Buildx or BuildKit still using cache API v1 Upgrade to Buildx 0.21.0 and BuildKit 0.20.0 or later, or set version: latest on setup-buildx
deploy-production waits forever, approve is not possible Prevent self-review is on and the only reviewer pushed the commit Add a second reviewer or untick Prevent self-review
DEPLOY_SSH_KEY is empty in production The secret was never set on that environment, or required: true was removed and the caller stopped passing it Run gh secret set DEPLOY_SSH_KEY --env production, and pass it under secrets: by name or with secrets: inherit
Host key verification failed DEPLOY_KNOWN_HOSTS is missing, wrong or stale after a rebuild Re-run ssh-keyscan, compare the fingerprint on the server, update the variable
Server prints refusing image reference The ref is a tag, has capitals, or is empty because the build job did not push Check needs.build.outputs; deploy jobs only run on push to main
docker pull on the server returns unauthorized Server is not logged in to GHCR, or used a fine-grained token Log in with a classic token scoped to read:packages, or make the package public
Run fails before any step, saying an action must be pinned to a full-length commit SHA SHA pinning policy is enforced for the org or repo Replace the tag with the 40-character SHA and a version comment

FAQ

Should I pin GitHub Actions to a commit SHA or a version tag?

Pin to the full 40-character commit SHA and keep the version in a trailing comment. Tags can be moved to different code, which is exactly how the 2025 tj-actions compromise reached its users; a SHA cannot. Dependabot with a short cooldown keeps SHA pins current without much effort.

Can I use required reviewers on a private repository?

Only on GitHub Enterprise. GitHub's docs say that on Free, Pro and Team plans, required reviewers and wait timers are available only for public repositories, while environment secrets work in private repositories on Pro, Team and Enterprise. On a smaller plan, a separate workflow_dispatch workflow that only maintainers can trigger is the usual substitute.

Why deploy by digest instead of a tag like latest?

A digest names exact bytes; a tag names whatever was pushed last. Deploying @sha256:... guarantees production runs the image staging tested, makes rollbacks precise, and lets the server's forced command reject anything else. Tags are still useful for humans browsing the registry.

How do I roll back a bad deploy?

Every deploy appends its digest to deploy-history.log on the server. Send the previous digest through the same fenced key, for example ssh -i deploy_key_production deploy@host ghcr.io/owner/hello-ci@sha256:<previous>, and the script redeploys it after the healthcheck passes. Then fix forward with a revert commit so main matches what is running.

When I review someone else's pipeline in production, I check four things before reading any job logic: the top-level permissions block, whether every uses: is a SHA, whether deploy jobs receive a digest rather than a tag, and whether concurrency could cancel a deploy halfway. Those four decide how bad a compromised dependency or a fat-fingered merge can get; the test suite mostly does not. The official references worth bookmarking are the workflow syntax, deployments and environments, concurrency, Docker's cache backends for GitHub Actions and the Container registry pages.

Related: How to Set Up Argo CD for GitOps: Step-by-Step Guide (2026), How to Reduce Docker Image Size: Multi-Stage Builds Guide (2026) and How to Stop Secrets Leaking Into Git: Gitleaks, Pre-commit, CI (2026).

I'm Ajay Kumar — I build and operate PandaStack, an open-source Firecracker microVM cloud for AI agents, and write about the infrastructure and tooling around it.

Need this kind of infrastructure work? See what I do or email hello@ajayk.sh.


Related