From Broken Builds to Reliable Deployments with Vercel and GH Actions

7 min read

Blog post cover image

Turn a persistent build failure into a faster, more reliable deployment workflow.

By Nikolina Požega

How and Why I Moved My Vercel Build to GitHub Actions

A problem-solving sprint: why sometimes fixing an error isn’t about patching the log message, it’s about changing where the problem lives.

TL;DR

I couldn’t run rollup-critical-plugin on Vercel because of Node/Chromium/Puppeteer incompatibilities. I spent time trying to patch the environment on Vercel, but the right move was to move the build step to GitHub Actions, let it produce a prebuilt artifact, and have Vercel handle only the deploy. The result: a reliable pipeline that runs critical CSS generation (with Puppeteer) where I control the environment. This post is the investigation arc of that decision — the errors, the experiments, the logic that led me to the pivot, and the final outcome.

Why this post matters

This is not purely a how-to. It’s a story about why you sometimes stop trying to fix the symptom and instead change the context. If you’re a developer who’s seen similar “it worked on my machine” or “it fails in CI but not locally” errors, this will resonate. I’ll walk you through a practical example: the build fails on a remote platform because of deep dependency-environment mismatch, and the pragmatic, maintainable solution is to move the build where you control environment and caching.

The starting point — a broken build

It began innocently: a failed deploy on Vercel. The first error was straightforward and fixable — Vercel announced they were moving away from Node 18, so the project needed Node 22.x. That was an easy upgrade.

Then the next build failed with a different error: missing libraries required by Puppeteer/Chromium. I’d seen this before. My mind went through the checklist:

  • libnss3 missing? Add apt packages?
  • Puppeteer looking for a chromium executablePath that doesn’t exist in Vercel runtime?
  • Use an alternate chromium package?

I tried the usual things: add libnss3 through build config, try alternative chromium packages, adjust executablePath. None of it fixed the failure. Eventually a new error appeared: executablePath points to a path that Vercel’s environment simply doesn’t support.

That’s when the curveball idea appeared: instead of fighting the platform restrictions, run the part that needs special environment somewhere else.

Diagnosis — read the message, then read where it points

A crucial point in the investigation was disciplined error reading. Look not only at what the error says but where it comes from — the file, the dependency chain, and the tool that triggers it.

I traced the error chain:

  • The package that fails is rollup-critical-plugin.
  • That plugin uses Rollup plugins and ultimately runs Penthouse for critical CSS.
  • Penthouse relies on an older Puppeteer (v2.x-ish) / headless Chromium behavior that is brittle on Vercel’s newer Node environment.
  • The failing symptoms were a combination of missing system libs (like libnss3) and an unrecognizable executablePath in Vercel.

This was the key insight: the problem isn’t a single missing package, it’s a brittle dependency stack that expects a runtime Vercel can’t guarantee.

Two possible approaches (and why I picked the second)

  1. Fix the dependency chain in place

    • Fork Penthouse or the upstream package, update Puppeteer and rebuild, or shim the runtime in Vercel.
    • Pros: keeps everything on Vercel.
    • Cons: high maintenance, fragile (future changes in Vercel or packages could break it again), time sink, too much work.
  2. Change where the work is done (the pivot)

    • Run the critical CSS generation in GitHub Actions where I control Node, Chromium, Puppeteer, and apt packages.
    • Produce a prebuilt artifact (.vercel/output) and let Vercel do the final deploy.
    • Pros: pragmatic, maintainable, immediate results. I control the environment and caching.
    • Cons: changes the CI/CD architecture a bit — build is off Vercel now.

I chose option 2 because it minimized long-term maintenance and got me reliable results fast.

The implementation arc — what I actually did

This is the condensed, technical summary of how I implemented the pivot.

1. Offload build to GitHub Actions

  • Create a workflow that:

    • Checks out repository
    • Sets up Node 22
    • Ensures Puppeteer & Chromium can install (or uses cached binary)
    • Runs npm run build (which includes rollup + penthouse + puppeteer usage)
    • Uses Vercel CLI to vercel pull --environment= , vercel build --prod (or --environment=preview for preview), then vercel deploy --prebuilt

2. Solve Puppeteer/Chromium reproducibility in Actions

  • Cache Puppeteer’s Chromium binary at ~/.cache/puppeteer so we don’t download each run.
  • Install system deps (libnss3) only if the Puppeteer cache is missing.
  • Important Bash gotcha: use -d for directories when checking existence: (if \[ -d ~/.cache/puppeteer \]).

3. Keep Node install safe

  • Cache ~/.npm to speed installs, but always run npm install in the workflow — the npm cache speeds installs, but does not populate node_modules by itself.

4. Vercel prebuilt deploy details

  • Ensure .vercel/output target matches the deploy target:

    • Build production with vercel build --prod
    • For preview use default preview build (or explicit --environment=preview)
  • Avoid cross-environment pollution from .vercel/cache — I eventually disabled caching for .vercel/cache or made keys environment-specific so preview and production caches never mix.

  • Clean .vercel/output before a production build to be safe: rm -rf .vercel/output

5. Debug and iterate

  • I hit lots of small issues: wrong if syntax in bash, checking -f on directories, incorrect vercel build flags, cache key collisions.
  • Each was a small fix. The pattern was always the same: read logs carefully, identify the minimal diffs between expected and actual environment, and fix the smallest possible thing.

The mental model — how I approached this as a problem-solving sprint

I want to highlight the mindset and the steps that mattered most in this sprint. These are the practical habits that got me through the weeds:

  1. Read the error stack, not just the error line. The source file and dependency path tell you whether the problem is in your code or a third-party binary.
  2. Compare contexts. Things that work locally but fail on Vercel often mean the runtime differs (Node version, available libraries, binary paths).
  3. Try minimal, reversible changes. When you try a fix, make it small and confirm results quickly.
  4. Ask: can I move the work? If a platform is restrictive, sometimes the pragmatic solution is to do the thing somewhere else you control.
  5. Leverage caching, carefully. Cache speeds everything up but can also hide stale state. Use environment-encoded keys when caches can poison different targets.
  6. Accept the cost of maintenance tradeoffs. Forking a dependency could work but means ongoing maintenance. Offloading to CI means less future friction.

The result — what changed for me

  • The critical CSS generation now runs reliably in GitHub Actions.
  • Vercel receives a prebuilt artifact; it handles deployment only.
  • Build reliability is high and the CI process is deterministic.
  • Build times are much faster (once caches are warmed) and less error prone.
  • I gained room to further optimize the workflow (parallel steps, better caching, selective builds).

Short checklist you can apply to similar problems

  • When a build fails in CI:

    • Read full logs; identify the sub-stack that triggered failure.

    • Check runtime differences (Node version, OS libs, binaries).

    • Decide whether to patch the platform or move the step to controlled CI.

    • If moving to CI:

      • Ensure you can install required system packages or use cached binaries.
      • Make your build produce a deployable artifact.
      • Use explicit build flags (e.g., vercel build --prod) to avoid target mismatch.
      • Design cache keys so they don’t leak environment metadata across branches.

Fixing a failing build doesn’t always mean fixing the failing code. Sometimes you change your deployment model so the code can run where the environment is compatible.

Final thoughts

This was a small example of a bigger discipline: be pragmatic. Fixing one error by patching a platform is sometimes possible, but often the lowest-cost, highest-value move is to shift where the work happens so you can control the runtime. You end up with a cleaner, faster, and more maintainable flow — and that is what sustainable development looks like.

Nikolina Požega

Nikolina Požega

Nikolina is a software engineer, CTO at VibeIT, and a problem-solving enthusiast who loves optimizing workflows, debugging complex issues, and sharing the lessons learned along the way.