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
:
-
base
layer has system dependencies, package.json, yarn.lock, and .env.local file. -
build
layer starts withbase
and installs all dependencies to build.next
directory that has all of the site’s code ready for use. -
prod-build
layer starts withbase
and installs production dependencies only. -
prod
layer starts withbase
and copies production dependencies fromprod-build
,.next
folder frombuild
-
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
.
- 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
.
- 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
- 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 /app/prod_node_modules /app/node_modules
COPY /app/.next /app/.next
COPY /app/public /app/public
COPY /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 /app/prod_node_modules /app/node_modules
COPY /app/.next /app/.next
COPY /app/public /app/public
COPY /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!