DEV Community πŸ‘©β€πŸ’»πŸ‘¨β€πŸ’»

Cover image for Dockerizing Next.js with Prisma for Production Environments
Code Mochi
Code Mochi

Posted on • Originally published at codemochi.com

Dockerizing Next.js with Prisma for Production Environments

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 ./
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
  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"]
Enter fullscreen mode Exit fullscreen mode

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"]
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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")
}
Enter fullscreen mode Exit fullscreen mode
DATABASE_URL="postgresql://your_user:your_password@localhost:5432/my-prisma-app?schema=public"
Enter fullscreen mode Exit fullscreen mode

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!

Originally posted at Code Mochi.

Top comments (0)

typescript

11 Tips That Make You a Better Typescript Programmer

1 Think in {Set}

Type is an everyday concept to programmers, but it’s surprisingly difficult to define it succinctly. I find it helpful to use Set as a conceptual model instead.

#2 Understand declared type and narrowed type

One extremely powerful typescript feature is automatic type narrowing based on control flow. This means a variable has two types associated with it at any specific point of code location: a declaration type and a narrowed type.

#3 Use discriminated union instead of optional fields

...

Read the whole post now!