DevOps

Fixing Next.js Docker Build OOM Kills in CI

Asep Alazhari

A Next.js build that passes locally can still get OOM killed in CI. Here is how to diagnose and fix Docker memory limits, build worker CPU count, and Node engine checks.

Fixing Next.js Docker Build OOM Kills in CI

Fixing Next.js Docker Build OOM Kills in CI

The build worked fine on my laptop. It worked fine the day before on the same CI runner. Then one morning the pipeline just stopped halfway through next build, no error, no stack trace, just a runner that went silent and a job marked failed after the timeout. That is the moment you learn that a resource constrained CI host is not a smaller version of your dev machine. It is a different environment with its own failure modes, and a Dockerized Next.js build will happily expose every one of them.

I spent a frustrating afternoon chasing this down before realizing it was not one bug. It was three separate constraints stacked on top of each other, each one looking almost identical from the outside.

TL;DR: Key Takeaways

  • A Next.js build that OOM kills or hangs in CI usually comes from one of three causes: unbounded Docker build memory, too many parallel build workers for the available CPUs, or an unrelated Node engine version check blocking a pipeline stage.
  • Diagnose with next build --experimental-debug-memory-usage (available since Next.js 14.2.0) before guessing at a fix.
  • Cap Docker build memory explicitly with --memory, limit Next.js build parallelism with experimental.cpus in next.config.js, and relax engine checks separately from either of those.
  • Next.js now documents this exact pattern officially in its Memory Usage guide, so this is a supported configuration, not a workaround.

Why Does a Next.js Build Pass Locally But Die in CI?

A Next.js build passes locally because your laptop has resources it never has to share. CI runners are usually capped at 2 to 4 CPUs and a fixed memory ceiling, often shared with other jobs on the same host.

next build runs a production compile through webpack or Turbopack, type checks your project, and by default tries to use every CPU core it can see to parallelize work. On a laptop with 8 or 10 cores and 16 or 32 GB of RAM, that parallelism is basically free. On a CI runner reporting 2 cores and 4 GB, the same build tries to spin up the same number of workers, and each worker holds its own chunk of memory. The build does not fail gracefully. The kernel’s out-of-memory killer just terminates the process, and depending on your CI setup you get a bare exit code with no explanation.

Docker adds a second layer to this. If you build your image inside a docker build step in CI without an explicit memory limit, the build process inherits whatever the container runtime allows, which can be far less than what next build assumes is available. Two different ceilings, two different failure points, both looking like the same stuck build from the outside.

How Do You Diagnose Which Resource Is Actually the Problem?

Run the build with Next.js’s built-in memory debug flag before changing anything. Since Next.js 14.2.0, you can add --experimental-debug-memory-usage to print live memory statistics throughout the build:

next build --experimental-debug-memory-usage

This prints heap usage at intervals as the build progresses, so you can see whether memory climbs steadily until the runner kills it, or whether the build simply hangs without memory growth, which points to a CPU or scheduling issue instead. Pair that with your CI platform’s own resource graphs for the job. If memory flatlines near the container’s limit right before the job dies, you are looking at an OOM kill. If CPU usage pegs at 100 percent with no forward progress in the logs, you are looking at parallelism fighting for cores that are not there.

Only after that do you know which of the three fixes below actually applies. Applying all three blindly works, but it hides which constraint was the real one, and you will not know which knob to turn next time your CI provider changes the runner size.

Fix 1: Cap Docker Build Memory Explicitly

Do not let a docker build step assume unlimited host memory. Set an explicit ceiling that matches what your CI runner actually has, with some headroom for the daemon itself:

docker build --memory 8g --memory-swap 8g -t myapp:latest .

Setting --memory-swap equal to --memory disables swap for the build, which is what you want in CI. A build that silently swaps to disk instead of failing fast just turns an OOM kill into a build that takes twenty minutes instead of two, which is arguably worse because it burns your pipeline’s time budget without telling you anything is wrong.

If your CI platform builds through Docker Buildx or a managed builder, check its own memory allocation settings too. The runner’s total memory and the memory Buildx assigns to the build step are not always the same number.

Fix 2: Limit Next.js Build Parallelism to Available CPUs

This is the fix Next.js itself now documents. Add experimental.cpus to next.config.js and set it to the actual core count your CI host reports, not the number your local machine has:

// next.config.js
const nextConfig = {
    experimental: {
        cpus: 2,
        webpackMemoryOptimizations: true,
    },
};

module.exports = nextConfig;

experimental.cpus throttles how many parallel workers Next.js spins up for webpack compilation, which directly caps how much memory the build can claim at once. webpackMemoryOptimizations, available since Next.js 15.0, makes additional changes to webpack’s internal behavior specifically to reduce peak memory usage during the build. Both are documented in the official Memory Usage guide, which Vercel added precisely because this failure pattern is common enough to need a first-party answer instead of everyone reinventing a workaround.

You can scope this with an environment variable so local builds keep using every core you have, while CI builds stay throttled:

const nextConfig = {
    experimental: {
        cpus: process.env.CI ? 2 : undefined,
    },
};

Also Read: Docker vs PM2 for Next.js on VPS: The 2026 Deployment Guide

Fix 3: When the Node Engine Check Itself Is the Blocker

Not every stuck pipeline is a memory or CPU problem. A separate stage in the same pipeline, in my case a test coverage stage, was failing outright on a constrained host because the package manager’s strict engine check rejected the Node version installed on that runner image, even though the version mismatch had nothing to do with the actual test run.

Yarn Classic and npm both read the engines field in package.json and can hard fail installs or scripts if the host Node version does not match. In CI, where the runner image’s Node version is controlled separately from your project, that check can block a stage for reasons unrelated to your code. You can relax it for that specific stage without loosening it everywhere:

# Yarn Classic
yarn install --ignore-engines

# or scoped to the CI job only
YARN_IGNORE_ENGINES=true yarn test:coverage
# .npmrc, npm equivalent
engine-strict=false

Treat this as a targeted exception for a specific CI stage, not a blanket policy. The engine check exists to catch real incompatibilities, and disabling it globally trades one class of silent failure for another.

Also Read: Why Docker Buildx Changed My CI/CD Game Forever

Yes. As of Next.js 15 and carried forward into Next.js 16, experimental.cpus and experimental.webpackMemoryOptimizations are both documented directly by the Next.js team as the sanctioned mitigation for build memory pressure, not a community workaround pulled from a GitHub issue thread. The --experimental-debug-memory-usage diagnostic flag has been stable since 14.2.0. If you are running an older Next.js version without these options, upgrading is worth doing before you invest more time tuning CI infrastructure around a problem the framework itself now has a documented answer for.

Putting a CI-Safe Build Together

Here is what the three fixes look like combined in a GitLab CI job building a Next.js Docker image on a constrained runner:

build-image:
    stage: build
    image: docker:26
    services:
        - docker:dind
    variables:
        YARN_IGNORE_ENGINES: "true"
    script:
        - docker build
          --memory 8g
          --memory-swap 8g
          -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA
          .
        - docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA

With next.config.js capping experimental.cpus inside the image build itself, all three constraints are addressed at the layer where they actually happen, memory at the Docker level, CPU parallelism at the Next.js level, and the engine check scoped to the one script that needed it.

Frequently Asked Questions

Why does my Next.js build hang instead of showing an error in CI? When the out-of-memory killer terminates the build process, it often does so without giving Node a chance to print a stack trace, so the job just stops and eventually times out. Run with --experimental-debug-memory-usage to see memory climbing before the kill instead of guessing after the fact.

What is experimental.cpus in Next.js? It is a next.config.js option that limits how many parallel workers Next.js uses during the webpack compilation step of next build, which reduces peak memory usage on hosts with fewer CPU cores than your local machine.

Does Docker’s --memory flag limit the whole build or just the running container? It limits the build process itself when passed to docker build, not only the final container at runtime. Pairing it with --memory-swap set to the same value prevents the build from silently swapping to disk instead of failing fast.

Should I just disable the Node engine check everywhere to avoid CI failures? No. Scope it to the specific CI stage that needs it, using a flag like Yarn’s --ignore-engines or an environment variable for that job only. A global bypass hides real Node version incompatibilities that the check exists to catch.

Is webpackMemoryOptimizations on by default in Next.js? No, it is an opt-in flag under experimental as of Next.js 15.0. You need to add it explicitly to next.config.js alongside experimental.cpus if you want both memory reductions together.

Conclusion

A CI build that fails on a resource constrained host is not one bug wearing three disguises. It is three separate ceilings, Docker memory, Next.js build parallelism, and package manager engine checks, that happen to produce the same symptom of a build that stalls or dies without explanation. Diagnose with the memory debug flag first, apply the fix that actually matches what you saw, and treat your CI runner’s real CPU and memory numbers as the source of truth instead of whatever your laptop can get away with.

Back to Blog

Related Posts

View All Posts »