A repeatable Docker development environment makes it easier to run a web application, its database, and supporting services without relying on undocumented machine-specific setup. This guide shows how to create a practical Dockerfile and Compose configuration, manage environment variables, mount source code, persist database data, expose ports, and troubleshoot the problems that commonly appear during local development.
Overview
A local Docker setup usually has two layers. The Dockerfile describes how to build the application image: which runtime to use, which dependencies to install, which files to copy, and which command starts the application. The Compose file describes how multiple containers work together, including application and database services, ports, networks, volumes, and environment variables.
Keeping these responsibilities separate makes the project easier to understand. The Dockerfile should be reusable for the application container, while Compose should provide the local wiring around it. A typical small web project might contain this structure:
project/
├── Dockerfile
├── compose.yaml
├── .dockerignore
├── .env.example
├── package.json
├── src/
└── ...Before starting, install Docker Desktop or a Docker Engine and Compose-compatible command-line tools appropriate to your operating system. Verify the installation with docker --version and docker compose version. Compose commands use the current docker compose form; older projects may use the separate docker-compose command.
The goal is not to put every development concern into a container. Instead, aim for a setup that a teammate can clone, configure, and start with a short documented workflow.
What to track
1. The application image
Start with a development-focused Dockerfile. The example below assumes a Node-based web application, but the same sequence applies to Python, Ruby, PHP, Java, and other stacks: choose a runtime image, establish a working directory, copy dependency manifests, install dependencies, then copy the source code.
FROM node:22-bookworm-slim
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
EXPOSE 3000
CMD ["npm", "run", "dev"]Use a runtime version that your application supports rather than copying this version blindly. Pinning a major version, and often a more specific image reference for team or CI use, reduces unexpected changes when an image is rebuilt. A development image may include tools that would not belong in a production image. Keeping development and production stages separate is often clearer than trying to make one command serve both purposes.
Add a .dockerignore file so local dependencies, build output, environment files, and version-control metadata are not sent to the Docker build context:
node_modules
.git
.env
coverage
distThe exact entries depend on the language and framework. Do not ignore files that the build genuinely needs.
2. Services and dependencies
A Compose file can connect the application to a database while keeping configuration visible in one place:
services:
app:
build:
context: .
ports:
- "3000:3000"
env_file:
- .env
volumes:
- .:/app
- /app/node_modules
depends_on:
- db
db:
image: postgres:16
environment:
POSTGRES_DB: app_dev
POSTGRES_USER: app_user
POSTGRES_PASSWORD: app_password
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
volumes:
postgres_data:The left side of a port mapping is the host port; the right side is the container port. If port 3000 is already occupied, change the host side, such as 3001:3000, and open the application at the new host port. Containers on the same Compose network should normally connect to the database using the service name, such as db, not localhost. Inside the application container, localhost refers to that container itself.
depends_on controls startup order but does not necessarily mean that the database is ready to accept connections. Applications should tolerate a short connection delay, use retry logic, or use a health-check-based startup strategy when the framework supports it.
3. Environment variables and secrets
Commit an .env.example file containing variable names and safe placeholder values, but keep personal credentials in an untracked .env file:
APP_PORT=3000
DATABASE_HOST=db
DATABASE_PORT=5432
DATABASE_NAME=app_dev
DATABASE_USER=app_user
DATABASE_PASSWORD=app_passwordMake the application read these values rather than hard-coding connection details. Ensure .env is listed in .gitignore. Local development credentials should still be treated as credentials: do not paste production passwords into Compose files, shell history, issue trackers, or screenshots.
4. Source mounts and database persistence
The bind mount .:/app makes source changes on the host visible inside the application container. The anonymous volume at /app/node_modules prevents the host's dependency directory from replacing the container's installed dependencies. This pattern is useful, but file-watching behavior can vary by operating system. If hot reload does not work, inspect the framework's polling or file-watcher settings rather than assuming the image build is broken.
The named volume postgres_data keeps database files outside the disposable database container. Removing and recreating a container will not remove that named volume by itself. However, docker compose down -v explicitly removes Compose-managed volumes, so use it only when you intend to reset local database data.
Cadence and checkpoints
Check the setup at predictable points rather than waiting for a broken environment. A short monthly or quarterly review is useful for active projects, while a checkpoint at every framework or runtime upgrade is essential.
- After cloning: Copy
.env.exampleto.env, review the values, and rundocker compose up --build. - After dependency changes: Rebuild or reinstall dependencies deliberately. If the lockfile changes, confirm that the Dockerfile copies it before running the install command.
- After database schema changes: Run migrations inside the application container and confirm that the application points to
db, not the host loopback address. - After image or runtime updates: Read the relevant release notes, rebuild from a clean state when appropriate, and run the project's tests.
- During regular maintenance: Review image tags, unused volumes, exposed ports, environment variable names, and documented startup commands.
Useful commands for this checkpoint include:
docker compose up --build
docker compose ps
docker compose logs -f app
docker compose exec app sh
docker compose downUse docker compose config to render and validate the combined Compose configuration. This can reveal indentation errors, missing variables, or unexpected substitutions before you investigate application behavior.
How to interpret changes
When the setup changes, identify which layer changed first. A failed image build usually points to the Dockerfile, build context, dependency manifest, or network access during installation. A container that starts and exits usually requires the application logs and its configured command. A database connection error often indicates a wrong hostname, port, credential, or startup timing issue.
Read logs for the first meaningful error rather than the final cascade of messages:
docker compose logs --tail=100 app
docker compose logs --tail=100 dbIf the application cannot connect to the database, check these values from inside the application container:
- Hostname: use the Compose service name, commonly
db. - Port: use the database's container port, not necessarily the host-mapped port.
- Database name, user, and password: compare the application variables with the database service configuration.
- Readiness: confirm that the database container is running and not repeatedly restarting.
Permission problems deserve separate attention. Bind-mounted files are owned according to host and container user behavior, which can produce generated files that the host user cannot edit. Avoid running the whole development container as an unnecessarily privileged user. Where practical, configure the container user and group to match the host, or write generated artifacts to a controlled directory.
If data appears to vanish, determine whether the application wrote to the named database volume or to the container's writable layer. If a port is unavailable, inspect the host process or change only the host side of the mapping. For broader debugging, combine container logs with the request and browser checks described in this web application troubleshooting workflow.
When to revisit
Revisit this Docker development environment whenever a recurring variable changes: the language runtime, framework, dependency lockfile, database version, host operating system, Compose syntax, or team onboarding process. A monthly or quarterly review should answer four practical questions: Can a new contributor start the project from the README? Are the image versions intentional? Is local data clearly separated from disposable containers? Are ports and environment variables documented?
After making an update, use a clean verification cycle:
- Record the intended change and its reason.
- Validate the Compose file with
docker compose config. - Rebuild the relevant image with
docker compose build --no-cachewhen cache behavior could hide the change. - Start the services and inspect
docker compose psand logs. - Run a basic application request, database migration or query, and project test command.
- Update
.env.example, the README, and any onboarding notes.
Do not delete persistent volumes as a routine troubleshooting step. First capture any data you need and confirm that a reset is acceptable. When a clean reset is necessary, stop the services, remove the intended volume, and start again with the documented initialization process.
A good Docker development environment is not defined by a complicated Compose file. It is defined by predictable startup, visible configuration, safe local data handling, and a maintenance habit that catches drift before it blocks the next feature or teammate. Keep the setup small, test it after meaningful changes, and treat this checklist as part of the project rather than as a one-time experiment.