Docker
This guide walks you through running a Prisma ORM application in Docker. You build a small Node.js app with Express, add Prisma ORM to it with orm init, run PostgreSQL from Docker Compose to apply the first migration, and then containerize the app so the app and the database start together with one command.
Prisma ORM has no query engines, no prisma generate step, and no schema.prisma. That makes the container story short: the image needs Node.js, your dependencies, the committed contract artifacts, and the migrations directory. The container applies pending migrations at start and then runs the server.
Every command and output below was run end to end with Docker Desktop.
- Docker and Docker Compose installed
- Node.js 24 or later
Before starting, make sure no PostgreSQL service is running locally and that ports 5432 (PostgreSQL), 3000 (application server), and 5555 (Prisma Studio) are free.
To stop an existing PostgreSQL service, use:
sudo systemctl stop postgresql # Linux
brew services stop postgresql # macOS
net stop postgresql # Windows (Run as Administrator)To stop all running Docker containers and free up ports:
docker ps -q | xargs docker stopTo delegate this guide to your coding agent, copy the prompt below and hand it over:
Containerize a new Express app on Prisma ORM with Docker Compose, following https://www.prisma.io/docs/guides/deployment/docker.md.
1. Create `docker-test`, run `npm init -y` and `npm install express`, then run `npx prisma@latest orm init --yes --target postgres --authoring psl`. Set `"type": "module"` in package.json and add the scripts `start` (`node src/index.ts`), `contract:emit` (`prisma contract emit`), and `db:migrate` (`prisma db migrate`). Run `npx prisma@latest init` so the Prisma agent skills are installed, and use them.
2. Replace `src/prisma/contract.prisma` with a single `User` model (id, createdAt, email, name), run `npx prisma contract emit`, and write `src/index.ts`: an Express server whose `GET /` counts users with `db.orm.public.User.aggregate((a) => ({ total: a.count() }))` and reports whether any exist. Read the port from `PORT` with a default of 3000.
3. Write `docker-compose.postgres.yml` with a `postgres:17` service on port 5432 (user postgres, password prisma, database postgres, with a pg_isready healthcheck). Start it with `docker compose -f docker-compose.postgres.yml up -d`, set `DATABASE_URL="postgresql://postgres:prisma@localhost:5432/postgres"` in `.env`, run `npx prisma migration plan --name init` then `npx prisma db migrate`, start `npm start` in the background, verify `curl http://localhost:3000` returns "No users have been added yet.", stop the server, and run `docker compose -f docker-compose.postgres.yml down`.
4. Regenerate the lockfile with `rm -rf node_modules package-lock.json && npm install` so `npm ci` succeeds on Linux. Write a `.dockerignore` (node_modules, .env, .env.prod), a `Dockerfile` based on `node:24-alpine` that runs `npm ci`, copies the project, and uses `CMD ["sh", "-c", "npm run db:migrate && npm start"]`, a `docker-compose.yml` with a `postgres_db` service and a `server` service that builds the Dockerfile, publishes port 3000, waits for the database healthcheck, and loads `.env.prod`, and `.env.prod` with `DATABASE_URL="postgresql://postgres:prisma@postgres_db:5432/postgres"`.
5. Run `docker compose up --build -d`, verify `curl http://localhost:3000` returns "No users have been added yet.", show me the `server` logs, then run `docker compose down -v`.1. Set up your Node.js and Prisma ORM application
Section titled “1. Set up your Node.js and Prisma ORM application”Start by creating a small Node.js application with Express and Prisma ORM.
Create a new project directory, initialize a Node.js project, and install Express:
mkdir docker-test
cd docker-test
bun init
bun add expressmkdir docker-test
cd docker-test
pnpm init
pnpm add expressmkdir docker-test
cd docker-test
yarn init
yarn add expressmkdir docker-test
cd docker-test
npm init
npm install expressInstalling Express first also creates package-lock.json, which is how the next command detects that you use npm.
Run orm init in the project. It preselects PostgreSQL and the PSL authoring style, installs the runtime and the CLI, and emits the contract artifacts:
bunx prisma@latest orm init --yes --target postgres --authoring pslpnpm dlx prisma@latest orm init --yes --target postgres --authoring pslyarn dlx prisma@latest orm init --yes --target postgres --authoring pslnpx prisma@latest orm init --yes --target postgres --authoring pslpackage.json declares "type": "commonjs" ... the scaffolded prisma/db.ts uses an ESM-only import attribute (`with { type: 'json' }`) and will not load under that module type.
If you want the default, set "type": "module" in package.json.
✔ npm add @prisma/orm-postgres dotenv
✔ npm add -D prisma@latest @types/node
✔ npm add -D @prisma/cli-engine@0.6.1
✔ Emit the contract
│ target: postgres
│ authoring: psl
│ schema: src/prisma/contract.prisma
written
├─ src/prisma/contract.prisma
├─ prisma.config.ts
├─ src/prisma/db.ts
├─ prisma-8.md
├─ .env.example
├─ tsconfig.json
├─ .gitignore
├─ .gitattributes
└─ package.json
✔ Done. Open prisma-8.md to get started.The command creates:
prisma.config.ts, which tells the CLI where the contract lives and loads.envthroughdotenv/config.src/prisma/contract.prisma, your data model.src/prisma/contract.jsonandsrc/prisma/contract.d.ts, the emitted artifacts the runtime and the CLI read. Commit both.src/prisma/db.ts, the client your app imports. It readsDATABASE_URLfrom the environment.
The warning at the top matters: npm init -y writes "type": "commonjs", and the scaffolded db.ts is an ES module. Open package.json, switch the module type, and replace the scripts with the ones this guide uses:
{ "name": "docker-test", "version": "1.0.0", "scripts": { "test": "echo \"Error: no test specified\" && exit 1", "start": "node src/index.ts", "contract:emit": "prisma contract emit", "db:migrate": "prisma db migrate" }, "type": "commonjs", "type": "module", "dependencies": { "@prisma/orm-postgres": "...", "dotenv": "...", "express": "..." }, "devDependencies": { "@prisma/cli-engine": "...", "@types/node": "...", "prisma": "..." }}There is no prisma generate and no postinstall hook to add. Node.js 24 runs src/index.ts directly, so the start script needs no build step either.
package.json declares "type": "commonjs" ... the scaffolded prisma/db.ts uses an ESM-only import attribute (`with { type: 'json' }`) and will not load under that module type.
If you want the default, set "type": "module" in package.json.
✔ npm add @prisma/orm-postgres dotenv
✔ npm add -D prisma@latest @types/node
✔ npm add -D @prisma/cli-engine@0.6.1
✔ Emit the contract
│ target: postgres
│ authoring: psl
│ schema: src/prisma/contract.prisma
written
├─ src/prisma/contract.prisma
├─ prisma.config.ts
├─ src/prisma/db.ts
├─ prisma-8.md
├─ .env.example
├─ tsconfig.json
├─ .gitignore
├─ .gitattributes
└─ package.json
✔ Done. Open prisma-8.md to get started.The command creates:
prisma.config.ts, which tells the CLI where the contract lives and loads.envthroughdotenv/config.src/prisma/contract.prisma, your data model.src/prisma/contract.jsonandsrc/prisma/contract.d.ts, the emitted artifacts the runtime and the CLI read. Commit both.src/prisma/db.ts, the client your app imports. It readsDATABASE_URLfrom the environment.
The warning at the top matters: npm init -y writes "type": "commonjs", and the scaffolded db.ts is an ES module. Open package.json, switch the module type, and replace the scripts with the ones this guide uses:
{
"name": "docker-test",
"version": "1.0.0",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"start": "node src/index.ts",
"contract:emit": "prisma contract emit",
"db:migrate": "prisma db migrate"
},
"type": "commonjs",
"type": "module",
"dependencies": {
"@prisma/orm-postgres": "...",
"dotenv": "...",
"express": "..."
},
"devDependencies": {
"@prisma/cli-engine": "...",
"@types/node": "...",
"prisma": "..."
}
}There is no prisma generate and no postinstall hook to add. Node.js 24 runs src/index.ts directly, so the start script needs no build step either.
Replace the starter contract with a single User model:
// use prisma-8
model User {
id Int @id @default(autoincrement())
createdAt TimestamptzString @default(now())
email String @unique
name String?
}Emit the contract again so contract.json and contract.d.ts match the new model:
bunx prisma contract emitpnpm prisma contract emityarn prisma contract emitnpx prisma contract emit✔ Emitted contract.json and contract.d.ts
storageHash: f340195186d0acf8e9914097e58af0375650e7251358382aa9ad3ed5b724daf7contract emit is offline; it needs no database.
✔ Emitted contract.json and contract.d.ts
storageHash: f340195186d0acf8e9914097e58af0375650e7251358382aa9ad3ed5b724daf7contract emit is offline; it needs no database.
Create src/index.ts next to the src/prisma/ folder:
import express from "express";
import { db } from "./prisma/db.ts";
const app = express();
app.use(express.json());
// Report whether any users exist
app.get("/", async (req, res) => {
const { total } = await db.orm.public.User.aggregate((a) => ({
total: a.count(),
}));
res.json(
total === 0
? "No users have been added yet."
: "Some users have been added to the database.",
);
});
const PORT = Number(process.env.PORT) || 3000;
app.listen(PORT, () => {
console.log(`Server is running on http://localhost:${PORT}`);
});Model access is namespace-qualified on PostgreSQL (db.orm.public.User), and counting goes through .aggregate(...). There is no PrismaClient, no driver adapter, and nothing to instantiate: db.ts already exports the client.
With the application in place, the next step is a PostgreSQL database to migrate against.
2. Set up a PostgreSQL database with Docker Compose
Section titled “2. Set up a PostgreSQL database with Docker Compose”To create the first migration, start a standalone PostgreSQL database with Docker Compose.
2.1. Create a Docker Compose file for PostgreSQL
Section titled “2.1. Create a Docker Compose file for PostgreSQL”Create docker-compose.postgres.yml in the project root:
services:
postgres:
image: postgres:17
restart: always
environment:
- POSTGRES_DB=postgres
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=prisma
ports:
- "5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d postgres"]
interval: 5s
timeout: 2s
retries: 20
volumes:
- postgres_data:/var/lib/postgresql/data
volumes:
postgres_data:docker compose -f docker-compose.postgres.yml up -d Container docker-test-postgres-1 StartedPoint the project at the database. orm init wrote .env.example; create .env with the connection string of the container you just started:
DATABASE_URL="postgresql://postgres:prisma@localhost:5432/postgres"Plan the first migration from the emitted contract. This is offline and writes a migration package under migrations/app/:
bunx prisma migration plan --name initpnpm prisma migration plan --name inityarn prisma migration plan --name initnpx prisma migration plan --name init✔ Planned 3 operation(s)
migrations/app/20260910T1532_init
├─ Create schema "public"
├─ Create table "User"
└─ Add unique constraint on "User" (email)
from: (baseline)
to: f340195186d0acf8e9914097e58af0375650e7251358382aa9ad3ed5b724daf7Apply it to the running database:
✔ Planned 3 operation(s)
migrations/app/20260910T1532_init
├─ Create schema "public"
├─ Create table "User"
└─ Add unique constraint on "User" (email)
from: (baseline)
to: f340195186d0acf8e9914097e58af0375650e7251358382aa9ad3ed5b724daf7Apply it to the running database:
bunx prisma db migratepnpm prisma db migrateyarn prisma db migratenpx prisma db migrate│ migrations: migrations
│ database: postgresql://****:****@localhost:5432/postgres
✔ Applied 1 migration(s) (3 operation(s)) across 1 contract space(s)
App space
├─ Create schema "public"
├─ Create table "User"
├─ Add unique constraint on "User" (email)
└─ marker f340195186d0acf8e9914097e58af0375650e7251358382aa9ad3ed5b724daf7db migrate applies the migration packages in migrations/ and records the resulting contract hash in the database. Running it again is safe; it reports Already up to date. This is the same command the container runs at start in step 3, which replaces prisma migrate deploy from Prisma ORM 7. There is no generate step afterwards: the runtime reads contract.json, which you already emitted.
Confirm the database and the contract agree:
│ migrations: migrations
│ database: postgresql://****:****@localhost:5432/postgres
✔ Applied 1 migration(s) (3 operation(s)) across 1 contract space(s)
App space
├─ Create schema "public"
├─ Create table "User"
├─ Add unique constraint on "User" (email)
└─ marker f340195186d0acf8e9914097e58af0375650e7251358382aa9ad3ed5b724daf7db migrate applies the migration packages in migrations/ and records the resulting contract hash in the database. Running it again is safe; it reports Already up to date. This is the same command the container runs at start in step 3, which replaces prisma migrate deploy from Prisma ORM 7. There is no generate step afterwards: the runtime reads contract.json, which you already emitted.
Confirm the database and the contract agree:
bunx prisma migration statuspnpm prisma migration statusyarn prisma migration statusnpx prisma migration status○ f340195 @contract @db
│↑ 20260910T1532_init ∅ → f340195 3 ops ✓ applied
○ ∅
✔ Up to date○ f340195 @contract @db
│↑ 20260910T1532_init ∅ → f340195 3 ops ✓ applied
○ ∅
✔ Up to dateStart the server:
bun startpnpm startyarn startnpm startServer is running on http://localhost:3000Open http://localhost:3000 or query it with curl:
curl http://localhost:3000"No users have been added yet."Stop the server with Ctrl+C.
Server is running on http://localhost:3000Open http://localhost:3000 or query it with curl:
curl http://localhost:3000"No users have been added yet."Stop the server with Ctrl+C.
Remove the standalone PostgreSQL container:
docker compose -f docker-compose.postgres.yml down --remove-orphansThis stops and removes the container and the default network. The named postgres_data volume stays; add -v to remove it too.
With the application tested locally, the next step is to containerize it.
3. Run the app and database together with Docker Compose
Section titled “3. Run the app and database together with Docker Compose”Containerizing the application means it runs the same way on any host that has Docker installed.
orm init installs its packages in three steps, and on macOS and Windows the resulting package-lock.json misses two Linux-only optional packages that the Prisma CLI depends on. npm ci inside the Linux image then refuses to install:
npm error `npm ci` can only install packages when your package.json and package-lock.json or npm-shrinkwrap.json are in sync. Please update your lock file with `npm install` before continuing.
npm error Missing: @emnapi/runtime@1.11.3 from lock fileRegenerate the lockfile once from a clean install before you build the image:
rm -rf node_modules package-lock.json
bun installrm -rf node_modules package-lock.json
pnpm installrm -rf node_modules package-lock.json
yarn installrm -rf node_modules package-lock.json
npm installThe new lockfile records every platform's optional packages, so the same npm ci works on your machine and in the container.
The new lockfile records every platform's optional packages, so the same npm ci works on your machine and in the container.
Prisma ORM ships no native query engine, so the choice between Alpine and Debian base images is only about image size and your other dependencies. Both node:24-alpine and node:24-slim were tested with this guide and behave the same; no openssl or libc6-compat package is needed.
First, keep the host's node_modules and local environment files out of the image:
node_modules
.env
.env.prodThen create the Dockerfile:
FROM node:24-alpine
WORKDIR /usr/src/app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
CMD ["sh", "-c", "npm run db:migrate && npm start"]What the image needs, and why:
npm ciinstalls dev dependencies too. The container runs the Prisma CLI at start to apply migrations, soprismahas to be in the image.COPY . .brings insrc/prisma/contract.json,src/prisma/contract.d.ts, andmigrations/. Because those are committed, the build has no Prisma step. If you prefer not to commit the emitted artifacts, addRUN npx prisma contract emitafter the copy; it runs offline.CMDapplies pending migrations withdb migrateand then starts the server. On a fresh database this creates the schema; on an existing one it reportsAlready up to dateand moves on.DATABASE_URLis not baked in. The CLI reads it throughprisma.config.tsand the runtime throughsrc/prisma/db.ts, both from the container's environment, which Compose sets in the next step.
3.3. Create and configure a Docker Compose file
Section titled “3.3. Create and configure a Docker Compose file”With the Dockerfile ready, use Docker Compose to manage the app and the database together.
Create docker-compose.yml in the project root:
services:
postgres_db:
image: postgres:17
hostname: postgres_db
container_name: postgres_db
restart: always
environment:
POSTGRES_DB: postgres
POSTGRES_USER: postgres
POSTGRES_PASSWORD: prisma
ports:
- "5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d postgres"]
interval: 5s
timeout: 2s
retries: 20
server:
build:
context: .
dockerfile: Dockerfile
ports:
- "3000:3000"
depends_on:
postgres_db:
condition: service_healthy
env_file:
- .env.prodThe server service waits for the database healthcheck before it starts, so db migrate never runs against a database that is still booting.
3.4. Configure the environment variable for the container
Section titled “3.4. Configure the environment variable for the container”Inside the Compose network the database is reachable by its service name, not localhost. Create .env.prod with that hostname:
DATABASE_URL="postgresql://postgres:prisma@postgres_db:5432/postgres"Compose strips the quotes when it loads the file. .env (with the localhost URL) stays on your machine for local commands; .dockerignore keeps it out of the image.
Build the image and start both services:
docker compose up --build -d[+] up 4/4
✔ Image docker-test-server Built 1.4s
✔ Network docker-test_default Created 0.0s
✔ Container postgres_db Healthy 5.7s
✔ Container docker-test-server-1 Started 5.7sCheck the app:
curl http://localhost:3000"No users have been added yet."The server logs show the migration running before the server starts:
docker compose logs serverserver-1 | > docker-test@1.0.0 db:migrate
server-1 | > prisma db migrate
server-1 | {"kind":"result","envelope":{"ok":true,"commandId":"db.migrate","result":{"ok":true,"migrationsApplied":1,"migrationsTotal":1,...,"summary":"Applied 1 migration(s) (3 operation(s)) across 1 contract space(s)",...}}}
server-1 | > docker-test@1.0.0 start
server-1 | > node src/index.ts
server-1 | Server is running on http://localhost:3000The CLI prints JSON lines when it has no terminal attached, which is what you see in container logs. Restart the server and the migration step becomes a no-op:
docker compose restart server
docker compose logs --since 30s serverserver-1 | ...,"summary":"Already up to date",...
server-1 | Server is running on http://localhost:3000Your Prisma ORM app and database now run together under Docker Compose. To stop everything and remove the containers, network, and volumes:
docker compose down -v3.6. Bonus: browse the database with Prisma Studio
Section titled “3.6. Bonus: browse the database with Prisma Studio”Prisma Studio lets you view and edit your data in the browser. With the Compose stack running, the database is published on port 5432, so run Studio on your machine against it. Studio ships with the Prisma ORM 7 CLI and takes the connection string directly; run it from a directory outside the project, because that CLI cannot read the Prisma ORM 8 prisma.config.ts:
cd .. && npx prisma@prev studio --url "postgresql://postgres:prisma@localhost:5432/postgres"Prisma Studio is running at: http://localhost:5555Open http://localhost:5555 to browse the User table. Once the database has an applied migration, Studio also shows a Migrations view with the history db migrate recorded; see Studio with Prisma ORM.
Running Studio as a Compose service, as the Prisma ORM 7 guide did, does not work from the host: the Studio server binds to 127.0.0.1 inside its container, so a published port never reaches it.
npm cifails in the image withMissing: @emnapi/runtime ... from lock file. The lockfileorm initleaves behind on macOS or Windows lacks Linux-only optional packages. Regenerate it as in step 3.1.db.tswill not load. Iform initwarned about"type": "commonjs", set"type": "module"inpackage.json. The scaffoldeddb.tsimportscontract.jsonwith an ES module import attribute.- The image is large. The built image measured 2.12 GB on disk on
node:24-alpineand 2.23 GB onnode:24-slim(theDISK USAGEcolumn ofdocker image ls), almost all of it theprismadev dependency, which bundles the Composer and deploy toolchain. The image needs the CLI because it runsdb migrateat start. docker run --env-filekeeps the quotes. Compose strips the quotes aroundDATABASE_URLin.env.prod; plaindocker run --env-filedoes not, and the Prisma CLI then rejects the URL. Pass-e DATABASE_URL=...without quotes when you run the image by hand.- Port 3000 or 5432 is already in use. Change the host side of the port mapping (
"3100:3000") or stop the service that holds the port; the container side stays the same.
Run npx prisma@latest init once to install the Prisma ORM skills for your coding agent and keep them matching your installed packages. Prompts that map to this guide:
- "Using the prisma-8 skill, add a POST /users route that creates a user from the request body."
- "Add a
Postmodel with an author relation to the contract, plan a migration named add_posts, and apply it with db migrate." - "Add a healthcheck to the server service in docker-compose.yml that hits GET / once the migration has run."
- Change the schema in
src/prisma/contract.prisma, runnpm run contract:emit, thennpx prisma migration plan --name <change>; the nextdocker compose up --buildapplies it. See Generating a migration and Applying a migration. - Learn the fundamentals: filtering, sorting, pagination, and writes.
- Read the Prisma ORM overview for the concepts behind contracts and typed queries.