July 11, 2026


Reference: Docker Compose docs ยท Prisma docs ยท ioredis
This guide follows the same kind of setup used in this app: Next.js App Router, TypeScript, Prisma ORM, a self-hosted PostgreSQL instance, and a traditional Redis instance โ both running locally via Docker, ready to be deployed the same way on a VPS later.
I wanted a local database setup that mirrors production exactly, instead of relying on a hosted free-tier database that behaves differently once real traffic hits it.
Docker Compose is useful because it gives you two separate pieces that work together:
The important idea is this:
Your app talks to Postgres and Redis over normal network connections (
localhost:5432,localhost:6379) โ it has no idea they're running inside Docker.
That means your Prisma schema, your DATABASE_URL, and your Redis client code stay identical whether you're running locally or on a production server.
By the end, you will have this:
docker-compose.yml
.env
lib/
redis.ts
prisma.ts
scripts/
test-redis.ts
prisma/
schema.prismaTwo running containers:
postgres โ localhost:5432
redis โ localhost:6379Before setting this up, make sure your machine already has:
pnpm add prisma @prisma/client)Check Docker is ready before continuing:
docker --version
docker compose versionIf either command fails, install Docker Desktop first and restart your machine.
PostgreSQL defaults to port 5432, Redis to 6379. Before writing any config, confirm nothing else on your machine is already using them:
netstat -ano | findstr :5432
netstat -ano | findstr :6379If both commands return nothing, you're clear to use the default ports. If either returns a process, either stop that process or change the left-hand port number later in docker-compose.yml (e.g. '5433:5432') โ the right-hand number always stays the same, since that's the container's internal port.
docker-compose.ymlCreate this file at your project root:
version: '3.9'
services:
postgres:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_USER: craftui
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: craftui
ports:
- '5432:5432'
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U craftui']
interval: 5s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
restart: unless-stopped
command: redis-server --requirepass ${REDIS_PASSWORD} --appendonly yes
ports:
- '6379:6379'
volumes:
- redisdata:/data
healthcheck:
test: ['CMD', 'redis-cli', '-a', '${REDIS_PASSWORD}', 'ping']
interval: 5s
timeout: 5s
retries: 5
volumes:
pgdata:
redisdata:What this file does:
postgres runs a lightweight Alpine-based Postgres 16 image, with data persisted in a named Docker volume (pgdata) so it survives container restarts.redis runs Redis 7, protected with a password (--requirepass) and configured for AOF persistence (--appendonly yes) so cached data isn't lost if the container restarts.healthcheck blocks let Docker (and you) verify each service is actually accepting connections, not just that the container started.${POSTGRES_PASSWORD}, ${REDIS_PASSWORD}) instead of hardcoded passwords, which are supplied by your .env file next.Before writing your .env file, pick passwords that are safe to use inside connection URLs.
Avoid
@,:,/,#, and?in your passwords. These characters have special meaning inside a URL (likeDATABASE_URL) and will break parsing unless carefully percent-encoded. Stick to letters, numbers, hyphens, and underscores.
For example, avoid:
WatuuloB54F20@Use instead:
WatuuloB54F20x.env filePOSTGRES_PASSWORD=your_strong_password_here
REDIS_PASSWORD=your_strong_redis_password_here
DATABASE_URL="postgresql://craftui:your_strong_password_here@localhost:5432/craftui"
REDIS_URL="redis://:your_strong_redis_password_here@localhost:6379"Make sure .env is excluded from version control:
grep -n "^\.env$" .gitignoreIf nothing prints, add it:
echo ".env" >> .gitignoreRun this from your project root, in the same terminal session you use for pnpm dev:
docker compose up -dThe -d flag runs both containers in the background. The first run pulls the postgres:16-alpine and redis:7-alpine images, which takes a minute or two depending on your connection.
docker compose psYou should see both postgres and redis listed with a status of Up (healthy). If either shows Up (health: starting) for more than 30 seconds, or unhealthy, check its logs:
docker compose logs postgres
docker compose logs redisdocker compose exec postgres psql -U craftui -d craftui -c "SELECT version();"A Postgres version string printing back confirms the database is reachable inside the Docker network.
docker compose exec redis redis-cli -a your_strong_redis_password_here pingA reply of PONG confirms Redis is running and accepting authenticated connections.
With both containers healthy and your DATABASE_URL pointing at the running Postgres container:
pnpm dlx prisma generate
pnpm dlx prisma db pushdb push reads prisma/schema.prisma, connects using DATABASE_URL, and creates every table your models describe directly inside the Postgres container.
Confirm the tables actually landed:
docker compose exec postgres psql -U craftui -d craftui -c "\dt"pnpm add ioredisCreate a singleton client so Next.js's hot-reload in development doesn't spawn a new connection on every file save:
import Redis from 'ioredis';
const globalForRedis = globalThis as unknown as { redis: Redis };
export const redis =
globalForRedis.redis ??
new Redis(process.env.REDIS_URL!, {
maxRetriesPerRequest: 3,
});
if (process.env.NODE_ENV !== 'production') globalForRedis.redis = redis;Standalone scripts run outside Next.js's dev server, which means they don't automatically load .env the way next dev does. Load it explicitly:
pnpm add -D dotenv tsximport 'dotenv/config';
import { redis } from '../lib/redis';
async function test() {
await redis.set('test-key', 'hello from craft-ui');
const value = await redis.get('test-key');
console.log('Redis says:', value);
process.exit(0);
}
test();Use a relative import (
../lib/redis) instead of the@/alias in standalone scripts.tsxdoesn't reliably resolve TypeScript path aliases outside the Next.js build pipeline, and this avoidsCannot find module '@/lib/redis'errors entirely.
Run it:
pnpm tsx scripts/test-redis.tsYou should see:
Redis says: hello from craft-uidocker-compose.ymlThis defines both services.
It answers:
.envThis holds every secret and connection string.
It answers:
lib/redis.tsThis is the shared Redis client.
It answers:
prisma/schema.prismaThis defines every table Postgres will hold.
It answers:
prisma db push need to create?NOAUTH Authentication requiredThis means your Redis client connected without a password โ usually because REDIS_URL wasn't loaded.
pnpm add -D dotenvAdd import 'dotenv/config'; as the very first line of any standalone script.
Cannot find module '@/lib/redis'tsx can't resolve the @/ alias outside the Next.js build pipeline.
Use a relative import instead:
import { redis } from '../lib/redis';Another next dev server is already runningNot a Docker issue, but common in the same workflow โ an orphaned Next.js process is holding the port. On Git Bash (MINGW64), taskkill /PID fails because Git Bash mangles the leading slash. Fix it with:
taskkill //PID 12345 //FDATABASE_URLIf your password contains @, :, /, #, or ?, the connection string will fail to parse correctly. Either choose a password without those characters, or percent-encode them (@ becomes %40) inside the URL only โ not inside POSTGRES_PASSWORD itself.
unhealthyCheck logs for the specific service:
docker compose logs postgres
docker compose logs redisCommon causes: a port conflict with another running service, or a malformed password in .env.
docker compose up -d # start both services in the background
docker compose down # stop and remove containers (data persists in volumes)
docker compose down -v # stop AND wipe all data โ deletes your database
docker compose logs -f # follow logs live
docker compose restart # restart both services
docker compose ps # check status of both servicesUse this checklist when setting up local Postgres and Redis with Docker:
5432 and 6379 are freedocker-compose.yml with both services and healthchecks.env with POSTGRES_PASSWORD, REDIS_PASSWORD, DATABASE_URL, REDIS_URL.env to .gitignoredocker compose up -ddocker compose pspsqlredis-cliprisma generate and prisma db push\dtioredis and create lib/redis.tsdotenv for standalone scriptsThe clean mental model is:
docker-compose.yml
-> docker compose up -d
-> Postgres + Redis running locally
-> .env
-> DATABASE_URL -> Prisma -> your tables
-> REDIS_URL -> lib/redis.ts -> your app's cache and countersOnce both containers are healthy and both connection strings are correct, your Next.js app talks to them exactly the same way it will talk to their production counterparts on a VPS later โ no code changes required when you deploy.
The biggest advice is simple:
Keep passwords free of special URL characters, and always load
.envexplicitly in scripts that run outsidenext dev.
That's the difference between a five-minute setup and an hour of debugging NOAUTH and MODULE_NOT_FOUND errors.