Docker Compose: Understanding Ports vs Expose
The difference between ports
and expose
in Docker Compose often confuses developers, especially when building multi-container applications. Both deal with container networking, but they serve different purposes and have different security implications.
Understanding when to use each option will help you build more secure applications and avoid common networking pitfalls. The choice between them affects whether services are accessible from outside your Docker network and how containers communicate with each other.
How Docker Container Networking Works
Before diving into the specifics, it's helpful to understand Docker's networking model. When you run Docker Compose, it creates an isolated network where containers can communicate with each other using service names as hostnames.
Host Machine (localhost:3000)
│
├── Docker Network (isolated)
│ ├── web-app (port 3000 internal)
│ ├── api-server (port 8000 internal)
│ └── database (port 5432 internal)
│
└── External Access (only if ports are published)
Containers within the same Docker Compose network can always reach each other using internal ports, regardless of whether those ports are published to the host. The ports
and expose
directives control different aspects of this networking behavior.
Using Ports for External Access
The ports
directive publishes container ports to the host machine, making them accessible from outside the Docker network. This is what you use when you want to access a service from your browser or external applications.
# docker-compose.yml
version: '3.8'
services:
web-frontend:
image: nginx:alpine
ports:
- '80:80' # Host port 80 maps to container port 80
- '443:443' # Host port 443 maps to container port 443
volumes:
- ./public:/usr/share/nginx/html
api-backend:
image: node:18-alpine
ports:
- '3000:3000' # Make API accessible on localhost:3000
working_dir: /app
volumes:
- ./api:/app
command: npm start
environment:
- NODE_ENV=development
With this configuration, you can access the nginx server at http://localhost:80
and the Node.js API at http://localhost:3000
from your host machine. The format is "host_port:container_port"
, and Docker creates the necessary port forwarding rules.
You can also let Docker choose random host ports:
services:
test-app:
image: python:3.9-slim
ports:
- '5000' # Docker assigns a random host port
- '8080:8000' # Explicit mapping: host 8080 to container 8000
command: python app.py
Use docker-compose ps
to see which random ports Docker assigned:
docker-compose ps
# Shows the actual port mappings like 0.0.0.0:32768->5000/tcp
Using Expose for Internal Communication
The expose
directive documents which ports a container makes available to other containers in the same network. It doesn't publish ports to the host machine, so these services remain inaccessible from outside the Docker network.
# docker-compose.yml
version: '3.8'
services:
web-app:
image: nginx:alpine
ports:
- '80:80' # Accessible from host
depends_on:
- api-service
api-service:
image: node:18-alpine
expose:
- '8000' # Only accessible from other containers
working_dir: /app
volumes:
- ./api:/app
command: npm start
environment:
- DATABASE_URL=postgresql://postgres:5432/myapp
database:
image: postgres:15
expose:
- '5432' # Database only accessible internally
environment:
- POSTGRES_DB=myapp
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=secretpassword
volumes:
- postgres_data:/var/lib/postgresql/data
volumes:
postgres_data:
In this setup, the web app can reach the API service at http://api-service:8000
and the API can connect to the database at postgres:5432
. However, you cannot access the API or database directly from your host machine - they're protected within the Docker network.
The expose
directive is actually optional in most cases. Containers can communicate on any port regardless of whether it's explicitly exposed. The main value of expose
is documentation - it tells other developers which ports the service uses.
Practical Example: Multi-Tier Application
Let's build a realistic example that demonstrates both ports
and expose
in a typical web application architecture. This setup includes a React frontend, Express.js API, and PostgreSQL database.
# docker-compose.yml
version: '3.8'
services:
# Frontend - needs external access for development
frontend:
image: node:18-alpine
ports:
- '3000:3000' # Accessible at localhost:3000
working_dir: /app
volumes:
- ./frontend:/app
- /app/node_modules
command: npm run dev
environment:
- REACT_APP_API_URL=http://localhost:8000
depends_on:
- backend
# Backend API - needs external access for testing
backend:
image: node:18-alpine
ports:
- '8000:8000' # Accessible at localhost:8000 for testing
working_dir: /app
volumes:
- ./backend:/app
- /app/node_modules
command: npm run dev
environment:
- DATABASE_URL=postgresql://dbuser:dbpass@database:5432/ecommerce
- NODE_ENV=development
depends_on:
- database
# Database - internal only for security
database:
image: postgres:15
expose:
- '5432' # Only containers can access
environment:
- POSTGRES_DB=ecommerce
- POSTGRES_USER=dbuser
- POSTGRES_PASSWORD=dbpass
volumes:
- postgres_data:/var/lib/postgresql/data
- ./init.sql:/docker-entrypoint-initdb.d/init.sql
# Redis cache - internal only
redis:
image: redis:7-alpine
expose:
- '6379' # Only containers can access
command: redis-server --appendonly yes
volumes:
- redis_data:/data
volumes:
postgres_data:
redis_data:
This configuration allows you to:
- Access the React app at
http://localhost:3000
- Test API endpoints at
http://localhost:8000
- Keep the database and Redis secure (not accessible from outside)
- Enable all services to communicate internally using service names
Production vs Development Configurations
Your port configuration should differ between development and production environments. Development needs more external access for testing and debugging, while production should minimize exposed services.
Development configuration (docker-compose.dev.yml
):
version: '3.8'
services:
web:
image: myapp:latest
ports:
- '3000:3000' # External access for testing
api:
image: myapi:latest
ports:
- '8000:8000' # External access for API testing
database:
image: postgres:15
ports:
- '5432:5432' # Direct database access for debugging
environment:
- POSTGRES_PASSWORD=devpassword
Production configuration (docker-compose.prod.yml
):
version: '3.8'
services:
web:
image: myapp:latest
ports:
- '80:80' # Only web interface exposed
- '443:443'
api:
image: myapi:latest
expose:
- '8000' # Internal only - accessed through web proxy
database:
image: postgres:15
expose:
- '5432' # Internal only - no direct access
environment:
- POSTGRES_PASSWORD_FILE=/run/secrets/db_password
secrets:
- db_password
secrets:
db_password:
file: ./secrets/db_password.txt
You can use different compose files for different environments:
# Development with external database access
docker-compose -f docker-compose.yml -f docker-compose.dev.yml up
# Production with minimal exposure
docker-compose -f docker-compose.yml -f docker-compose.prod.yml up
Security Implications and Best Practices
The choice between ports
and expose
has significant security implications. Publishing ports with ports
creates network attack surface, while expose
keeps services protected within the Docker network.
Follow these security guidelines:
# Good: Minimal external exposure
services:
proxy:
image: nginx:alpine
ports:
- "80:80" # Only expose what users need
- "443:443"
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf
app:
image: myapp:latest
expose:
- "3000" # Internal only
database:
image: postgres:15
expose:
- "5432" # Never expose databases directly
environment:
- POSTGRES_PASSWORD_FILE=/run/secrets/db_password
# Bad: Unnecessary exposure
services:
app:
image: myapp:latest
ports:
- "3000:3000" # Bypasses proxy security
database:
image: postgres:15
ports:
- "5432:5432" # Direct database access is dangerous
When you must expose services for development, use specific IP addresses to limit access:
services:
database:
image: postgres:15
ports:
- '127.0.0.1:5432:5432' # Only accessible from localhost
environment:
- POSTGRES_PASSWORD=devonly
This binds the port only to localhost, preventing access from other machines on your network.
Troubleshooting Network Issues
When containers can't communicate, the problem is usually related to port configuration or network connectivity. Here are common issues and solutions:
# Check if containers are on the same network
docker network ls
docker network inspect myproject_default
# Test connectivity between containers
docker-compose exec web-app ping api-service
docker-compose exec web-app nc -zv api-service 8000
# Check actual port mappings
docker-compose ps
docker port myproject_web-app_1
# View container logs for network errors
docker-compose logs api-service
Remember that containers use service names as hostnames, not localhost. Inside the web-app
container, you connect to http://api-service:8000
, not http://localhost:8000
.
Moving Forward with Container Networking
You now understand the fundamental difference between ports
and expose
in Docker Compose. Use ports
when you need external access and expose
(or nothing at all) for internal services.
Start with minimal external exposure and add ports only when necessary. This approach keeps your applications secure while maintaining the flexibility to access services when needed for development and testing.
Consider using reverse proxies like nginx or Traefik to further control access to your services, especially in production environments where you want fine-grained control over routing and security.
Found an issue?