Why Real Pipelines Split Build From Deploy

The operational reasons a build job and a deploy job should never be the same job, and how a protected branch quietly becomes a security control.

September 5, 20265 min read4 / 4

The credentials warning from the last post has an obvious follow-up question: if a static key is that risky, what actually limits the damage until you fix it properly?

Part of the answer isn't a security feature at all. It's something that looks like a simple structural choice: keeping the job that builds your code completely separate from the job that ships it.

Build and deploy were never supposed to be one job

The two halves of the name give this away if you actually read them. Continuous integration is about proving a change is good. Continuous deployment is about getting a proven change out into the world. Those are different jobs with different failure tolerances, and cramming them into one script hides that difference instead of respecting it.

Picture a single job that installs dependencies, builds, runs tests, and deploys, all in sequence. Every one of those steps has to succeed for the run to succeed. If the build takes 45 minutes and the deploy step fails on step five, you're rerunning the entire 45 minutes to try step five again.

Split them, and a failed deploy only costs you the deploy. A four-hour build that only needs a nine-second deploy afterward should never force you to redo four hours of work for nine seconds of work.

What "needs" actually buys you

GitHub Actions expresses this split with needs, which declares an explicit dependency between jobs instead of implying one through step order.

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

needs: build means exactly what it says: deploy will not start until build finishes successfully. A failed build short-circuits everything downstream of it automatically, no manual guard required. The dependency graph already refuses to run it.

That one line also unlocks something more useful than failure isolation on its own: the artifact build uploads becomes a real, reusable object, not just a byproduct of one pipeline run.

One tested build artifact reused across every deploy target, with none of them triggering a rebuild ExpandOne tested build artifact reused across every deploy target, with none of them triggering a rebuild

Once a build produces a tested artifact, that same artifact can ship to staging, production, and a preview environment, without rebuilding for any of them.

If you're testing against a fresh npm run dev server instead of the real production build, you're not testing what ships. You're testing something close to it. Building once and reusing that exact artifact everywhere closes that gap.

A protected branch is a security control wearing a different name

Here's the part that surprised me most. Branch protection is usually framed as a code-quality feature: require passing checks, require a review, don't let anyone push straight to main. All true. But once your deploy job only runs on pushes to a protected branch, something else falls out of that setup almost for free.

No developer can trigger a production deploy directly. Not because you told them not to. Because the only trigger that fires the deploy job is a push to main, and main only accepts changes that already passed review and checks.

That's a real answer to a real audit question: can a human affect a production deployment on their own? With this setup, no. The deploy job, and the credentials it holds, are reachable only through the same gate that already enforces code review.

It's not airtight. Someone could technically copy the deploy job into a new workflow file and rename it. But it closes off the easy, accidental path, which is the path almost every real incident actually comes from. Nobody sits down and plans to bypass a security control. They forget it exists, or they didn't know it applied to them.

Different jobs, different credentials

Splitting build from deploy also means the two jobs don't need the same access to anything.

A build job checks out code, installs dependencies, and produces an artifact. It never touches your cloud account, so it never needs a credential that can write to it. A deploy job is the only one that needs write access, and only to the specific resources it deploys to.

Handing both jobs the same broad, long-lived key from the last post means the build job is carrying write access it will literally never use. Scoping credentials to the job that actually needs them is the natural next step once build and deploy already live in separate jobs. It's a smaller change than it sounds like, because the separation that makes it possible is already there.

This is the same principle behind scoping an IAM policy to exactly what a role needs instead of reaching for AdministratorAccess: give a job, or a role, exactly the access the task requires, nothing carried along out of convenience.

The Essentials

  1. CI and CD are different jobs with different failure tolerances. Merging them into one script hides that difference instead of respecting it.
  2. A failed deploy shouldn't cost you a rebuild. Splitting the jobs means a nine-second deploy failure never forces a four-hour build to run again.
  3. needs makes a failed build short-circuit deployment automatically. No manual guard required, the dependency graph enforces it.
  4. One tested artifact should ship to every target. Rebuilding for each environment risks testing something slightly different from what actually deploys.
  5. A protected branch doubles as a security control. If deploy only triggers on a protected branch, no developer can trigger a production deploy on their own.
  6. Separate jobs unlock separate, scoped credentials. A build job never needs write access it's never going to use.

Further Reading and Watching