DEV Community

Cover image for 5 GitHub Actions mistakes that will slow down (or break) your CI/CD pipeline.
Sonia
Sonia

Posted on • Originally published at thegoodshell.com

5 GitHub Actions mistakes that will slow down (or break) your CI/CD pipeline.

Most GitHub Actions tutorials get you to a green checkmark. Very few of them help you understand why your pipeline takes 8 minutes when it should take 2, or why your production deploy triggered from a feature branch PR at 11pm on a Friday.

After working with a lot of engineering teams setting up CI/CD from scratch, these are the patterns that come up again and again.


1. You're not caching dependencies and it's costing you minutes per run.

The single fastest win in any GitHub Actions pipeline is dependency caching. Most people skip it because the pipeline "works." It does work. It's just running npm install or pip install from scratch on every single run.

- name: Cache node modules
  uses: actions/cache@v4
  with:
    path: ~/.npm
    key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
    restore-keys: |
      ${{ runner.os }}-node-
Enter fullscreen mode Exit fullscreen mode

The hashFiles key is the part that matters: the cache invalidates automatically when your lockfile changes, so you always get fresh deps when you actually update something. When it hits, you skip the install entirely. On a mid-size Node project, this typically cuts 2–4 minutes per run.


2. You're pushing to Docker Hub when GHCR is sitting right there.

GitHub Container Registry (GHCR) is built into GitHub and works with the GITHUB_TOKEN that already exists in every workflow. No extra secrets, no separate account, no rate limiting surprises.

The catch that trips people up: you need to explicitly grant the packages: write permission in your job definition. Without it, the push will fail with a misleading auth error.

jobs:
  build-and-push:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write   # ← this line is required, not optional
Enter fullscreen mode Exit fullscreen mode

Then authenticate like this:

- name: Log in to GHCR
  uses: docker/login-action@v3
  with:
    registry: ghcr.io
    username: ${{ github.actor }}
    password: ${{ secrets.GITHUB_TOKEN }}
Enter fullscreen mode Exit fullscreen mode

No secrets to rotate, no third-party dependency, and images are scoped to your repo automatically.


3. Your production deploy is one accidental push away from triggering.

If your workflow deploys to production on every push to main, that's fine until someone force-pushes a fix, a bot commits a version bump, or a merge goes sideways.

The pattern that solves this is a separate deploy job with an if condition and needs chaining:

deploy-production:
  runs-on: ubuntu-latest
  needs: [lint-and-test, build-and-push]
  if: github.ref == 'refs/heads/main' && github.event_name == 'push'
  environment: production
Enter fullscreen mode Exit fullscreen mode

The environment: production line is the one most people miss. If you've configured environment protection rules in GitHub (Settings → Environments), this gates the deploy behind required reviewers or a manual approval. It's free on public repos and included in Team plans for private ones.

This means: automated deploys from main, but with a human checkpoint before anything touches production.


4. You're putting everything in secrets when half of it should be in vars.

GitHub has two distinct places for pipeline configuration: Secrets (encrypted, write-only, for credentials) and Variables (plaintext, readable in UI, for config values).

Most teams put everything in secrets. That means your APP_ENV=production or LOG_LEVEL=info is encrypted and invisible in the GitHub UI, which makes debugging and auditing unnecessarily painful.

Variables are accessed with the vars context:

env:
  APP_ENV: ${{ vars.APP_ENV }}
  LOG_LEVEL: ${{ vars.LOG_LEVEL }}
  DATABASE_URL: ${{ secrets.DATABASE_URL }}  # this one actually needs to be a secret
Enter fullscreen mode Exit fullscreen mode

Practical rule: if the value isn't a credential, a token, or a key, it belongs in vars.


5. You're pinning actions/checkout@v4 but running on ubuntu-latest.

This is a subtle one. Pinning action versions (e.g., actions/checkout@v4) is good practice, it prevents upstream changes from breaking your pipeline without warning.

But then running runs-on: ubuntu-latest undoes some of that stability. ubuntu-latest is an alias that GitHub updates periodically (currently ubuntu-24.04, soon to rotate again), and those updates can change pre-installed tool versions, breaking pipelines that depend on system-level tools.

If stability matters more than getting the latest runner features:

runs-on: ubuntu-22.04   # pinned, not latest
Enter fullscreen mode Exit fullscreen mode

You'll need to update it manually when the version reaches end-of-life, but you control when that happens, not GitHub's release schedule.


These are the patterns that separate a "it works" pipeline from one that's actually reliable in production. The full step-by-step guide covering the complete pipeline structure, including Kubernetes deploy jobs, multi-environment promotion workflows, and secrets management at scale, is at thegoodshell.com.

Happy to answer questions in the comments if you are working through any of these.

Originally published at thegoodshell.com

Top comments (0)