Guides / Separate deploy from CI

Too much work per run

Separate release and deploy from CI on pull requests

By Keith Mazanec, Founder, CostOps ยท Updated January 31, 2026

A developer opens a PR. The workflow builds the app, runs tests, pushes a Docker image to the registry, and deploys to staging, all before anyone reviews the code. Every PR update repeats the entire pipeline. You're billed for deploy minutes that produce nothing useful, and you risk accidental publishes to production registries. This is fixable by gating deploy and publish steps to main with a few lines of conditional logic.

Symptoms

How to tell if deploy steps are running on PRs

Open your Actions tab and look at the job list on any PR run. If you see deploy, publish, or release jobs executing, you have this problem.

  • Deploy jobs on PR runs. Your PR workflow shows jobs named deploy, publish, or release that execute on every push to a feature branch. These jobs succeed but their output is never used because nobody is reviewing a staging deploy from an unreviewed PR.

  • Docker images pushed from feature branches. Your container registry fills with images tagged from PR branches like myapp:feature-login-fix and myapp:dependabot-bump-react that no one will ever pull. Worse, if you tag with latest, PR builds overwrite your production image.

  • Duplicate build-then-deploy work. The same application is built twice in one pipeline: once in a build job for testing, and again inside the deploy job because it doesn't reuse artifacts. On PRs this doubles the wasted minutes.

Metrics

Quantify the waste from PR deploys

Deploy steps typically add 3–8 minutes per run. When they execute on every PR push across a team, the cost is straightforward to calculate. Here's a typical scenario for a team running 30 PR-triggered workflows per day on Linux:

Before: deploy on every PR

PR runs/day 30
Deploy minutes/run 5
Monthly deploy minutes 3,300
Monthly deploy cost $19.80/mo

At $0.006/min (Linux 2-core)

After: deploy only on main

PR runs/day 30
Deploy minutes/run 0
Monthly deploy minutes 0
Monthly deploy cost $0/mo

Save $19.80/mo · $238/year · per workflow

That's one workflow on Linux runners. If the deploy job includes a Docker build step on a larger runner, say an 8-core at $0.022/min, the same 3,300 minutes become $72.60/mo wasted. On macOS at $0.062/min, it's $204.60/mo. The deploy minutes go to zero in all cases once you gate to main.


Fix 1

Add conditional guards to deploy jobs

The simplest fix is an if: condition on the deploy job. GitHub evaluates this before the job starts, and if the condition is false, the job is skipped entirely and consumes zero minutes.

Check both the branch ref and the event type. A push to main means merged code ready for deployment. A pull_request event means unreviewed code that should only be tested.

Deploys on every PR push
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - run: npm test

  deploy:
    needs: test
    runs-on: ubuntu-latest
    # No condition - runs on PRs too
    steps:
      - run: ./deploy.sh
Deploys only on main
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - run: npm test

  deploy:
    needs: test
    if: github.ref == 'refs/heads/main' &&
        github.event_name == 'push'
    runs-on: ubuntu-latest
    steps:
      - run: ./deploy.sh

The same pattern works at the step level. If you want the Docker image to be built on PRs (to validate the Dockerfile) but not pushed, use a conditional on the push flag:

.github/workflows/ci.yml
- name: Build and push Docker image
  uses: docker/build-push-action@v6
  with:
    context: .
    push: ${{ github.event_name != 'pull_request' }}
    tags: myorg/myapp:latest

On PRs, the image builds (catching Dockerfile errors) but is not pushed. On merge to main, it builds and pushes. This is the pattern recommended by Docker's own GitHub Actions documentation.

Fix 2

Split CI and deploy into separate workflows

Instead of conditional logic inside one file, create two workflow files. The CI workflow runs on PRs. The deploy workflow runs only on pushes to main. No conditionals are needed because the trigger scope handles everything.

ci.yml (runs on PRs)
name: CI
on:
  pull_request:

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci && npm test

  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm run build
deploy.yml (runs on main)
name: Deploy
on:
  push:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci && npm test

  deploy:
    needs: test
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: ./deploy.sh

This is the cleanest separation. PR authors see only the CI workflow in their checks. Deploy runs only appear on main after merge. The tradeoff: you may duplicate some job definitions across files. If that bothers you, extract shared steps into a reusable workflow with workflow_call. See speeding up CI pipelines for more patterns.

One caveat: the deploy workflow above re-runs tests on main. If you trust your PR CI and want to skip redundant testing, remove the test job from the deploy workflow and use artifacts from the CI run instead (see Fix 3). For more on reducing full CI on branch pushes, see the dedicated guide.

Fix 3

Build once and deploy from artifacts

A common pattern is to rebuild the application inside the deploy job. This means the same code compiles twice in one pipeline: once for testing, once for deploying. On main, build once, share artifacts, upload the output, and download it in the deploy job. This eliminates the duplicate build and guarantees the deployed artifact is identical to what was tested.

.github/workflows/deploy.yml
name: Deploy
on:
  push:
    branches: [main]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci && npm run build
      - uses: actions/upload-artifact@v4
        with:
          name: build-output
          path: dist/

  deploy-staging:
    needs: build
    runs-on: ubuntu-latest
    environment: staging
    steps:
      - uses: actions/download-artifact@v4
        with:
          name: build-output
      - run: ./deploy.sh staging

  deploy-production:
    needs: deploy-staging
    runs-on: ubuntu-latest
    environment: production
    steps:
      - uses: actions/download-artifact@v4
        with:
          name: build-output
      - run: ./deploy.sh production

The build runs once. Both staging and production deploy the exact same artifact. GitHub Actions v4 artifacts include SHA256 digest verification, so the integrity of what you tested is preserved through deployment.

One caveat: artifacts are tied to a workflow run. If you need to deploy from a different workflow (e.g., a separate deploy workflow triggered by workflow_run), you'll need to use actions/download-artifact@v4 with the run-id and github-token inputs, and the calling workflow needs actions: read permission.

Fix 4

Use environment protection rules as a safety net

Even with if: conditions in your YAML, someone can remove them. GitHub's deployment environments provide a server-side safety net. Configure the production environment in your repository settings to restrict deployments to the main branch. Even if a workflow job references the environment, GitHub blocks it unless the branch matches.

Job referencing a protected environment
jobs:
  deploy:
    needs: build
    runs-on: ubuntu-latest
    environment: production  # Branch restriction enforced server-side
    steps:
      - uses: actions/checkout@v4
      - run: ./deploy.sh

Enterprise Cloud Required reviewers and wait timers for environment protection require GitHub Enterprise Cloud on private repositories. Deployment branch restrictions are available on Pro and Team plans for private repos. Public repositories get all environment features on every plan.

Environment protection rules give you three things beyond YAML conditions: branch restrictions (only main or tagged refs can deploy), required reviewers (a human must approve before the deploy job starts), and wait timers (a delay between approval and execution). Even if someone accidentally removes the if: condition from a workflow, the environment rule still blocks the deploy.

Environment secrets are also scoped, so deploy credentials stored in the production environment are not available to jobs that don't reference that environment. This means PR runs can't access your deploy keys even if the workflow YAML is misconfigured.


Reference

Tag-based release triggers for package publishing

For npm, PyPI, gem, or container publishing, the safest trigger is a version tag. Releases happen only when you explicitly push a tag like v1.2.3. There is no possibility of an accidental PR-triggered publish.

.github/workflows/publish.yml
name: Publish
on:
  push:
    tags:
      - 'v*.*.*'

jobs:
  publish:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          registry-url: https://registry.npmjs.org
      - run: npm ci && npm publish
        env:
          NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

This workflow only fires when a tag matching v*.*.* is pushed. Normal development pushes, PR updates, and merges to main do not trigger it. The publish is an intentional act that requires someone to run git tag v1.2.3 && git push --tags.

Reference

When should deploy steps run?

Use this table to decide which trigger is appropriate for each type of deploy-related step. The goal: PR runs should only build and test. Everything else waits.

Step type On PR On main On tag
Build / compile yes yes yes
Unit / integration tests yes yes optional
Docker build (no push) yes yes yes
Docker push to registry no yes yes
Deploy to staging no yes optional
Deploy to production no optional yes
npm / PyPI / gem publish no no yes

The pattern is consistent: anything that only validates code belongs on PRs. Anything that publishes or deploys belongs on main or a version tag. Blurring this line means paying for deploy minutes on every PR push and risking accidental releases from unreviewed code.

Related guides

Guides / Separate deploy from CI

See which workflows run deploy steps on PRs

CostOps breaks down step-level cost by category. Spot deploy, publish, and release steps that execute outside of main and see exactly how many minutes they waste.

Free for 1 repo. No credit card. No code access.

Built by engineers who've managed CI spend at scale.