DEV Community

Julia for Light Cloud

Posted on Originally published at blog.light-cloud.com on

Working With a Monorepo: Shared Packages and npm Workspaces

Working With a Monorepo: Shared Packages and npm Workspaces

To share code between the apps of a monorepo on Light Cloud, put it in a workspace package and import it from each app. The static site keeps its own folder as Root directory and builds unchanged, because the whole repository is checked out and npm finds the workspace root above the folder. A container cannot see outside its folder, so it gets a Dockerfile at the repository root and the root as its Root directory. From then on a change to the shared package redeploys the container by itself; the static site rebuilds when you ask it to.

What you will build

The two apps from Part 1, now importing the same package:

  • packages/shared exports the product name and a version format,
  • apps/web shows them in the page,
  • apps/api uses them in its JSON answer.

The web page: the label apps/web, the heading One repository, many apps, the line This page is the web app, v2.1, and the api's JSON with version v2.1

Source code: github.com/light-cloud-com/tutorial-monorepo, tag part-2.

Before you start

  • Part 1 finished: the two apps deployed from apps/web and apps/api. To start here, clone the repository at the tag part-1 and deploy both apps as Part 1 shows.
  • Node.js 22 or later; I used Node.js 22.22 with npm 10.9. You need it on your machine this time, to write the lock file.
  • Git and a terminal. On Windows, use PowerShell 7.

Step 1: Create the shared package

npm workspaces let several packages in one repository install together and link to each other. Declare them in a package.json at the repository root:

{
  "name": "tutorial-monorepo",
  "private": true,
  "workspaces": [
    "apps/*",
    "packages/*"
  ],
  "scripts": {
    "build:web": "npm run build --workspace apps/web",
    "start:api": "npm start --workspace apps/api"
  }
}

Enter fullscreen mode Exit fullscreen mode

Then the package itself. It is two files:

{
  "name": "@tutorial/shared",
  "version": "1.0.0",
  "description": "Code shared by the web app and the api",
  "type": "module",
  "main": "index.js"
}


// Shared by apps/web and apps/api. Change it here, and both apps pick it up
// on their next build.
export const PRODUCT = "One repository, many apps";

export function formatVersion(number) {
  return `v${number}.0`;
}

Enter fullscreen mode Exit fullscreen mode

Step 2: Use it in both apps

Add the package to the dependencies of both apps. Its version is *: any version, which with workspaces means the folder in this repository.

"dependencies": {
  "@tutorial/shared": "*",
  "react": "^19.2.8",
  "react-dom": "^19.2.8"
}


"dependencies": {
  "@tutorial/shared": "*",
  "cors": "^2.8.5",
  "express": "^5.1.0"
}

Enter fullscreen mode Exit fullscreen mode

Import it where each app shows its version. In the web app:

import { PRODUCT, formatVersion } from "@tutorial/shared";


<h1>{PRODUCT}</h1>
<p>This page is the web app, {formatVersion(2)}.</p>

Enter fullscreen mode Exit fullscreen mode

In the API:

import { formatVersion } from "@tutorial/shared";


res.json({ service: "api", version: formatVersion(VERSION), time: new Date().toISOString() });

Enter fullscreen mode Exit fullscreen mode

With workspaces there is one lock file, at the root. Delete the two the apps had and let npm write the new one:

$ rm apps/api/package-lock.json apps/web/package-lock.json
$ npm install
added 98 packages, and audited 102 packages in 712ms
found 0 vulnerabilities


PS> Remove-Item apps/api/package-lock.json, apps/web/package-lock.json
PS> npm install
added 98 packages, and audited 102 packages in 712ms
found 0 vulnerabilities

Enter fullscreen mode Exit fullscreen mode

npm also prints a line about packages looking for funding; I left it out. The repository root now has node_modules with a link @tutorial/shared pointing at packages/shared, and package-lock.json covering all three packages. Try the API on your machine:

$ npm run start:api
> start:api
> npm start --workspace apps/api

> api@1.0.0 start
> node server.js

api listening on port 8080


PS> npm run start:api
> start:api
> npm start --workspace apps/api

> api@1.0.0 start
> node server.js

api listening on port 8080

Enter fullscreen mode Exit fullscreen mode

In a second terminal, curl http://localhost:8080/ answers {"service":"api","version":"v2.0","time":"..."}: the version now comes through the shared package. Stop the API with Ctrl+C.

Step 3: Push, and see what builds

Commit everything and push:

$ git add -A
$ git commit -m "Shared package with npm workspaces"
$ git push
To https://github.com/YOUR-USERNAME/tutorial-monorepo.git
   8c35173..f327c2a main -> main


PS> git add -A
PS> git commit -m "Shared package with npm workspaces"
PS> git push
To https://github.com/YOUR-USERNAME/tutorial-monorepo.git
   8c35173..f327c2a main -> main

Enter fullscreen mode Exit fullscreen mode

Both folders changed, so both apps redeploy. They end differently.

The web app builds and is live: 15 seconds in my run. Its Install command is now npm install, because apps/web has no lock file of its own, and that is fine: Light Cloud checks out the whole repository and runs the install inside apps/web, where npm notices the workspace root one level up and installs from there, shared package included. The page shows the product name and v2.0 from the package.

The API fails:

The failed deployment of monorepo-api: Deployment failed, Container build failed, Check your Dockerfile and application code for errors, The build failed

The Build output on the same page shows why. Light Cloud generated a Dockerfile for the API and built it from apps/api alone; trimmed to the lines that matter:

#10 [builder 3/5] COPY package.json ./
#11 [builder 4/5] RUN npm install
#11 4.308 npm error code E404
#11 4.309 npm error 404 Not Found - GET https://registry.npmjs.org/@tutorial%2fshared - Not found
#11 4.311 npm error 404 The requested resource '@tutorial/shared@*' could not be found or you do not have permission to access it.
#11 ERROR: process "/bin/sh -c npm install" did not complete successfully: exit code: 1

Enter fullscreen mode Exit fullscreen mode

A container is built from its Root directory only: the Docker build context is apps/api, so packages/shared is not there, and npm asks the public registry for a package that exists only in your repository. The static site had the whole repository on disk; the container does not.

Step 4: Build the API from the repository root

The fix has two parts: a Dockerfile at the repository root that copies the shared package in, and the API's Root directory moved to the root so that Dockerfile is the one Light Cloud uses.

# Builds apps/api together with the shared package. The api app's Root
# directory is the repository root, so this file sees every workspace.
FROM node:22-alpine
WORKDIR /app

# The root manifest and lock file, the shared package, then the api itself.
COPY package.json package-lock.json ./
COPY packages ./packages
COPY apps/api ./apps/api

# Install only what apps/api needs; the shared package is linked in.
RUN npm ci --workspace apps/api --omit=dev

ENV NODE_ENV=production
EXPOSE 8080
CMD ["node", "apps/api/server.js"]

Enter fullscreen mode Exit fullscreen mode

npm ci --workspace apps/api reads the root lock file and installs the API's dependencies, with @tutorial/shared as a link to the copied folder. Commit and push the file:

$ git add Dockerfile
$ git commit -m "Dockerfile for the api, built from the repository root"
$ git push
To https://github.com/YOUR-USERNAME/tutorial-monorepo.git
   f327c2a..717c635 main -> main


PS> git add Dockerfile
PS> git commit -m "Dockerfile for the api, built from the repository root"
PS> git push
To https://github.com/YOUR-USERNAME/tutorial-monorepo.git
   f327c2a..717c635 main -> main

Enter fullscreen mode Exit fullscreen mode

Nothing deploys on this push: the new file is outside both apps' folders. Now move the API: open the monorepo-api app, click Settings , clear the Root Directory field so that it shows / (repository root), and click Save Changes :

The Root Directory card in the app settings, with the field cleared and showing the placeholder repository root

Then open the Production environment, its Deployments tab, and click Deploy (it reads Redeploy once an environment has deployed before). This time the build used the Dockerfile: Deployed in 59s , and the API answered {"service":"api","version":"v2.0",...} 68 seconds after the click. The web page shows both halves coming from the shared package:

The web page showing the shared heading One repository, many apps, the line This page is the web app, v2.0, and the api's JSON with version v2.0

A root Dockerfile always makes the root a container, which is why the web app keeps apps/web as its Root directory: a static site cannot be built from a root that has a Dockerfile.

Step 5: Change the shared package

This is where the two apps part ways. Change the version format in the shared package from .0 to .1:

return `v${number}.1`;


$ git commit -am "shared: version suffix .1"
$ git push
To https://github.com/YOUR-USERNAME/tutorial-monorepo.git
   717c635..4a9c93a main -> main


PS> git commit -am "shared: version suffix .1"
PS> git push
To https://github.com/YOUR-USERNAME/tutorial-monorepo.git
   717c635..4a9c93a main -> main

Enter fullscreen mode Exit fullscreen mode

The API redeployed by itself (1 minute 5 seconds), because its Root directory is the whole repository and every push is inside it. Not the web app: packages/shared is outside apps/web, so the push did not touch its folder. Refresh the page and the two disagree:

The web page saying This page is the web app, v2.0, while the api's JSON says v2.1

To rebuild the web app with the new package, open its Production environment, the Deployments tab, and click Redeploy :

The Deploy card on the Deployments tab: Redeploy this environment with the latest code, with the Redeploy button highlighted

Mine was rebuilt in 14 seconds and served the new bundle 31 seconds after the click. Both sides now say v2.1:

The web page saying This page is the web app, v2.1, and the api's JSON with version v2.1

Both Deployments tabs tell the story. The web app's latest deployment was started by hand; the API's by the push:

Deployment History of monorepo-web: shared: version suffix .1 deployed by Julia Kafarska in 14s, highlighted, above the first workspace deployment

Deployment History of monorepo-api: shared: version suffix .1 by GitHub Auto-Deploy in 1m 5s, the Dockerfile deployment in 59s, and the failed first attempt, highlighted

A push that changes a file inside apps/web rebuilds the web app with the current shared package too, so in day-to-day work the gap closes on its own; Redeploy is for the case where only the package changed.

pnpm, yarn and task runners

Everything above uses npm workspaces, and two facts decide what else works:

  • A container built from your own Dockerfile runs whatever the Dockerfile says, so pnpm or yarn workspaces work there the same way: copy the lock file and the packages, install, start.
  • Light Cloud's static site builds run npm: npm ci when the folder has a package-lock.json, npm install otherwise. A static site inside a pnpm or yarn workspace cannot rely on workspace: links.

Task runners such as Turborepo sit on top of workspaces. Each Light Cloud app still builds from its own folder with its own Build command , so they are not needed for what this guide does.

Troubleshooting

Container build failed. Check your Dockerfile and application code for errors.

Open the deployment and read the Build output. If it ends with npm error 404 for a package that exists only in your repository, the container was built from a folder that does not contain the package. Build it from the repository root with a Dockerfile that copies the package in (Step 4).

npm error 404 Not Found - GET https://registry.npmjs.org/@tutorial%2fshared

npm did not find the workspace root, so it treated the shared package as a public one. In a container, that means the build context does not include the root package.json and packages/. On your machine, run npm install from the repository root, not from inside an app folder that has no package-lock.json.

The web app still shows the old shared code

A change in packages/shared does not redeploy an app whose Root directory is apps/web. Click Redeploy on its Production environment, or push a change inside apps/web.

Top comments (0)