Building a single workspace with Docker
This lesson preview is part of the Bundling and Automation in Monorepos course and can be unlocked immediately with a \newline Pro subscription or a single-time purchase. Already have access to this course? Log in here.
Get unlimited access to Bundling and Automation in Monorepos, plus 90+ \newline books, guides and courses with the \newline Pro subscription.

[00:00 - 00:21] In the last lesson of this module, we're going to look into building a specific workspace of our monorepo with Docker into its own container to be used for deployment. As we said in the beginning of the module, we're not going to look into all possible ways to do Continuous Delivery.
[00:22 - 00:36] The only thing that we're going to look into is going to be a Docker build, in our case for our server-express application. So the server-express application is a fairly simple express server.
[00:37 - 00:52] It has one internal dependency which makes it a good candidate for an example build. We'll be able to see how internal dependencies get built for your final artifact. And it's a server, so it's easy to test.
[00:53 - 01:06] What I'm going to do is I'm going to show you a Docker file that I've prepared ahead of time. In our apps/server-express/Dockerfile. I have the full Dockerfile for this build.
[01:07 - 01:18] Annotated it with comments about what each part does. We're going to go through it line by line, and where it does something a bit more complicated. We're going to drop back down to a terminal.
[01:19 - 01:27] But let's start with the base. When building for Node, selecting a container as your starting container is the first choice that you need to make.
[01:28 - 02:06] My recommendation is to use the slim containers as opposed to the alpine containers. The difference in file size is about 40MB, but the slim containers are much easier to configure compared to alpine. So we're starting with node:22-slim as our base, and we're going to set up the pnpm home folder to be "/pnpm" and attach that to the path. Next, since we're using the Node container we can enable pnpm in corepack, and then we can install it with corepack.
[02:07 - 02:20] The way that I suggest doing this with pnpm 10 currently being the latest version, is to use the pnpm@latest-10 tag. So this is just going to give you the latest version of pnpm.
[02:21 - 02:49] Now we could try to instead find the version of pnpm that's defined in our root package.json in our packageManager field. However, in most cases, just using the same major version is more than enough, and getting this version from package.json and injecting it into this command is kind of annoying to do and not worth the effort in my opinion.
[02:50 - 03:12] Then we're going to set up a separate stage that's going to do our fetching of dependencies that I'm going to just call "fetch". To do that, we need to copy the pnpm lock file and the pnpm workspace file, as well as the "patches" folder if we have any patches.
[03:13 - 03:29] In our example monorepo, we do have those, so we need to copy those over. Then this command can look a bit intimidating, so let me just get rid of one part of it. Command that we are actually running is "pnpm fetch --frozen-lockfile --ignore-scripts".
[03:30 - 03:45] The "pnpm fetch" command is not going to do an installation. That is to say, it's not going to create a node_modules folder, but it is going to fetch all packages that match the lock file that you start with.
[03:46 - 04:21] Now, why do I have this much more complicated RUN line instead of the straightforward pnpm fetch? The reason for this "--mount" that we're doing is this allows us to share the pnpm store between different Docker containers. If something changes and we need to do "pnpm install" for a different container that has a different lock file, this is going to mount the same store between the two containers, and it's just going to make fetch easier and faster.
[04:22 - 04:31] This has an effect only on local builds. If you're doing your builds in a cloud environment, then you can skip this part because it's not going to matter.
[04:32 - 04:52] Now that we have fetched everything, we can create a "build" stage, set our work directory, and finally copy over the full set of files. Now you might notice that we are in a Dockerfile that's within apps/server-express.
[04:53 - 05:12] But we are saying copy everything from local relative root. The way that we're going to make this work is that when we do "docker build", we're going to supply "--file" as "apps/server-express/Dockerfile".
[05:13 - 05:26] But we're going to start we're going to say that we are building the monorepo root. So we have a Dockerfile that's specific to the server-express workspace.
[05:27 - 05:40] But when we build it we must always initiate the build from the root of our monorepo. Next we have another RUN command that's now pnpm install.
[05:41 - 06:07] So if we ignore the cache part for a moment, the part that we care is that we're doing "pnpm install --offline", which means don't even try to connect to npm, this has already been done in our fetch stage, and then use a frozen log file and ignore any scripts. Finally, we have our "pnpm --filter=@apps/server-express...
[06:08 - 06:49] run build". Now what does this do? If we go to the command line and we do "pnpm run --help", we can see that "--filter " followed by three dots means include all direct and indirect dependencies of the matched package and the package itself. So when we say "@apps/server-express...", this means build the @apps/server-express package and any direct or indirect dependencies of it that exist within our workspace. In our case, this would be only the logger dependency.
[06:50 - 07:01] So it's going to run build for all of those. And then we're going to run again for the "@apps/server-express" package, but this time without the three dots, the deploy command.
[07:02 - 07:10] The "pnpm deploy" command is kind of complicated. So let me copy this over and show you what it does.
[07:11 - 07:37] In Docker we're outputting to /prod/app, but in my local development I'm going to do "/tmp/server-express-build". Let's look at that folder I'm going to CD into /tmp/server-express-build, and then I'm going to show the tree structure in here.
[07:38 - 08:13] What we have here is actually the contents of apps/server-express with a minimal node_modules that has all its dependencies, including internal dependencies like our internal monorepo logger package. Indeed, if I do "tree node_modules/@monorepo/logger", we're going to see that it copied over the full monorepo logger package, including its build output in the dist folder.
[08:14 - 08:58] So with all of this in mind, what "pnpm deploy" did, was it pruned all dependencies from our monorepo except the ones that are used by the specific package, including internal dependencies, copying over build files, and creating something that we can run directly. We now have a build output that has a build index.cjs and it has all its node_modules dependencies. Doing it this way is important because if we look at the amount of disk space used by this pruned version, we can see that it's only seven megabytes.
[08:59 - 09:43] However, if I go back to my monorepo root and I do the same command, it's almost 800MB. A pruned "pnpm deploy" build is going to give you a much smaller Docker image that's going to be faster to deploy and easier to store compared to naively building the whole ponorepo with just "pnpm install". With that in mind, we now have our server-express, first built, and then pruned with all build artifacts put into a single folder that's only seven megabytes, and we can create our production image.
[09:44 - 10:24] That's going to start with either Alpine or Slim depending on your needs, and then copy the pruned image from our build and do the normal Docker setup, like setting up some environment variables and then running our build output with the node command. Let me show this in action. I'm going to do "docker build -F" for input of our Dockerfile, "apps/server-express/Dockerfile", then dot to specify that I'm building from the monorepo root. And I'm going to tag this image as server-express.
[10:25 - 10:36] The build is going to take a couple of seconds, and let's look at the image that was created. We got an image that has a total size of 230MB.
[10:37 - 11:20] It can go even lower if you were using node-alpine as our base for the application container. But usually this is fine, and it's definitely much better than more than a gigabyte that we would have if we had naively just run pnpm install for our dependencies. Let me run this with "docker run --init -p 3000:3000 server-express". I am remapping my local 3000 port to the server-express image 3000 port and this is now running. We get our structured JSON logs saying that hey, the server-express is running at localhost 3000.
[11:21 - 11:36] If I open this in in a browser, we do have the expected "status: running" JSON response. And if I go back to my console, we can see a server request being served for each of those refreshes.
[11:37 - 11:48] So this is the way that you would approach building Docker in a monorepo. You would have separate Dockerfiles per workspace in each separate workspace.
[11:49 - 12:11] You will build relative to the root of your monorepo, and you should use deploy command to create a small pruned version of your monorepo that has only the workspaces that matter for your particular application. And with that, this lesson is complete.