Docker Compose Explained: From One Container to a Complete Development Stack
Learn Docker Compose from first principles by running a multi-container application with services, networks, volumes, health checks, environment variables, profiles, and production-ready practices.
Running one container with docker run is simple. A real application, however, usually needs more than one process: a web application, a database, a cache, perhaps a background worker, and sometimes a local mail or object-storage service. Starting every container with a long command, remembering the correct ports, and manually connecting networks quickly becomes difficult.
Docker Compose solves this problem by describing the entire application stack in one YAML file. With a single command, you can build images, create networks and volumes, start every service, inspect logs, and stop the complete environment.
This guide explains Docker Compose from first principles and builds a practical stack with a Node.js application and PostgreSQL. The same concepts apply to Next.js, Laravel, Go, Python, Java, and most other containerized applications.
What is Docker Compose?
Docker Compose is a tool for defining and running multi-container applications. You describe the desired services and their configuration in a Compose file, usually named compose.yaml, and Docker turns that declaration into running containers and related resources.
A Compose application can define:
- services such as an API, database, cache, or worker,
- container images or local build instructions,
- published ports,
- environment variables,
- persistent volumes,
- private or shared networks,
- health checks and startup dependencies,
- secrets and configuration files, and
- optional services enabled through profiles.
Modern Docker uses the command with a space:
docker compose upYou may still see the old standalone docker-compose command in older tutorials. Current projects should normally use the Compose plugin integrated into the Docker CLI.
The current format is the Compose Specification. You do not need a top-level version: '3' field. That property exists only for backward compatibility and is considered obsolete by modern Compose.
Compose versus Dockerfile
A Dockerfile and a Compose file solve different problems.
A Dockerfile explains how to build one image:
FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
CMD ["npm", "start"]A Compose file explains how one or more containers should run together:
services:
app:
build: .
ports:
- "3000:3000"The Dockerfile creates the application image. Compose builds that image, starts a container from it, publishes a port, connects it to other services, and manages the surrounding environment. Most multi-container projects use both files.
Our example project
We will use a small Node.js application connected to PostgreSQL. The project structure looks like this:
my-app/
├── compose.yaml
├── Dockerfile
├── .dockerignore
├── .env
├── package.json
├── package-lock.json
└── src/
└── server.jsThe important part of this tutorial is compose.yaml, but the Dockerfile gives Compose an application image to build.
FROM node:22-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
EXPOSE 3000
CMD ["npm", "start"]Add a .dockerignore file so unnecessary local files are not sent into the build context:
node_modules
npm-debug.log
.git
.env
coverageExcluding .env also helps prevent local secrets from being copied into an image layer.
Building the first Compose file
Create compose.yaml in the project root:
name: demo-stack
services:
app:
build:
context: .
dockerfile: Dockerfile
ports:
- "${APP_PORT:-3000}:3000"
environment:
NODE_ENV: development
DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
depends_on:
db:
condition: service_healthy
restart: unless-stopped
db:
image: postgres:17-alpine
environment:
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- postgres-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
interval: 5s
timeout: 3s
retries: 10
start_period: 10s
restart: unless-stopped
volumes:
postgres-data:Create a local .env file beside it:
APP_PORT=3000
POSTGRES_DB=app
POSTGRES_USER=app
POSTGRES_PASSWORD=local-development-passwordDo not commit real credentials. A repository can include .env.example with safe placeholders so other developers know which values are required.
Start the application:
docker compose up --buildCompose builds the app image, downloads PostgreSQL if necessary, creates a project network and named volume, starts both containers, and attaches their logs to the terminal.
Press Ctrl+C to stop the foreground process, or start everything in detached mode:
docker compose up -d --buildUnderstanding the Compose file
Project name
name: demo-stackThe project name groups the containers, networks, and volumes created by Compose. It also helps avoid naming collisions when several projects use common service names such as app and db.
You can override it from the command line:
docker compose -p feature-123 up -dThis is useful for running isolated copies of the same stack.
Services
services:
app:
# ...
db:
# ...A service describes a type of container in the application. In this stack, app is built from local source code and db uses an existing PostgreSQL image. A service is not necessarily limited to one container; some environments can scale stateless services to multiple replicas.
Build versus image
The application uses build:
app:
build:
context: .
dockerfile: Dockerfilecontext defines the files available during the image build. dockerfile selects the Dockerfile relative to that context.
PostgreSQL uses image:
db:
image: postgres:17-alpinePinning a meaningful version is safer than relying on latest, which can change unexpectedly. For stricter reproducibility, production systems may pin an immutable image digest.
Port mapping
ports:
- "${APP_PORT:-3000}:3000"The left side is the host port; the right side is the container port. If APP_PORT is not set, Compose uses the default value 3000. A request to localhost:3000 is forwarded to port 3000 inside the application container.
The database has no ports section. The application can still reach it through the internal Compose network, but PostgreSQL is not unnecessarily exposed on the host. Add a host port only when a local database tool genuinely needs direct access:
ports:
- "127.0.0.1:5432:5432"Binding to 127.0.0.1 limits access to the local machine.
Service discovery and networking
Compose creates a default network for the project. Every service on that network can reach another service by its service name. That is why the application connects to this hostname:
db:5432Inside the app container, localhost refers to the application container itself—not the host machine and not PostgreSQL. Using localhost:5432 would therefore fail. The correct database host is db, because that is the Compose service name.
You can declare custom networks when isolation is useful:
services:
proxy:
image: nginx:alpine
networks:
- public
- private
app:
build: .
networks:
- private
db:
image: postgres:17-alpine
networks:
- private
networks:
public:
private:Here, only the proxy joins the public-facing network, while application and database communication remains on the private network. A custom network is not automatically a complete security boundary, but it reduces accidental connectivity and documents the intended architecture.
Persisting data with named volumes
Containers are disposable. When a database container is removed, data written only to its writable container layer disappears with it. The named volume solves that problem:
services:
db:
volumes:
- postgres-data:/var/lib/postgresql/data
volumes:
postgres-data:Docker manages postgres-data separately from the container lifecycle. The database can be recreated while its files remain available.
Stop and remove the containers and network:
docker compose downThe named volume remains. Start the stack again and the database data is still there.
To remove the volume as well:
docker compose down --volumesThis permanently deletes the Compose-managed database data. Use it deliberately, especially outside a disposable development environment.
Named volumes versus bind mounts
A named volume is managed by Docker:
volumes:
- postgres-data:/var/lib/postgresql/dataA bind mount maps a specific host path into a container:
volumes:
- ./src:/app/srcBind mounts are useful for live development because source changes appear immediately inside the container. Named volumes are generally a better fit for database data because Docker manages their location and lifecycle.
Startup order is not readiness
A common mistake is assuming that depends_on means a database is ready to accept connections. Starting a container only means its process has begun; PostgreSQL may still be initializing.
The database health check tests readiness:
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
interval: 5s
timeout: 3s
retries: 10
start_period: 10sThe application then waits for the healthy state:
depends_on:
db:
condition: service_healthyThe double dollar signs in $${POSTGRES_USER} are intentional. They pass a literal dollar sign through Compose so the variable is expanded inside the container when the health-check command runs.
Health checks improve startup coordination, but resilient applications should still retry temporary database failures. Dependencies can restart or become unavailable after the initial startup sequence has completed.
Environment variables and interpolation
Compose supports variable interpolation in YAML values:
ports:
- "${APP_PORT:-3000}:3000"Useful forms include:
${VARIABLE} Use the value directly
${VARIABLE:-default} Use a default when missing or empty
${VARIABLE:?message} Fail with a message when missing or emptyFor a value that must be provided, fail early:
environment:
API_KEY: ${API_KEY:?API_KEY must be set}You can inspect the fully resolved configuration before starting containers:
docker compose configThis command is one of the best ways to find YAML merge errors, missing variables, incorrect interpolation, and unexpected final values. Be careful when sharing its output because resolved configuration may contain sensitive values.
An .env file used for interpolation is not the same as an env_file passed into a container. You can explicitly load container variables like this:
services:
app:
env_file:
- .env.appRegardless of the mechanism, do not treat plain environment variables as a perfect secret-management system. Production deployments should use the secret facility provided by the target platform whenever possible.
Development with bind mounts
For a fast edit-refresh cycle, add source code as a bind mount and run the development command:
services:
app:
build: .
command: npm run dev
ports:
- "${APP_PORT:-3000}:3000"
volumes:
- .:/app
- app-node-modules:/app/node_modules
environment:
NODE_ENV: development
DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
volumes:
app-node-modules:
postgres-data:The project directory is mounted at /app, so local edits are immediately visible. A separate named volume protects the Linux dependencies installed inside the container from being replaced by a host node_modules directory.
For Next.js, the development server may need to listen on all interfaces inside the container:
{
"scripts": {
"dev": "next dev --hostname 0.0.0.0"
}
}Compose also supports a watch-based development workflow, which can synchronize files or rebuild a service when selected paths change. Whether bind mounts or Compose Watch feels better depends on the operating system and project size.
Essential Docker Compose commands
Start services and attach to their logs:
docker compose upBuild images before starting:
docker compose up --buildRun in the background:
docker compose up -dList service status:
docker compose psFollow logs from every service:
docker compose logs -fFollow one service and show its last 100 lines:
docker compose logs -f --tail=100 appExecute a command in an already running container:
docker compose exec app npm testOpen a shell:
docker compose exec app shRun a temporary one-off container:
docker compose run --rm app npm run migrateRestart a service:
docker compose restart appStop containers without removing them:
docker compose stopStop and remove the application containers and network:
docker compose downAlso remove named volumes:
docker compose down --volumesPull newer versions of referenced images:
docker compose pullSee the final normalized configuration:
docker compose configOptional services with profiles
Some tools are useful only in specific situations. Profiles let you keep them in the Compose model without starting them every time.
services:
app:
build: .
db:
image: postgres:17-alpine
adminer:
image: adminer:latest
profiles: [tools]
ports:
- "127.0.0.1:8080:8080"A normal start ignores Adminer:
docker compose up -dEnable the tools profile when needed:
docker compose --profile tools up -dProfiles work well for debugging tools, local mail servers, database dashboards, and optional observability services. Core dependencies should not usually be hidden behind a profile.
Multiple Compose files
Development and production often need different settings. Instead of duplicating the entire stack, you can merge files:
compose.yaml
compose.production.yamlThe base file contains shared configuration. The production file overrides selected values:
services:
app:
image: registry.example.com/my-app:${APP_VERSION}
restart: alwaysRun both files in order:
docker compose \
-f compose.yaml \
-f compose.production.yaml \
up -dLater files add to or override earlier files. Always inspect the result:
docker compose \
-f compose.yaml \
-f compose.production.yaml \
configFor larger systems, modern Compose also supports include, which can import separately maintained Compose models. Keep the structure as simple as the team can understand; modularity is helpful only when it reduces ownership and maintenance problems.
Production considerations
Docker Compose is excellent for development, testing, demos, CI jobs, and many single-server deployments. A production Compose file still needs deliberate operational choices.
Build immutable images in CI
Build and test the application image once, publish it to a registry, and deploy that exact tag or digest. Rebuilding source code directly on the production server can produce a different artifact from the one tested in CI.
Do not publish unnecessary ports
Only the reverse proxy or public application normally needs a host port. Databases and caches can remain on internal networks.
Use production secret management
Do not commit passwords or place long-lived credentials in image layers. Use restricted environment files, Docker secrets where appropriate, or the secret manager supplied by the hosting platform.
Set restart behavior and health checks
A restart policy can recover from process failures, while health checks provide visibility into whether a service is actually usable. Neither replaces monitoring, alerting, backups, or application-level retry logic.
Protect persistent data
A volume is not a backup. Test database backups and restoration. Know exactly what docker compose down --volumes would delete before using it on a production host.
Add resource and log controls
A runaway process should not consume the entire server. Configure appropriate resource constraints for the environment and ensure logs are rotated or exported to a log system.
Plan zero-downtime requirements
Plain Compose replaces containers but does not automatically provide every orchestration feature. Applications requiring multi-host scheduling, advanced rolling deployments, automatic failover, or complex autoscaling may need a managed container platform or another orchestrator. Choose based on operational needs, not fashion.
Common mistakes
Using localhost between containers
Inside a container, localhost means that same container. Use the target service name, such as db or redis, for communication over a Compose network.
Assuming depends_on means ready
Startup order and application readiness are different. Add a meaningful health check and make dependent applications resilient with retry logic.
Storing database data only inside the container
Use a named volume for state that must survive container replacement, and maintain separate backups.
Committing secrets
Keep .env out of Git, publish a safe .env.example, and use a proper secret-management mechanism in production.
Using latest everywhere
An unpinned image can change without a code change. Pin versions, test updates, and use digests where immutable deployment is required.
Adding the obsolete version key
Modern Compose follows the current Compose Specification. The top-level version field is no longer needed and may produce an obsolete-field warning.
Confusing down with down --volumes
The first removes containers and networks while preserving named volumes. The second also deletes those volumes and their data.
Treating Compose as a security system
Networks, environment variables, and container boundaries help organize an application, but secure deployment also requires patched images, limited privileges, protected credentials, controlled host access, and monitoring.
A practical debugging checklist
When the stack does not work, inspect it systematically:
- Validate the resolved file with
docker compose config. - Check service state with
docker compose ps. - Read logs with
docker compose logs --tail=200 service-name. - Inspect health-check output with
docker inspect. - Verify that the application uses service names instead of
localhost. - Confirm that required variables are present.
- Test DNS and connectivity from inside the container.
- Rebuild the image if source or dependencies changed.
Useful commands include:
docker compose exec app sh
docker compose exec app getent hosts db
docker compose exec db pg_isready -U app -d app
docker compose build --no-cache appAvoid immediately deleting every volume or pruning all Docker data. Destructive cleanup can erase the evidence needed to understand the problem—and may delete valuable local databases.
Final thoughts
Docker Compose turns a collection of container commands into a versioned application model. Anyone with Docker and the repository can start the same services, networks, ports, and volumes with one command. That consistency is valuable in local development, onboarding, automated tests, CI pipelines, demos, and smaller production environments.
The most important ideas are straightforward:
- A Dockerfile builds an image; Compose runs the complete application.
- Services communicate through Compose networks using service names.
- Named volumes preserve state beyond a container's lifetime.
- Health checks distinguish a running process from a ready service.
- Environment interpolation makes configuration flexible, but secrets still require protection.
docker compose config,ps, andlogsare the first debugging tools to reach for.- Production use requires immutable images, backups, limited exposure, monitoring, and deliberate deployment design.
Start with two services, keep the Compose model readable, and add complexity only when the architecture truly needs it. A clear compose.yaml should be executable documentation for how the application fits together.