Dockerizing Next.js with Prisma for Production Environments

How to configure your Next.js app with Docker and Prisma from start to finsh

Here is the sample repository for this blog post

Next.js is a phenomenal framework for building SEO friendly, performant webpages with React. For static pages, Next.js is enough to create your web page, but when you need to store persistent state such as when you have users, or perhaps blog pages that are dynamically being created once the web page has been deployed, you need a database to keep track of the various changes in state that the web page will undergo. Prisma is a library that will create a connector with your database and allow you to easily perform CRUD (create, read, update and delete) operations whenever your backend needs to.

The combination of Next.js and Prisma is a powerful one, and I’ve created blog posts and courses if you are interested in how to create a complete web application from scratch, but for this post we will discuss how to deploy Prisma and Next.js in a production docker container.

If you haven’t used Docker before, it is a containerization technology that allows you to reproducibly build and run your code in a way that will consistently run across all platforms, both on your computer and up in the cloud. The primary configuration that we need to do with Docker is to create a Dockerfile that essentially can be thought of as the command line steps that you’d type into your terminal in order to build your Next.js and Prisma app.

We will build up our production image in multiple stages which will allow us to take the approach of building the code in one image that is all loaded up with the development npm dependencies and then copy the built code into a clean production image to dramatically save on space.

The four main commands used in a Dockerfile are the following:

FROM: this is your starting spot for building your docker image. The first time that you use this in a Dockerfile, you will be pulling from an already existing image on the internet. When you have multiple stages, it is good practice to label the stage using the AS followed by the name. Then, later in the Dockerfile you can use FROM to import the current state of that layer, which we’ll talk about in a bit.

RUN: used for running any commands just like you would from the command line. Keep in mind that the shell you are in is dictated by the base image that you are loading. For example, alpine images are widely used due to their small size but they also use the sh shell rather than bash, so if you are using alpine make sure that your RUN commands are sh compatible. In this example below, we will use the slim family of docker images as our base which uses bash as its shell. This makes installing Prisma dependencies much easier.

WORKDIR: This will set the current working directory to whatever path is specified.

COPY: Takes two or more parameters, the first up through the second to last parameters are paths to the desired file(s) or folder(s) on the host. The last parameter is the destination path for where those files should be copied into.

There are two other commands you sometimes see in Dockerfiles, but since they can also be configured with docker-compose, kubernetes or whatever your hosting provider is, they are less important:

EXPOSE: allows you to explicitly open certain ports in the container. Can be overridden when running the container.

CMD: indicates the command that Docker runs when the container starts up. Can also overridden when run.

Armed with those basics, let’s take a look at the start of our Dockerfile. The goal with creating this base docker image is to have everything that both our development and production images without anything more. There will be 4 layers that we create to our Dockerfile:

  1. base layer has system dependencies, package.json, yarn.lock, and .env.local file.

  2. build layer starts with base and installs all dependencies to build .next directory that has all of the site’s code ready for use.

  3. prod-build layer starts with base and installs production dependencies only.

  4. prod layer starts with base and copies production dependencies from prod-build, .next folder from build

  5. Create the base layer

FROM node:lts-buster-slim AS base
RUN apt-get update && apt-get install libssl-dev ca-certificates -y
WORKDIR /app

COPY package.json yarn.lock ./

This starts with a slim version of the long term stable version of node and labels it base. Going with the slim variety allows the base image to only be 174MB while the full-blown image is 332MB. Alpine images are even smaller- around 40MB but since the shell is sh rather than bash, I ran into problems getting everything needed for Next.js and Prisma to compile properly. (Found a way to get alpine to work? Let me know in the comments!)

In any case, we start with Buster Debian base image that has node lts preinstalled, and then we run apt-get update to ensure that all of our package lists are up to date. We then install libssl-dev and ca-certificates which are dependencies of Prisma and then set the working directory as /app.

  1. Create the build layer

By then creating a new FROM designation, we are saving off those first set of steps under the layer base, so that any steps created from here on out get saved to the build layer, rather than the base layer.

From the top:

FROM node:lts-buster-slim AS base
RUN apt-get update && apt-get install libssl-dev ca-certificates -y
WORKDIR /app

COPY package.json yarn.lock ./

FROM base as build
RUN export NODE_ENV=production
RUN yarn

COPY . .
RUN yarn run prisma:generate
RUN yarn build

Running yarn does an install of all the packages that we have in our package.json which we copied in during the base step. From there, we can copy in our entire next.js app to the /app folder with the command COPY . .. Once we have our dependencies, we can run the prisma:generate command which we define in the package.json as prisma generate. This generates the client library in our node_modules folder that’s specific to the Prisma schema that we’ve already defined in prisma/schema.prisma.

  1. Create the prod-build layer

Now that we have our site’s code built, we should turn to installing the production dependencies so we can eliminate all the packages that are only for development. Picking up with the base image, we install the production npm packages, and then copy in the Prisma folder so that we can generate the Prisma library within the node_modules folder. To ensure that we keep this production node modules folder intact, we copy it off to prod_node_modules.

FROM base as prod-build

RUN yarn install --production
COPY prisma prisma
RUN yarn run prisma:generate
RUN cp -R node_modules prod_node_modules
  1. Create the production layer

Now that we’ve created all of our build layers, we are ready to assemble the production layer. We start by coping prod_node_modules over to the app's node_modules, next we copy the .next and public folders which are needed for any Next.js apps. Finally, we copy over the prisma folder, which is needed for Prisma to run properly. Our npm start command is different from the development npm run dev command because it runs on port 80 rather than 3000 and it is also using the site built out of .next rather than hot-reloading the source files.

FROM base as prod

COPY --from=prod-build /app/prod_node_modules /app/node_modules
COPY --from=build  /app/.next /app/.next
COPY --from=build  /app/public /app/public
COPY --from=build  /app/prisma /app/prisma

EXPOSE 80
CMD ["yarn", "start"]

In all, by creating a layered approach we can save often save a 1GB or more off the image size which can really speed up the deployment to AWS Fargate, or whatever hosting platform that you choose to do.

Here’s the final full Dockerfile:

FROM node:lts-buster-slim AS base
RUN apt-get update && apt-get install libssl-dev ca-certificates -y
WORKDIR /app

COPY package.json yarn.lock ./

FROM base as build
RUN export NODE_ENV=production
RUN yarn

COPY . .
RUN yarn run prisma:generate
RUN yarn build

FROM base as prod-build

RUN yarn install --production
COPY prisma prisma
RUN yarn run prisma:generate
RUN cp -R node_modules prod_node_modules

FROM base as prod

COPY --from=prod-build /app/prod_node_modules /app/node_modules
COPY --from=build  /app/.next /app/.next
COPY --from=build  /app/public /app/public
COPY --from=build  /app/prisma /app/prisma

EXPOSE 80
CMD ["yarn", "start"]

Running Noted: a cryptocurrency tracker locally and in production

The sample project used for this repo is a simple cryptocurrency tracking application that allows you to add how much of each cryptocurrency you have and it will tell you the current worth based on the market prices. You should create a .env.local that looks like this:

DATABASE_URL=file:dev.db
#CMC_PRO_API_KEY=000-000-000-000-000

The CMC_PRO_API_KEY is optional but if set will pull the latest currency data for the top cryptocurrencies using CoinMarketCap. If you'd like to use it, sign up for a free account over at CoinMarketCap and replace the blank api key with your actual api key and remove the # from the start of the variable definition. If you choose not to use the api, the app will populate with some default coins and prices.

To run it locally, feel free to delete any prisma/dev.db file and prisma/migrations folder that you already have. Next run npm install. Ideally your version of node will match the lts version used in the docker images. You can use nvm to set the version and node --version to check that they are the same. Then you can runnpm run prisma:generate which will generate the library followed by npm run prisma:migrate to create a dev.db file.

From there, you have two options. First, you can run it locally without docker which will allow you to make changes and see them instantly change in your app. This works best for the development stage of things. To run this, run npm run dev.

To run it locally in the docker environment, first you need to build the image with docker-compose build. Next, you can run docker-compose up to actively run the image. There is a volume set up so that it will utilize the prisma/dev.db folder that you have mounted on your host. I'll discuss in a minute why this is not ideal, but in a pinch this can be used to run your webapp in a production environment because the dev.db file is being mounted on your host which will mean that it will persist when the containers crash or the machine or docker has been restarted.

The downsides to running the app with a local dev.db file is that there are no backups or redundancies. For a true production environment, the datasource should be migrated from sqlite to postgresql or mysql connectors with the url being changed to a database connection string. Here's an example of how you'd switch to postgresql.

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}
DATABASE_URL="postgresql://your_user:your_password@localhost:5432/my-prisma-app?schema=public"

For the purposes of this tutorial we wanted to keep it with sqlite because the local development is just so much easier and it is essentially a drop-in replacement to switch over to a more production friendly environment.

Stay tuned for a future blog post where we go through all of the inner-workings of this app and show how Prisma can be used with Next.js to create a nimble fullstack web application!

Learn something new? Share it with the world!

There is more where that came from!

Drop your email in the box below and we'll let you know when we publish new stuff. We respect your email privacy, we will never spam you and you can unsubscribe anytime.