In the first article, I made the case that "works on my machine" is an architecture problem. This article turns that principle into a practical starting point: a small application environment that is easy to run, easy to reset, and honest about the difference between local development and production.

The stack is intentionally familiar:

  • Next.js and TypeScript for the web application
  • PostgreSQL for durable application data
  • Prisma for schema and migration management
  • Docker Compose for a repeatable local runtime

This is not a claim that every project needs the same technology. It is a reference pattern for the kind of operational application where data, workflows, and dependable delivery matter. The value comes from the boundaries and habits the pattern establishes.

Decide what needs to be repeatable

Before writing a Dockerfile, identify the contract a new developer needs:

  1. Start the application and database with one command.
  2. Use known runtime and database versions.
  3. Run migrations and seed safe sample data.
  4. Run linting, type checks, tests, and builds with the project's own tools.
  5. Reset local data without affecting anyone else.
  6. Use only local, disposable configuration values.

That is enough for a strong first version. Do not start by recreating every cloud service locally. Start with the services that let you exercise the application's important interfaces and make a useful change with confidence.

The application container and the database container are separate on purpose. Their connection string, startup behavior, and persisted development data are explicit. If a developer needs a clean slate, they know exactly where it is and how to recreate it.

Start with a deliberate project shape

Here is a useful baseline for the repository:

.
├── app/                         # Next.js routes and UI
├── prisma/
│   ├── migrations/              # versioned database changes
│   ├── schema.prisma
│   └── seed.ts                  # safe local sample data
├── .env.example                 # names, never real secrets
├── .gitignore
├── compose.yaml                 # local service definition
├── Dockerfile                   # development and production image stages
├── package.json                 # standard project commands
└── README.md                    # the short, human path to first run

The names are less important than the separation of responsibilities. The database schema and its migration history belong in version control. Configuration has an example file, while real local values remain untracked. The commands people run are exposed through package.json rather than hidden in a personal shell history.

Use one Dockerfile with distinct development and production stages

Local development needs fast feedback and development dependencies. Production needs a smaller runtime image and an explicit start command. A multi-stage Dockerfile lets one file serve both purposes without pretending they have identical needs.

# Dockerfile
FROM node:22-bookworm-slim AS base
WORKDIR /app
ENV NEXT_TELEMETRY_DISABLED=1

FROM base AS dependencies
COPY package.json package-lock.json ./
RUN npm ci

FROM dependencies AS development
COPY . .
EXPOSE 3000
CMD ["npm", "run", "dev"]

FROM dependencies AS builder
COPY . .
RUN npm run build

FROM node:22-bookworm-slim AS production
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
EXPOSE 3000
CMD ["node", "server.js"]

For the production stage above, enable Next.js standalone output in next.config.ts:

import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  output: "standalone",
};

export default nextConfig;

Next.js documents standalone output as a way to generate the minimal files needed to deploy a production Docker image. It is a good fit when your hosting platform runs containers, but it is not required just to develop locally. Next.js deployment documentation

Two cautions are worth calling out:

  • Pin a runtime major version intentionally, then upgrade it through normal pull requests. Do not silently depend on whatever a developer happens to have installed.
  • Do not copy development shortcuts into production. The production stage should contain only what the running application needs.

Add PostgreSQL with Docker Compose

Docker Compose is the practical layer that runs the services together. The following example uses an application container plus a local PostgreSQL database. The password is deliberately a local development value—not a production credential.

# compose.yaml
services:
  app:
    build:
      context: .
      target: development
    command: npm run dev
    ports:
      - "3000:3000"
    environment:
      DATABASE_URL: postgresql://app:local-dev-password@db:5432/app?schema=public
      WATCHPACK_POLLING: "true"
    volumes:
      - .:/app
      - node_modules:/app/node_modules
    depends_on:
      db:
        condition: service_healthy

  db:
    image: postgres:17
    environment:
      POSTGRES_DB: app
      POSTGRES_USER: app
      POSTGRES_PASSWORD: local-dev-password
    ports:
      - "5432:5432"
    volumes:
      - postgres_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app -d app"]
      interval: 5s
      timeout: 5s
      retries: 10

volumes:
  node_modules:
  postgres_data:

The health check matters. Starting a database container is not the same as having a database that is ready to accept connections. Docker Compose can wait for a dependency marked service_healthy before creating the dependent service. Docker Compose startup-order documentation

From the repository root, the first run is straightforward:

docker compose up --build

Then open http://localhost:3000.

Because commands now run inside the application container, use the same environment for database work:

docker compose exec app npx prisma migrate dev --name init
docker compose exec app npx prisma db seed
docker compose exec app npm run lint
docker compose exec app npm test

Later in the series, a development container will make these commands feel like normal terminal commands inside your editor. For now, docker compose exec app makes the boundary clear and keeps every developer using the same client tooling.

Make configuration safe by default

A local environment still needs configuration, but it should never require copying real production values to a laptop.

Commit an .env.example file that documents the names and safe placeholders:

# .env.example
DATABASE_URL=postgresql://app:local-dev-password@db:5432/app?schema=public
NEXT_PUBLIC_APP_URL=http://localhost:3000

Then add .env to .gitignore and let each developer create their own local file only when needed. For example, a non-sensitive local API mock key may live there. A production payment, identity, or cloud-storage secret should not.

The rule is simple: the repository may describe configuration; it must not contain credentials.

Put database change discipline in place early

Database migrations are easy to postpone and difficult to retrofit. They deserve a deliberate routine from the first real table onward.

In a Prisma-based project, a typical local change looks like this:

  1. Update prisma/schema.prisma.
  2. Create and apply a migration against the local development database.
  3. Review the generated SQL migration file.
  4. Commit the schema change and migration together.
  5. Let continuous integration validate that a clean database can apply the migration history.

Prisma explicitly distinguishes its development and production commands: prisma migrate dev is for development, while production deployment uses the migration history through the appropriate deploy workflow. Prisma development and production guidance

Do not make production schema changes when the application starts. An operational deployment should make migrations a visible, owned step with backups and recovery planning. We will cover that path in depth in Article 7.

Know what belongs locally—and what does not

The goal is a useful local system, not a tiny imitation of your entire cloud account.

Keep locallyKeep environment-specific or remote
Application runtimeProduction credentials
PostgreSQL and safe test dataReal customer data
Migration toolsManaged backups and restore authority
Lint, tests, type checks, buildsProduction-only monitoring credentials
Mocked or sandboxed third-party servicesLive payment, identity, or notification systems

For integrations, a small local fake or sandbox is often better than calling a real external service. It makes tests faster, reduces cost, and prevents accidental side effects. When a real sandbox is necessary, scope credentials tightly and document what data may enter it.

Give developers predictable reset options

Every environment eventually gets into a strange state. The difference between a healthy team and a frustrating one is whether recovery is obvious.

Document the normal reset commands:

# Stop containers but keep the local database data
docker compose down

# Reset the local database and named volumes—development data only
docker compose down -v

# Rebuild after Dockerfile or dependency changes
docker compose up --build

The -v command removes the named local volume, which is precisely why it should be labeled clearly: it is useful for disposable development data and inappropriate for any shared or production database.

Common early mistakes

  • Running the app on the host and the database in Docker without documenting the two connection strings. Choose one standard route. This article uses an all-container application workflow.
  • Skipping database readiness checks. Container startup order is not the same as service readiness.
  • Mounting node_modules from the host into a Linux container. Use a named container volume to avoid platform-specific binary mismatches.
  • Using latest image tags. Pin to a deliberate major or vetted version and upgrade intentionally.
  • Committing an .env file with actual credentials. Use examples, managed secrets, and environment-specific configuration instead.
  • Treating local data as disposable without saying so. Make reset behavior explicit before someone loses work.

Practical checklist

  • A single docker compose up --build starts the app and database.
  • PostgreSQL data is stored in a named local volume.
  • The app waits for the database to be healthy before it starts.
  • The project uses one chosen Node runtime version.
  • Application commands run from the container, not from undocumented host tools.
  • .env.example explains variable names without exposing credentials.
  • Schema changes are committed with generated migration files.
  • The README documents both normal startup and the destructive local reset command.

Previous: Why "Works on My Machine" Is an Architecture Problem

Next: Dev Containers: Put the Developer Setup in the Repository. We will layer a full editor-ready development container on top of this stack so onboarding becomes an open-and-build experience instead of a setup ritual.