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:
- Start the application and database with one command.
- Use known runtime and database versions.
- Run migrations and seed safe sample data.
- Run linting, type checks, tests, and builds with the project's own tools.
- Reset local data without affecting anyone else.
- 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:
- Update
prisma/schema.prisma. - Create and apply a migration against the local development database.
- Review the generated SQL migration file.
- Commit the schema change and migration together.
- 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 locally | Keep environment-specific or remote |
|---|---|
| Application runtime | Production credentials |
| PostgreSQL and safe test data | Real customer data |
| Migration tools | Managed backups and restore authority |
| Lint, tests, type checks, builds | Production-only monitoring credentials |
| Mocked or sandboxed third-party services | Live 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_modulesfrom the host into a Linux container. Use a named container volume to avoid platform-specific binary mismatches. - Using
latestimage tags. Pin to a deliberate major or vetted version and upgrade intentionally. - Committing an
.envfile 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 --buildstarts 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.exampleexplains 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.

