Docker example in Turborepo docs can't find command to launch app

I’ve built a monorepo using Turborepo that contains an app and a couple of packages. My first attempt to deploy the app directly to DigitalOcean failed, and I think it’s because DO doesn’t offer certain features for configuring the build process, so I thought that creating a Docker image and deploying it via DigitalOcean’s container registry would be easier. So far it’s not.

For context, I’m completely new to Docker, and have only been using the basics of Turborepo for the past 8 months or so, and only for local tests. This is the first time I’ve tried to deploy any of the apps.

I’m trying to follow the example from this page:

My Dockerfile in the app folder—modified from the one on the page above—looks like this:

FROM node:20-alpine AS base

FROM base AS builder

# Set working directory
WORKDIR /app
RUN npm install turbo@^2.4.2
COPY . .
 
# Generate a partial monorepo with a pruned lockfile for a target workspace.
RUN turbo prune staff-portal --docker
 
# Add lockfile and package.json's of isolated subworkspace
FROM base AS installer
WORKDIR /app
 
# First install the dependencies (as they change less often)
COPY --from=builder /app/out/json/ .
RUN npm install --package-lock-only
 
# Build the project
COPY --from=builder /app/out/full/ .
RUN turbo run build
 
FROM base AS runner
# WORKDIR /app
  
CMD ["react-router-serve", "/app/build/server/index.js"]

When I build the image, only the first step shows in the output:

[+] Building 0.6s (6/6) FINISHED
 => [internal] load build definition from Dockerfile
 => => transferring dockerfile: 690B
 => [internal] load metadata for docker.io/library/node:20-alpine
 => [auth] library/node:pull token for registry-1.docker.io
 => [internal] load .dockerignore
 => => transferring context: 2B
 => CACHED [base 1/1] FROM docker.io/library/node:20-alpine@sha256:674181320f4f94582c6182eaa151bf92c6744d478be0f1d12db804b7d59b2d11
 => exporting to image
 => => exporting layers
 => => writing image sha256:3de5ea71651ce95b7525672a7da2d7cc93189299a5abf8627387d247b36c5109
 => => naming to docker.io/library/loa-portal:test   

I’m completely lost on how to fix this.

Reading docs, trying random things to see if anything changes:

  • Changed the Node version
  • Added some RUN instructions as pseudo-comments.

Here’s the new file:

# syntax=docker/dockerfile:1
FROM node:latest AS base

FROM base AS builder
RUN echo "builder"
# Set working directory
WORKDIR /app
RUN npm install turbo@^2.4.2
COPY . .

# Generate a partial monorepo with a pruned lockfile for a target workspace.
RUN turbo prune staff-portal --docker

# Add lockfile and package.json's of isolated subworkspace
FROM base AS installer
RUN echo "installer"
WORKDIR /app

# First install the dependencies (as they change less often)
COPY --from=builder /app/out/json/ .
RUN npm install --package-lock-only

# Build the project
COPY --from=builder /app/out/full/ .
RUN turbo run build

FROM base AS runner
RUN echo "runner"
# WORKDIR /app

CMD ["react-router-serve", "./app/build/server/index.js"]

Here’s the output:

[+] Building 33.5s (10/10) FINISHED                                                                                                         docker:desktop-linux
 => [internal] load build definition from Dockerfile                                                                                                        0.0s
 => => transferring dockerfile: 774B                                                                                                                        0.0s
 => resolve image config for docker-image://docker.io/docker/dockerfile:1                                                                                   0.7s
 => [auth] docker/dockerfile:pull token for registry-1.docker.io                                                                                            0.0s
 => CACHED docker-image://docker.io/docker/dockerfile:1@sha256:9857836c9ee4268391bb5b09f9f157f3c91bb15821bb77969642813b0d00518d                             0.0s
 => [internal] load metadata for docker.io/library/node:latest                                                                                              0.7s
 => [auth] library/node:pull token for registry-1.docker.io                                                                                                 0.0s
 => [internal] load .dockerignore                                                                                                                           0.0s
 => => transferring context: 2B                                                                                                                             0.0s
 => [base 1/1] FROM docker.io/library/node:latest@sha256:8369522c586f6cafcf77e44630e7036e4972933892f8b45e42d9baeb012d521c                                  30.8s
 => => resolve docker.io/library/node:latest@sha256:8369522c586f6cafcf77e44630e7036e4972933892f8b45e42d9baeb012d521c                                        0.0s
 => => sha256:8369522c586f6cafcf77e44630e7036e4972933892f8b45e42d9baeb012d521c 5.14kB / 5.14kB                                                              0.0s
 => => sha256:900e2c02f17f686733f4f957ddfb07b3342d1957d87b56254634d4fbb2abb81d 64.40MB / 64.40MB                                                            4.8s
 => => sha256:761f4cb0b9e5912819e614c1bcda1fe0d19829be4a48d3f6f35df4f055c413c5 2.49kB / 2.49kB                                                              0.0s
 => => sha256:fbe07b8f64dd4a209293f782a117604aa60017030c827f41e09e74256a385110 6.42kB / 6.42kB                                                              0.0s
 => => sha256:c1995213564325caf7e52ecd95fe4435c70b03eb94c674ac15706733986b86e0 48.49MB / 48.49MB                                                            4.6s
 => => sha256:7bbf972c6c2f5b7313ae3cb74e63888ab70931bcd9aefd960f9a38c540dbf2ca 24.02MB / 24.02MB                                                            0.7s
 => => sha256:abe9c1abe6f3b8ca9fc6abe710405f830f95262f1d356e8f6545d823b5840a5c 211.37MB / 211.37MB                                                          7.0s
 => => sha256:8ce1b37249dd92d2493f77215adf0e75eb4c3c8ca891eb1dd951021523a52fbe 3.32kB / 3.32kB                                                              4.8s
 => => extracting sha256:c1995213564325caf7e52ecd95fe4435c70b03eb94c674ac15706733986b86e0                                                                   3.9s
 => => sha256:8ae6ed2269348d2b5e0192514ba2f10d2936cd51acb25391699988f53b884db8 58.52MB / 58.52MB                                                            8.4s
 => => sha256:5471b55d22e8a9852f936e34df9d8b8a371fd843c5e8e7ae18400893bf7bf249 1.25MB / 1.25MB                                                              5.0s
 => => sha256:dcf69e3ecc1c4a732d74464a6b82afef86228e63a345857e1784eee10a5e35cc 445B / 445B                                                                  5.2s
 => => extracting sha256:7bbf972c6c2f5b7313ae3cb74e63888ab70931bcd9aefd960f9a38c540dbf2ca                                                                   0.9s
 => => extracting sha256:900e2c02f17f686733f4f957ddfb07b3342d1957d87b56254634d4fbb2abb81d                                                                   4.3s
 => => extracting sha256:abe9c1abe6f3b8ca9fc6abe710405f830f95262f1d356e8f6545d823b5840a5c                                                                  10.7s
 => => extracting sha256:8ce1b37249dd92d2493f77215adf0e75eb4c3c8ca891eb1dd951021523a52fbe                                                                   0.0s
 => => extracting sha256:8ae6ed2269348d2b5e0192514ba2f10d2936cd51acb25391699988f53b884db8                                                                   4.1s
 => => extracting sha256:5471b55d22e8a9852f936e34df9d8b8a371fd843c5e8e7ae18400893bf7bf249                                                                   0.1s
 => => extracting sha256:dcf69e3ecc1c4a732d74464a6b82afef86228e63a345857e1784eee10a5e35cc                                                                   0.0s
 => [runner 1/1] RUN echo "runner"                                                                                                                          1.0s
 => exporting to image                                                                                                                                      0.0s
 => => exporting layers                                                                                                                                     0.0s
 => => writing image sha256:c436f9c394f71704e627970f3636f7413bbee932c9c8550b98c2bbb2a3fc04cf                                                                0.0s
 => => naming to docker.io/library/testing 

It’s clearly skipping most of what’s in the middle. After Node loaded, it jumped straight to the last RUN instruction, which makes no sense.

In Docker’s docs, on a page that talks about multi-stage builds, it mentions that you can target a specific stage to be built and skip the rest using the --target flag, but I’m not doing that. My build command is:

docker build -t testing -f apps/staff-portal/Dockerfile .

After more digging, I found the problem.

In the sample Dockerfile for Turborepo, the final stage contains some COPY instructions. Because that Dockerfile was for a NextJS app, though, and my own app uses React Router, I thought that I didn’t need those. In truth, that’s only partly correct.

While I didn’t need those specific COPY instructions, I did need to have something that copied data from an earlier stage. Without that, the Docker build parser wouldn’t know to execute those other stages. It’s an odd way to parse/interpret a dependency tree, but that’s how Docker operates. Lesson learned.

That aside, I’ve now hit another snag. Here’s my new Dockerfile:

# syntax=docker/dockerfile:1
FROM node:latest AS base

FROM base AS builder
RUN echo "builder"
# Set working directory
WORKDIR /app
RUN npm install -g turbo@^2.4.2
COPY . .

# Generate a partial monorepo with a pruned lockfile for a target workspace.
RUN turbo prune staff-portal --docker

# Add lockfile and package.json's of isolated subworkspace
FROM base AS installer
RUN echo "installer"
WORKDIR /app

# First install the dependencies (as they change less often)
COPY --from=builder /app/out/json/ .
RUN npm install --package-lock-only

# Build the project
COPY --from=builder /app/out/full/ .
RUN npx turbo run build

FROM base AS runner
RUN echo "runner"
WORKDIR /app

COPY --from=installer /app/ .

CMD ["react-router-serve", "./build/server/index.js"]

And here’s the error when turbo tries to run the build on the app:

------                                                                                                                                                           
 > [installer 6/6] RUN npx turbo run build:
0.959 npm warn exec The following package was not found and will be installed: turbo@2.5.4
2.234  WARNING  No locally installed `turbo` found. Using version: 2.5.4.
2.249 
2.249 Attention:
2.249 Turborepo now collects completely anonymous telemetry regarding usage.
2.249 This information is used to shape the Turborepo roadmap and prioritize features.
2.249 You can learn more, including how to opt-out if you'd not like to participate in this anonymous program, by visiting the following URL:
2.249 https://turborepo.com/docs/telemetry
2.249 
2.249 turbo 2.5.4
2.249 
2.293 • Packages in scope: @loaportal/backend, staff-portal
2.293 • Running build in 2 packages
2.293 • Remote caching disabled
2.309 staff-portal:build: cache miss, executing 174b66929e166d5b
2.472 staff-portal:build: 
2.472 staff-portal:build: > build
2.472 staff-portal:build: > react-router build
2.472 staff-portal:build: 
2.476 staff-portal:build: sh: 1: react-router: not found
2.481 staff-portal:build: npm error Lifecycle script `build` failed with error:
2.481 staff-portal:build: npm error code 127
2.481 staff-portal:build: npm error path /app/apps/staff-portal
2.481 staff-portal:build: npm error workspace staff-portal
2.481 staff-portal:build: npm error location /app/apps/staff-portal
2.481 staff-portal:build: npm error command failed
2.482 staff-portal:build: npm error command sh -c react-router build
2.490 staff-portal:build: ERROR: command finished with error: command (/app/apps/staff-portal) /usr/local/bin/npm run build exited (127)
2.490 staff-portal#build: command (/app/apps/staff-portal) /usr/local/bin/npm run build exited (127)
2.491 
2.491  Tasks:    0 successful, 1 total
2.491 Cached:    0 cached, 1 total
2.491   Time:    220ms 
2.491 Failed:    staff-portal#build
2.491 
2.493  ERROR  run failed: command  exited (127)
------

Not sure how react-router couldn’t be found, unless I configured something else incorrectly in an earlier step. Any ideas?

Also, is there a way to specify which version of turbo to use when executing npx turbo run build? It defaults to 2.5.4, but my currently installed version is 2.4.2 as indicated in the Dockerfile. One idea I had was to move the turbo installation from the builder stage to the base stage, so that all of the later stages can use it. Will that work? If not, I’m open to suggestions.

Removed the --package-lock-only flag from the npm install command, and the error is gone. The build completes without any further errors.

Now the error comes up when I try to run the image in a container:

node:internal/modules/cjs/loader:1372
  throw err;
  ^

Error: Cannot find module '/react-router-serve'
    at Module._resolveFilename (node:internal/modules/cjs/loader:1369:15)
    at defaultResolveImpl (node:internal/modules/cjs/loader:1025:19)
    at resolveForCJSWithHooks (node:internal/modules/cjs/loader:1030:22)
    at Module._load (node:internal/modules/cjs/loader:1179:37)
    at TracingChannel.traceSync (node:diagnostics_channel:322:14)
    at wrapModuleLoad (node:internal/modules/cjs/loader:235:24)
    at Module.executeUserEntryPoint [as runMain] (node:internal/modules/run_main:152:5)
    at node:internal/main/run_main_module:33:47 {
  code: 'MODULE_NOT_FOUND',
  requireStack: []
}

Node.js v24.3.0

Not sure how to begin addressing this. Any ideas?

The react-router-serve binary comes from the @react-router/serve package which is probably being pruned as a dev dependency

It’s just a small easy node/express server, but since you need it to run the app (and not just to build it), it should be a real dependency instead of a devDependency

Thanks for the reply, Jacob. I can confirm that @react-router/serve is in the “dependencies” section of package.json for the app, not the “devDependencies” section. That’s one of the things that I checked pretty early in this troubleshooting process.

I’m still pretty green when it comes to Docker and Dockerfile configuration. Could there be something else that’s not configured correctly so that react-router-serve isn’t discoverable?

It occurred to me that because the runner stage is starting from the base stage, none of the packages installed in the installer stage can be found.

As a test, I added this right above the CMD instruction:

RUN npm install -g @react-router/serve@7.6.3

That got me past the module-not-found error. Now the error is Cannot find module '/app/build/server/index.js'

This is where my lack of understanding of the image’s internal folder structure is hampering me. In my attempts to fix this so far, I removed the WORKDIR /app line from the Dockerfile, which makes it search for the file at ./build/server/index.js, but that leads to a similar error.

Is there a way to inspect the image’s folder structure so that I don’t have to resort to trial-and-error to find the correct path?

I found a post on StackExchange that included this command for launching an image in a way that opens a shell and bypasses the default command:

docker run --rm -it --entrypoint=/bin/bash image-name

This let me navigate the folder structure and find the correct path to the file to be served. After updating the Dockerfile with that path, rebuilding and running the image worked.

Now my only errors are related to the internals of the image.