Skip to content

When Coolify doesn't build the packages CI builds

Coolify builds a pnpm monorepo with pnpm filter, not with Turborepo. When internal packages export from dist/, the prod build falls over while CI stays green

When Coolify doesn't build the packages CI builds

Last week I pushed a feature with a Vipps integration to stage. CI went green, all tests passed. The deploy to Coolify failed with an error message I didn't recognize: webpack complained that it couldn't find @flowment/payments. The package exists. It exports what I'm importing. It builds in CI without a peep. And yet, in the Docker build in Coolify, it wasn't there.

It turned out to be a difference I hadn't considered between how Turborepo builds locally and how Coolify builds in prod. That difference isn't obvious, and it fooled me for two hours before I saw what was going on.

Two different builds

Locally and in GitHub Actions I run pnpm turbo run build. Turbo reads turbo.json, sees that @flowment/web has "dependsOn": ["^build"], and builds all upstream packages first. @flowment/payments, @flowment/domain and @flowment/db each get their own build script run, which tsc-compiles TypeScript down to dist/. When Next.js 15 then starts its build, it finds @flowment/payments/dist/index.js right where it expects it.

Coolify's default setup for a pnpm monorepo takes a different approach. The generated Dockerfile ends with:

RUN pnpm install --frozen-lockfile
RUN pnpm --filter @flowment/web build

pnpm --filter only runs the build script for @flowment/web. It doesn't build the packages web depends on. pnpm has no notion that @flowment/payments needs to be built first; that's a convention Turborepo owns. The result is that Next.js starts its build, and every dist folder in the workspace packages is empty.

The error from webpack ended up being:

Module not found: Can't resolve '@flowment/payments'

Import trace for requested module:
./app/checkout/actions.ts

The package is installed. The symlink exists in node_modules. But package.json points at something that doesn't exist:

{
  "name": "@flowment/payments",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.js"
    }
  }
}

The obvious choice I didn't make

The immediate fix is to build the packages explicitly in the Dockerfile before Next.js:

RUN pnpm -r --filter "./packages/*" build
RUN pnpm --filter @flowment/web build

That works. It's also the wrong place to solve the problem. Every time Coolify regenerates the Dockerfile after a Nixpacks update, or if I move the project to another platform, that line disappears. The build becomes dependent on me remembering a specific infrastructure detail.

What I ended up with is removing the tsc build step for internal packages entirely. Next.js 15 transpiles workspace packages if you tell it to:

const nextConfig: NextConfig = {
  transpilePackages: [
    "@flowment/payments",
    "@flowment/domain",
    "@flowment/db",
  ],
};

And the packages' package.json points straight at the TypeScript source:

{
  "name": "@flowment/payments",
  "exports": {
    ".": {
      "types": "./src/index.ts",
      "import": "./src/index.ts"
    }
  }
}

No dist/, no tsc step, no dependency on build order. Next.js reads the TypeScript straight from the package's source folder and runs it through its own SWC pipeline. For packages consumed by the Trigger.dev runner in the same monorepo I do the same thing, and let the bundler there handle transpilation.

What still grates

Editor support is still a bit odd. TypeScript project references work, but the typegen package for Supabase expects to find compiled .d.ts files. That had to be solved package by package by keeping a minimal tsc emit for the types field, while the import condition points at the source:

{
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./src/index.ts"
    }
  }
}

That grates against the principle that files on disk should be real files, not optional artifacts. If I were doing it over, I'd consider dropping the exports field entirely and pointing main at src/index.ts. Or just accepting a build step in prod and running tsup --watch in dev. Both are trade-offs; neither is obviously right.

The real problem

CI building via Turborepo and prod building via pnpm --filter are not the same build. If they diverge, it's CI that's lying. I've added a separate GitHub Actions job that builds with pnpm --filter @flowment/web build without the Turbo cache, so the gap between the setups gets caught before it hits Coolify. That one job is a copy of Coolify's Dockerfile step, nothing more.

It should have been the first one I set up. Every time something lands on my "only deploy to prod" list, it has turned out that CI builds with an assumption that doesn't hold in prod. That isn't a Coolify problem, it's a general monorepo problem. The next deploy pattern I move to (Railway, Fly, Render) will have a different variant of the same thing.

This text is machine translated and has not been reviewed yet.

If you liked this

Subscribe via RSS or JSON Feed, or get in touch.

Share on: LinkedIn · Redi AS · Spotify · Instagram · Wikidata · GitHub (work) · GitHub (personal)