2024-12-15
6 min read

Docker Compose: Understanding Ports vs Expose

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.

Published: 2024-12-15|Last updated: 2024-12-15T11:00:00Z

Found an issue?