DEV Community

Friedrich WT
Friedrich WT

Posted on

How to Set Up Prisma and MongoDB with Docker Compose for Development

Note: If you are here because you have this issue currently and you just want to copy and paste the code, click to jump directly there.

About two years ago, I wanted to use Prisma in a project but with MongoDB as database. Don’t ask me why, it was one of my first projects and from the fresh tutorial I just watched, the guy used MongoDB, then it was my choice without really understanding why. And in my case, I had a well structured schema for the database, so a relational database like PostgreSQL would have been a better choice. Also, my TypeScript orm of choice is now Drizzle, still I want to share about what was done.

The problem I had was to connect Prisma and MongoDB running in a docker container (local database). Even now, the recommended way according to Prisma docs to do it is to use MongoDB Atlas. This is because the MongoDB Prisma connector requires a replica set deployment for its transactions, and MongoDB Atlas provides such an environment out of the box. But what if you want everything locally (for development or testing for example), or if you want to be able to replicate that infrastructure without any external provider? This is exactly what we are going to do, using Docker Compose. We will use a Next.js application, but any other web framework would be fine.

Let’s start by defining what a replica set is. It appears more in Kubernetes jargon, but basically a replica set is a group of instances (database in our case) that maintain the same dataset. We replicate our data across multiple nodes to guarantee their availability. Instead of having one single database instance we will have for example three of them and if one goes down, the others will still work. In the case of MongoDB, the three base nodes are:

  • the primary: all writes to the database happen here
  • one or many secondary(ies): they replicate the data of the primary node and in case the primary node goes down, a secondary node will be elected to become the new primary node, and the system can still work as expected
  • the arbiter that will be an mediator when a secondary node is chosen as primary. It doesn’t provide data redundancy

A quick excalidraw draft of our replica set:

Simple Replica Set

This is a very simplified description of how the MongoDB replica set works and if you want more details about MongoDB replication system, you can check their docs.

OK, now that we have an overview of the system that is required by Prisma to handle those transactions, it is time to write some code. Let’s start by creating a new Next.js application:

npx create-next-app@latest prisma-mongodb --yes
Enter fullscreen mode Exit fullscreen mode

Then cd in:

cd prisma-mongodb
Enter fullscreen mode Exit fullscreen mode

And start the development server:

npm run dev
Enter fullscreen mode Exit fullscreen mode

I have setup a basic Nextjs to illustrate all this. This is just some markup and it doesn’t really matter.

You can find the exact state of the repo here : Initial setup

Next step is to install Prisma itself. Currently, the version 7 doesn’t support MongoDB. We need to use the version 6.19 which is the latest version with proper support for MongoDB.

We first install it as a dev dependency with the command:

npm install -D prisma@6.19
Enter fullscreen mode Exit fullscreen mode

After, we also install the Prisma client (for database querying) and dotenv (to load the environment variables from a .env file)

Configure Nextjs for ESM compatibility. Make sure the following options are present in your tsconfig.json:

{
  "compilerOptions": {
    "module": "ESNext",
    "moduleResolution": "bundler",
    "strict": true,
    "esModuleInterop": true,
  }
}
Enter fullscreen mode Exit fullscreen mode

And enable ESM in the package.json:

"type": module
Enter fullscreen mode Exit fullscreen mode

We will now proceed to the initialization of Prisma ORM with:

npx prisma && npx prisma init --datasource-provider mongodb --output ../generated/prisma
Enter fullscreen mode Exit fullscreen mode

Those commands will allow us to generate a prisma folder with a schema.prisma file to configure the database connection and a prisma.config.ts (configuration file).

Now comes the fun part: craft the DATABASE_URL. Currently with the commands we just ran, Prisma provides us a .env file with a dummy value for this environment variable.

DATABASE_URL="mongodb+srv://root:randompassword@cluster0.ab1cd.mongodb.net/mydb?retryWrites=true&w=majority"
Enter fullscreen mode Exit fullscreen mode

It is composed of some key elements:

  • Protocol (Here mongodb+srv)
  • Base URL (root:randompassword@cluster0.ab1cd.mongodb.net) formed of the username of the database and its password then the host (which can also be something like localhost:27017)
  • The database (mydb)
  • Additional parameters (retryWrites=true&w=majority)

We are going to create our own replica set with Docker Compose and update the DATABASE_URL accordingly. Let’s start by creating a compose.yaml file (or docker-compose.yml):

Compose code

# compose.yaml or docker-compose.yml

services:
  mongodb-primary:
    image: bitnami/mongodb@${MONGODB_DIGEST}
    container_name: mongodb-primary
    env_file:
      - .env
      - db/.env.mongo.primary
    ports:
      - 127.0.0.1:27017:27017
    volumes:
      - demo-prisma-db:/bitnami
    networks:
      - app-network

  mongodb-secondary:
    image: bitnami/mongodb@${MONGODB_DIGEST}
    container_name: mongodb-secondary
    depends_on:
      - mongodb-primary
    env_file:
      - .env
      - db/.env.mongo.secondary
    ports:
      - 127.0.0.1:27018:27017
    networks:
      - app-network

  mongodb-arbiter:
    image: bitnami/mongodb@${MONGODB_DIGEST}
    container_name: mongodb-arbiter
    depends_on:
      - mongodb-primary
    env_file:
      - .env
      - db/.env.mongo.arbiter
    ports:
      - 127.0.0.1:27019:27017
    networks:
      - app-network

volumes:
  demo-prisma-db:
    driver: local

networks:
  app-network:
    driver: bridge
Enter fullscreen mode Exit fullscreen mode

And the proper .env files:

  • The root .env:
MONGODB_DIGEST=sha256:4ff25cfca3d44751416fffcde9fc3847dcf49ace2998f244a3bf319d9e216125 # I pinned the version of the image to a specific one
MONGODB_REPLICA_SET_NAME=rs0 # The name of the replica set
MONGODB_REPLICA_SET_KEY=replicasetkey123 # The replica set key, required for all our nodes. The current value is just an example
Enter fullscreen mode Exit fullscreen mode
  • A db folder at the root of the repository, with three .env files, each for one of the nodes:

    • .env.mongo.primary:
     MONGODB_REPLICA_SET_MODE=primary # The replication mode   (the type of node)
     MONGODB_ADVERTISED_HOSTNAME=mongodb-primary # It will  make the primary node configured with a hostname instead of an ip address. The other nodes will refer to him as  "mongodb-primary"
     MONGODB_ROOT_PASSWORD=password # The root password of the database, the current value is just an example
    
    • .env.mongo.secondary:
    MONGODB_REPLICA_SET_MODE=secondary # The replication mode (the type of node)
    MONGODB_INITIAL_PRIMARY_HOST=mongodb-primary # The hostname of our primary node
    MONGODB_ADVERTISED_HOSTNAME=mongodb-secondary # The hostname of the secondary node
    MONGODB_INITIAL_PRIMARY_ROOT_PASSWORD=password # The root password of the database
    
    • .env.mongo.arbiter:
     MONGODB_REPLICA_SET_MODE=arbiter # The replication mode (the type of node)
     MONGODB_INITIAL_PRIMARY_HOST=mongodb-primary # The hostname of our primary node
     MONGODB_ADVERTISED_HOSTNAME=mongodb-arbiter # The hostname of the arbiter node
     MONGODB_INITIAL_PRIMARY_ROOT_PASSWORD=password # The root password of the database
    

Now we should be able to start the replica set with:

docker compose up -d
Enter fullscreen mode Exit fullscreen mode

Let’s check that it has properly started. For the primary node:

  • exec in it:
  docker exec -it mongo-primary bash
Enter fullscreen mode Exit fullscreen mode
  • enter in mongosh with the proper authentication credentials:
   > mongosh -u root -p password 
   # The root user is the default user of the db and can be  changed by setting `MONGODB_ROOT_USER`
   # The password is the one we defined with  `MONGODB_ROOT_PASSWORD`
Enter fullscreen mode Exit fullscreen mode
  • check:
  > db.isMaster().ismaster
Enter fullscreen mode Exit fullscreen mode

It should output true.

For the secondary or arbitrary nodes we can repeat the same steps (exec and enter in mongosh) then check the content of our replica set:

rs.status().members
Enter fullscreen mode Exit fullscreen mode

And see an output like:

[
  {
    _id: 0,
    name: 'mongodb-primary:27017',
    health: 1,
    state: 1,
    stateStr: 'PRIMARY',
      ...
  },
  {
    _id: 1,
    name: 'mongodb-secondary:27017',
    health: 1,
    state: 2,
    stateStr: 'SECONDARY',
      ...
  },
  {
    _id: 2,
    name: 'mongodb-arbiter:27017',
    health: 1,
    state: 7,
    stateStr: 'ARBITER',
    ...
  }
]
Enter fullscreen mode Exit fullscreen mode

Finally we can set our DATABASE_URL in the root .env file:

DATABASE_URL=mongodb://root:password@localhost:27017/db-dev?replicaSet=rs0&authSource=admin
Enter fullscreen mode Exit fullscreen mode

We use localhost:27017 for the host because we exposed the container of the primary node on our machine on 127.0.0.1:27017.

To test that everything works, let’s add some dummy models to our schema.prisma:

generator client {
  provider = "prisma-client"
  output   = "../generated/prisma"
}

datasource db {
  provider = "mongodb"
  url      = env("DATABASE_URL")
}

model User {
  id        String   @id @default(auto()) @map("_id") @db.ObjectId
  email     String   @unique
  posts     Post[]
  createdAt DateTime @default(now()) @db.Timestamp()
  updatedAt DateTime @default(now()) @updatedAt @db.Timestamp()
}

model Post {
  id        String   @id @default(auto()) @map("_id") @db.ObjectId
  title     String
  slug      String
  content   String
  published Boolean  @default(false)
  authorId  String   @db.ObjectId
  createdAt DateTime @default(now()) @db.Timestamp()
  updatedAt DateTime @default(now()) @updatedAt @db.Timestamp()

  author User @relation(fields: [authorId], references: [id])
}
Enter fullscreen mode Exit fullscreen mode

After that we can sync the schema with:

npx prisma db push
Enter fullscreen mode Exit fullscreen mode

which will generate the Prisma client and create the collections in MongoDB (we don’t use migrate because it doesn’t support migrations like relational databases). And to have a visual of all those changes, we can run the Prisma studio:

npx prisma studio
Enter fullscreen mode Exit fullscreen mode

And open localhost:5555 where our two collections should display, even if they are currently empty.

Let’s instantiate the Prisma Client we will use in the application. I created a prisma.ts file in the lib folder of the app:

// lib/prisma.ts
import { PrismaClient } from "../../generated/prisma/client";

const globalForPrisma = global as unknown as { prisma: PrismaClient };

export const prisma = globalForPrisma.prisma || new PrismaClient();

if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
Enter fullscreen mode Exit fullscreen mode

We make sure to create one single instance of the Prisma Client by using a global variable. And a seed script to put some dummy data in the database:

// lib/seed.ts
import { prisma } from "./prisma";

async function main() {
  // Create a new user with some posts
  await prisma.user.create({
    data: {
      email: "alice@test.test",

      posts: {
        createMany: {
          data: [
            {
              title: "First Post",
              slug: "first-post",
              content: "This is my first post!",
              published: true,
            },
            {
              title: "His mother had always taught him",
              slug: "his-mother-had-always-taught-him",
              content:
                "His mother had always taught him not to ever think of himself as better than others. He'd tried to live by this motto. He never looked down on those who were less fortunate or who had less money than him. But the stupidity of the group of people he was talking to made him change his mind.",
            },
            {
              title: "He was an expert but not in a discipline",
              slug: "he-was-an-expert-but-not-in-a-discipline",
              content:
                "He was an expert but not in a discipline that anyone could fully appreciate. He knew how to hold the cone just right so that the soft server ice-cream fell into it at the precise angle to form a perfect cone each and every time. It had taken years to perfect and he could now do it without even putting any thought behind it.",
              published: true,
            },
            {
              title: "Dave watched as the forest burned up on the hill",
              slug: "dave-watched-as-the-forest-burned-up-on-the-hill",
              content:
                "Dave watched as the forest burned up on the hill, only a few miles from her house. The car had been hastily packed and Marta was inside trying to round up the last of the pets. Dave went through his mental list of the most important papers and documents that they couldn't leave behind. He scolded himself for not having prepared these better in advance and hoped that he had remembered everything that was needed. He continued to wait for Marta to appear with the pets, but she still was nowhere to be seen.",
            },
          ],
        },
      },
    },
    include: {
      posts: true,
    },
  });
}

main()
  .then(async () => {
    await prisma.$disconnect();
  })
  .catch(async (e) => {
    console.error(e);
    await prisma.$disconnect();
    process.exit(1);
  });
Enter fullscreen mode Exit fullscreen mode

Attach the seed script to the config in the prisma.config.ts (install tsx as a dev dependency)

import "dotenv/config";
import { defineConfig, env } from "prisma/config";

export default defineConfig({
  schema: "prisma/schema.prisma",
  migrations: {
    path: "prisma/migrations",
    seed: "tsx src/lib/seed.ts",
  },
  engine: "classic",
  datasource: {
    url: env("DATABASE_URL"),
  },
});
Enter fullscreen mode Exit fullscreen mode

Then, run it with:

npx prisma db seed
Enter fullscreen mode Exit fullscreen mode

And we should now seed those entries in the database through the Prisma Studio.

Prisma Studio

Let’s fetch that data in our application to properly display it:

Posts list

Each post can be viewed individually:

individual post

And we can also create a new posts:

Create post

Which will appear

Created post added to the list of posts

That’s it. We were able to create our replica set locally using Docker Compose, and connect to it in our application with Prisma. This setup is ideal for development and testing or when you don’t want to use MongoDB Atlas. Thanks you for reading and happy coding !

Top comments (0)