Building a sane Docker image for Typescript, Yarn Workspaces and Prisma 2
EDIT 1: As of Beta 4, Prisma now creates a new folder called .prisma
which contains the generated client. This is reflected in the current Dockerfile.
The other day I was working on the deployment of the new service I am building for a client. As is usual these days, I started by writing a Dockerfile to containerize this new service so it could be deployed (on GCP Cloud Run for this project). It usually doesn’t take me long to build a good image with proper caching and minimal size (I am a bit of a Docker nerd with a passion for optimized images 😀).
But this setup is particularly tricky because of the three following facts:
- Prisma generates a client inside the
node_modules
(and requires some dev dependencies to do so) - We have a common package that is not published to central repository (managed with yarn workspaces)
- We don’t want the image to contain Typescript code (only the generated Javascript)
I could not find any good resource online on how to create an efficient image for this specific setup, so I decided to write this blog post to help others who might face a similar challenge. Without further ado, here is the full Dockerfile I came up with:
Let’s go over this beast! 😅
- First, you can see that it’s a multistage build. We define a base image from node buster slim (Prisma does not currently build binaries for alpine images). I prefer buster since it’s the latest debian version. We also need to install openssl, because the slim version doesn’t ship with it anymore (this is the only step I don’t really like and might rework to reduce size).
- Second, we create a builder stage that will first copy the
package.json
andtsconfig.json
from the root, thebackend
package and thecommon
package. This is necessary for proper caching of layers (always pull dependencies before copying the code) - Third, we install the production dependencies for all our packages and we copy the
backend
dependencies somewhere else. This will allow us to copy them later in the runner stage. I tried usingnpm prune --production
but the result was not as good as a clean install. You might need to pass--ignore-scripts
to yarn if you have stuff in apostinstall
that doesn’t work in production (it is usual with prisma to generate the client in thepostinstall
). Also worth noting, we use the-L
flag incp
to make sure the dependencies of our common packages are also copied since it will be linked in thebackend
dependencies with a symlink. - Fourth, we install the dev dependencies and we then go on to build each package in order:
common
,prisma client
andbackend
. This is relatively straight forward , you just need to be careful of the order otherwise the Typescript compiler will complain. - Fifth, we create a runner stage and we copy all the dependencies we need. That means the production
node_modules
, the generatedprisma client
and the compiledcommon
. We finally copy the compiled backend code and thepackage.json
(only necessary if you need scripts). - Sixth, we drop the privileges of the runner and we set the command to start the server (we use
tsconfig-paths
for our absolute paths).
So that’s it! With this, you have an optimized image since you will only push the runner stage in production and it should still be fast to build if the dependencies don’t change and you cache the builder stage.