GitHub Actions Is an Automation Entry Point, Not a Deployment Machine

A practical review of where GitHub Actions works well, where it becomes the wrong execution environment, and how to draw a cleaner boundary for small project deployments.

My first experience with CI/CD was Travis CI on GitHub open source projects. Later, Travis became less attractive for personal projects, while GitHub Actions was built directly into the repository workflow. Blogs, documentation sites, and small open source projects naturally moved there.

The judgment at the time was simple: if the code lives on GitHub, automation should live next to it. Push code, run tests, build artifacts, publish packages, deploy the site. The experience was smooth.

Looking back after a few years, that judgment was only half right.

GitHub Actions is excellent for one-off automation around a repository: testing, linting, building, publishing npm packages, building Docker images, generating documentation, and notifying external systems. It is less suitable as the default execution environment for every deployment, especially when the target server is far away from the runner, the deployment needs to transfer many files, the process depends on server-local state, or the operational boundary should be clearer.

Chinese version of this article

This article revisits several related experiences:

  • Moving from Travis CI to GitHub Actions.
  • Publishing npm packages with Actions.
  • Building Docker images in Actions.
  • Changing this blog’s deployment from “Actions uploads the built files” to “Actions sends a signed webhook, and the server pulls, builds, and publishes locally.”

The short version is: GitHub Actions is a good automation entry point, but it should not automatically become the production deployment machine.

CI Belongs Close to the Repository

Travis CI was attractive in the early days because the setup was simple. Open source code was already on GitHub, and a .travis.yml file could run tests and builds after every push.

The downside was that CI lived in another system. Permissions, logs, triggers, caching, and deployment behavior all had to be understood across two platforms. Once GitHub Actions matured, putting CI back beside the repository became the natural choice.

Actions has several strengths:

  1. It is connected to GitHub events such as push, pull_request, release, and workflow_dispatch.
  2. Secrets, permissions, environments, and branch protection live in the same platform.
  3. The Marketplace covers many common tasks.
  4. Hosted runners cover Ubuntu, Windows, and macOS.
  5. Logs, checks, and pull request gates are part of the code review flow.

In an Mpx template project, I used a matrix to test generated projects across operating systems and Node.js versions:

strategy:
  matrix:
    os: [macos-latest, windows-latest, ubuntu-latest]
    node: [10, 12, 14]

That kind of verification is hard to do on one local machine and easy to do on cloud runners. Template projects are especially sensitive to “the generated project does not run,” and Actions can catch that kind of regression on every commit.

So CI is the strongest use case for GitHub Actions: if a task is stateless, repeatable, and tightly connected to repository code, it is usually a good fit.

Good Fit: Tests, Lint, and Builds

This is the least controversial use case.

name: test

on:
  pull_request:
  push:
    branches:
      - master

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 24
          cache: pnpm
      - run: corepack enable
      - run: pnpm install --frozen-lockfile
      - run: pnpm run lint
      - run: pnpm test
      - run: pnpm run build

These jobs have a clean shape:

  • The input is repository code and the lockfile.
  • The output is a test result or build artifact.
  • A failure can block a merge.
  • The job does not depend on production server state.
  • It can be rerun safely.

Within this boundary, Actions adds clear value: quality gates become automatic instead of relying on someone remembering to run commands locally.

Good Fit: Publishing npm Packages

Publishing npm packages also fits GitHub Actions well because it is essentially a transformation from repository state to a registry version.

A stable pattern is tag-based publishing:

name: publish

on:
  push:
    tags:
      - 'v*'

jobs:
  publish:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      id-token: write
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 24
          registry-url: https://registry.npmjs.org/
      - run: corepack enable
      - run: pnpm install --frozen-lockfile
      - run: pnpm test
      - run: pnpm run build
      - run: npm publish --provenance --access public

Two details matter here.

First, publishing should include tests and builds. A publish workflow is not only npm publish; it should encode the definition of “publishable.”

Second, npm trusted publishing should be the preferred direction when possible. It uses OIDC to establish trust between GitHub Actions and npm, reducing the need for long-lived npm tokens. If a project has not adopted trusted publishing yet, an npm automation token can still be a transitional option.

This use case works because Actions can connect tags, builds, tests, versions, and publish logs in one flow. The runner is temporary, but the publishing job should be temporary too.

Good Fit: Building and Pushing Docker Images

Docker image builds are also often a good fit, especially when the image is pushed to Docker Hub, GitHub Container Registry, or another registry.

A typical workflow looks like this:

name: docker

on:
  push:
    branches:
      - main

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: docker/setup-buildx-action@v3
      - uses: docker/login-action@v3
        with:
          username: ${{ secrets.DOCKERHUB_USERNAME }}
          password: ${{ secrets.DOCKERHUB_TOKEN }}
      - uses: docker/build-push-action@v6
        with:
          push: true
          tags: your-name/your-image:latest

The advantages are straightforward:

  • The build environment is clean.
  • Images can be tagged with a version, tag, or commit SHA.
  • The server only needs to pull and run the image.
  • Developers do not all need a complete Docker build environment locally.

The limit is runner resources. Normal web service images are usually fine. AI-related images can be different: Python, CUDA, PyTorch, and model dependencies can consume disk space quickly. I once built images related to A1111/Stable-Diffusion-WebUI and had to clean unused tools and caches from the runner before the build could finish.

That kind of cleanup can work, but it is not an infinite scaling strategy. If images keep growing, better options include:

  • Optimizing the Dockerfile and removing unnecessary dependencies.
  • Using BuildKit cache or registry cache.
  • Using GitHub larger runners.
  • Using self-hosted runners.
  • Moving the build closer to the target environment or to a dedicated build service.

Docker builds fit Actions as long as the resource scale still fits the runner. Once the workflow depends on forcing enough disk space out of a hosted runner every time, the boundary is already being stretched.

Poor Fit: Uploading Large Deployments to a Remote Server

This blog’s deployment change is a useful example of where Actions can become the wrong execution environment.

The earlier deployment flow was simple: GitHub Actions built the blog and synchronized the generated static files to the server. It worked for a while.

The problem was distance. The runner was overseas, while the server was in China. The build itself was not slow. Most of the time was spent transferring files across an unstable path. One deployment took nearly four minutes, and a large part of that time had little to do with application logic.

This kind of setup has several risks:

  • Network quality between the runner and the server is not under your control.
  • More static files mean more transfer time.
  • SSH keys or deployment tokens have to live in GitHub Secrets.
  • Failures require checking both Actions logs and server state.
  • Nginx, certificates, process managers, local caches, and directory permissions are not truly controlled by Actions.

The deployment flow now looks like this:

git push
  -> GitHub Actions
  -> send a signed webhook
  -> a NestJS service on the target server receives the notification
  -> the server runs git pull / pnpm install / build locally
  -> the result is switched into the Nginx site directory

Actions now does one light job: notification.

The workflow core is roughly:

name: deploy

on:
  push:
    branches:
      - master
  workflow_dispatch:

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

jobs:
  notify:
    runs-on: ubuntu-latest
    steps:
      - name: Notify deployment server
        env:
          DEPLOY_WEBHOOK_URL: ${{ secrets.DEPLOY_WEBHOOK_URL }}
          DEPLOY_WEBHOOK_SECRET: ${{ secrets.DEPLOY_WEBHOOK_SECRET }}
          REPOSITORY: ${{ github.repository }}
          REF: ${{ github.ref }}
          SHA: ${{ github.sha }}
        run: |
          payload="$(jq -cn \
            --arg repository "$REPOSITORY" \
            --arg ref "$REF" \
            --arg sha "$SHA" \
            '{ repository: $repository, ref: $ref, sha: $sha }')"

          signature="sha256=$(printf '%s' "$payload" \
            | openssl dgst -sha256 -hmac "$DEPLOY_WEBHOOK_SECRET" -binary \
            | xxd -p -c 256)"

          curl --fail-with-body --request POST "$DEPLOY_WEBHOOK_URL" \
            --header "Content-Type: application/json" \
            --header "X-Lihuanyu-Signature-256: $signature" \
            --data "$payload"

The important change is that Actions no longer carries the artifact. It sends a verifiable deployment request, and the actual deployment happens inside the target environment.

The benefits are direct:

  • The GitHub Actions job becomes shorter.
  • Large file transfers from an overseas runner to a domestic server disappear.
  • The server can reuse local Git, pnpm cache, and build environment.
  • Deployment logs live closer to Nginx, PM2, certificates, and system state.
  • GitHub Secrets only need a webhook URL and signing secret, not a server SSH private key.
  • The webhook can verify repository, branch, commit SHA, and HMAC signature.

There are costs too:

  • The server must maintain Node.js, pnpm, Git, build scripts, and deployment directory permissions.
  • The webhook service needs authentication, locks, logs, and error handling.
  • The initial clone or a bad GitHub network path can still be slow from the server side.
  • Concurrent pushes must be serialized.
  • Rollback has to be designed in the server deployment script.

But these are deployment-system concerns anyway. Handling them on the target server is closer to the real runtime environment.

Poor Fit: Long-Lived Operational State

GitHub-hosted runners are temporary machines for workflow jobs. They are not a place to keep important state.

That makes them a poor fit for tasks such as:

  • Saving important data outside normal build caches.
  • Running operations that depend on local machine state.
  • Performing tasks that need long manual observation.
  • Putting database migrations, service restarts, certificate updates, and directory switching into one fragile remote script.

Database migrations, Nginx reloads, PM2 reloads, certificate renewals, and static directory switches can be triggered by CI/CD. But the execution logic should usually live in the target environment, with clear logs, locks, and failure handling.

Actions can start a deployment. It does not always need to execute the deployment.

Poor Fit: Giving Secrets to Untrusted Code

Publishing, deployment, and image pushing all involve secrets.

The common risk is mixing untrusted code with powerful secrets. Workflows triggered by forked pull requests, third-party actions without pinned versions, dynamically downloaded scripts, and broad GITHUB_TOKEN permissions can all expand the blast radius.

My default rules are:

  • Set explicit minimum permissions.
  • Run publishing jobs only on tags, releases, or protected branches.
  • Run deployment jobs only from the main branch or manual triggers.
  • Pin third-party actions to clear versions; for critical paths, consider pinning to commit SHAs.
  • Prefer npm trusted publishing over long-lived npm tokens.
  • Prefer signed deployment webhooks over handing a server SSH key to every workflow.

Actions is good at automation, and automation means repeating something reliably. If the permission boundary is wrong, it also repeats the mistake reliably.

A Simple Decision Table

Scenario Fit for GitHub Actions Judgment
Lint, unit tests, type checks Good Stateless, repeatable, directly useful for code review.
Matrix tests across OS and Node versions Good Hosted runners cover platforms that are hard to reproduce locally.
Static site builds Good Artifacts are clear and failures are cheap.
npm package publishing Good Tags, tests, builds, and publishing can form one closed loop.
Docker image build and push Usually good Very large images may need larger runners, self-hosted runners, or dedicated builders.
GitHub Pages / Cloudflare Pages deployment Good The target platform is close to Actions and the flow is standardized.
Uploading many files to a distant server Not ideal Network path and transfer time can dominate the deployment.
Production local builds and directory switching Better on the server The execution is closer to the real environment, with clearer logs and permissions.
Database migrations and service restarts Be careful Actions can trigger them, but execution needs locks, logs, and rollback behavior.
Tasks requiring fixed IP or private network access Depends Larger runners, self-hosted runners, or server-side execution may be better.

How I Design Small Project Deployment Now

For a personal blog, admin tool, or small service, I would split responsibilities this way.

GitHub Actions handles:

  1. Tests, type checks, and builds during pull requests.
  2. Deployment notification after a push to the main branch.
  3. Standard artifact publishing, such as npm packages, Docker images, or documentation.
  4. Logging the commit SHA, actor, and workflow run URL.

The server handles:

  1. Verifying webhook signature, repository, branch, and commit SHA.
  2. Serializing deployments to avoid overlapping writes.
  3. Pulling code, installing dependencies, and running the build.
  4. Switching artifacts into the Nginx site directory.
  5. Recording deployment logs and supporting rollback when necessary.
  6. Managing Node.js, pnpm, PM2, Nginx, certificates, and system permissions.

This division is not complicated, but the boundary is cleaner: GitHub Actions acts as the trigger and quality gate, while the server executes production deployment inside the production-like environment.

Conclusion

When I first moved from Travis CI to GitHub Actions, I mostly cared about stability and convenience. That judgment still holds: GitHub Actions is a very good automation entry point for personal projects and open source projects.

After using it for npm publishing, Docker image building, and blog deployment, the boundary is clearer.

Tasks that are strongly tied to repository state, stateless, repeatable, and easy to rerun belong in Actions: tests, builds, package publishing, image building, documentation generation, and deployment notifications.

Tasks that depend on production server state, require large file transfers, involve long-lived operational permissions, or need detailed runtime handling should not automatically be pushed into Actions. For those cases, Actions is often better as the trigger than as the execution environment.

In one sentence: treat GitHub Actions as an automation entry point, not as the only deployment machine.

Further Reading