Docker
Build an Express app on Prisma ORM, run PostgreSQL from Docker Compose, then run the app and the database together in containers.
Introduction
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.
Prisma ORM 8 is the current release. Prisma ORM 7 remains fully supported; the Prisma ORM 7 version of this guide is at /guides/v7/deployment/docker.
Prerequisites
- 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 stopUse with your agent
To delegate this guide to your coding agent, copy the prompt below and hand it over:
1. Set up your Node.js and Prisma ORM application
Start by creating a small Node.js application with Express and Prisma ORM.
1.1. Initialize your project
Create a new project directory, initialize a Node.js project, and install Express:
mkdir docker-test
cd docker-test
bun init
bun add expressInstalling Express first also creates package-lock.json, which is how the next command detects that you use npm.
1.2. Add Prisma ORM
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 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.3
✔ 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.
1.3. Define the data model
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 emit✔ Emitted contract.json and contract.d.ts
storageHash: f340195186d0acf8e9914097e58af0375650e7251358382aa9ad3ed5b724daf7contract emit is offline; it needs no database.
1.4. Create an Express server
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
To create the first migration, start a standalone PostgreSQL database with Docker Compose.
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:2.2. Start the PostgreSQL container
docker compose -f docker-compose.postgres.yml up -d Container docker-test-postgres-1 Started2.3. Create and apply the first migration
Point 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 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:
bunx 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:
bunx prisma migration status○ f340195 @contract @db
│↑ 20260910T1532_init ∅ → f340195 3 ops ✓ applied
○ ∅
✔ Up to date2.4. Test the application
Start the server:
bun 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.
2.5. Clean up the standalone database
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
Containerizing the application means it runs the same way on any host that has Docker installed.
3.1. Regenerate the lockfile
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 installThe new lockfile records every platform's optional packages, so the same npm ci works on your machine and in the container.
3.2. Create the Dockerfile
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
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
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.
3.5. Build and run the application
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
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.
Common gotchas
Do not call db.close() in a route handler. The client's connection pool is shared across requests; close it only on process shutdown.
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.
Prompt your coding agent
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."
Next steps
- 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.
