A Practical Guide to Custom Docker Networks and Container Communication

This guide provides a practical exploration of custom Docker bridge networks and their role in container communication. Learn how to create, manage, and connect containers using the Docker CLI and Docker Compose. Discover how custom networks enable automatic DNS resolution, improve isolation, and simplify inter-service communication, leading to more robust and scalable containerized applications.

A Practical Guide to Custom Docker Networks and Container Communication

Custom Docker networks are one of those features that feel optional until you run more than one container. The default bridge can run a quick test, but a user-defined bridge gives you predictable service names, cleaner isolation, and easier debugging. For a small app with a web container, an API container, and a database, that difference is immediate: the API can connect to db:5432 instead of chasing whatever IP Docker assigned today.

This guide focuses on user-defined bridge networks on a single Docker host. Overlay networks, Kubernetes networking, and Swarm service discovery solve related problems in multi-host setups, but the bridge network is still the daily tool for local development, small deployments, and Docker Compose projects.

Why the default bridge gets awkward

Docker creates a network named bridge automatically. If you run containers without specifying a network, they usually land there. It works for simple cases, but it is not pleasant for multi-container applications.

On a user-defined bridge network, Docker provides built-in DNS for container names and Compose service names. On the default bridge, name-based discovery is limited and legacy linking is not a pattern you should build around. The practical result is that custom networks let you configure applications with stable hostnames:

DATABASE_HOST=db
REDIS_HOST=redis
API_BASE_URL=http://api:3000

That is easier to read, easier to move between machines, and less fragile than container IP addresses.

Custom networks also create a clearer boundary. Containers attached to the same network can talk to each other. Containers on different networks cannot, unless you attach one container to both or publish ports through the host. This is not a complete security model, but it is a useful layer of separation.

Create a network with the Docker CLI

Create a user-defined bridge:

docker network create app-net

Start two containers on it:

docker run -d --name db --network app-net -e POSTGRES_PASSWORD=devpass postgres:16
docker run -d --name adminer --network app-net -p 8080:8080 adminer

From the adminer container, the database hostname is db. You do not need to know its IP.

Inspect the network:

docker network inspect app-net

You will see the driver, subnet, gateway, and connected containers. When debugging, this command answers a basic question: are the two containers actually on the same network?

You can attach an existing container:

docker network connect app-net some-container

And detach it:

docker network disconnect app-net some-container

Docker will not remove a network while containers are still attached. Disconnect or remove the containers first:

docker network rm app-net

Published ports are different from container-to-container ports

A common confusion: containers on the same Docker network do not need published host ports to talk to each other. Published ports are for traffic entering from the host or outside the host.

If an API container listens on port 3000 and a web container is on the same network, the web container can call:

http://api:3000

You only need -p 3000:3000 if you want to reach the API from your laptop browser or another host through the Docker host.

This means your database usually should not publish a host port in a production-like setup unless something outside Docker needs direct access. Let the API reach db:5432 over the private Docker network instead.

Use Compose for normal multi-service apps

Docker Compose creates a default network for the project even if you do not define one. Services can reach each other by service name:

services:
  web:
    image: nginx:latest
    ports:
      - "8080:80"
    depends_on:
      - api

  api:
    image: my-api:latest
    environment:
      DATABASE_HOST: db

  db:
    image: postgres:16
    environment:
      POSTGRES_PASSWORD: devpass

In that file, api can reach db by using hostname db. web can reach api by using hostname api, assuming the application-level config points there.

You can also define named networks when you want clearer intent or separation:

services:
  web:
    image: nginx:latest
    ports:
      - "8080:80"
    networks:
      - frontend

  api:
    image: my-api:latest
    environment:
      DATABASE_HOST: db
    networks:
      - frontend
      - backend

  db:
    image: postgres:16
    environment:
      POSTGRES_PASSWORD: devpass
    networks:
      - backend

networks:
  frontend:
    driver: bridge
  backend:
    driver: bridge

Here, web cannot talk directly to db because they do not share a network. api is the bridge between the two application layers. This is a useful shape for real services: expose only the edge service to the host, keep the database private, and attach each service only where it needs to communicate.

depends_on is not readiness

Compose depends_on controls start order in common Compose usage, but it does not guarantee that the database is ready to accept connections. Your API may start after the db container process starts and still fail because PostgreSQL is initializing.

Handle readiness in the application with retries, or use a health check and a Compose configuration that respects service health for your Compose version and workflow. Even then, application-level retry logic is still the most reliable habit because databases can restart after initial startup.

A practical API config uses DATABASE_HOST=db and retries connection for a short period before exiting with a clear error.

Custom subnets are useful, but do not overuse them

You can choose a subnet:

docker network create --subnet 172.28.0.0/16 app-net

In Compose:

networks:
  backend:
    driver: bridge
    ipam:
      config:
        - subnet: 172.28.0.0/16

This helps when Docker's automatic subnet overlaps with a VPN, office network, or another route on the host. It is not needed for most projects. Hard-coding container IPs should be rare; service names are usually the better contract.

Troubleshoot network communication

When one container cannot reach another, check in this order:

  1. Are both containers running?
  2. Are they attached to the same network?
  3. Is the client using the service/container name, not localhost?
  4. Is the server listening on the expected port and interface?
  5. Is the port published only when host access is needed?

The localhost mistake is especially common. Inside a container, localhost means that same container, not the Docker host and not another service. If the API tries to connect to localhost:5432, it is looking for PostgreSQL inside the API container. Use db:5432 when the database service is named db.

Inspect networks:

docker network inspect app-net

Run a temporary diagnostic container on the same network:

docker run --rm -it --network app-net alpine sh

Inside it, install or use available tools as needed:

getent hosts db
nc -vz db 5432

Minimal images may not have nc, curl, or DNS tools installed. A short-lived debug container is often cleaner than adding troubleshooting packages to your application image.

A sensible default pattern

For most single-host apps, use Compose and let it create the project network. Add explicit networks when you need separation, such as frontend and backend. Use service names for internal traffic. Publish only the ports that humans, reverse proxies, or external systems need to reach.

That gives you a setup that is easy to explain:

  • Browser reaches localhost:8080 because web publishes a port.
  • web reaches api over the Docker network.
  • api reaches db over the backend network.
  • db has no host port unless there is a real operational reason.

Custom Docker networks are not just a neat feature. They are the difference between containers that happen to run on the same machine and services that have a clear communication model.

Network aliases can make migrations easier

Sometimes an application expects a hostname you do not want to use as the Compose service name. You can add an alias on a network:

services:
  postgres:
    image: postgres:16
    networks:
      backend:
        aliases:
          - database

networks:
  backend:
    driver: bridge

Containers on backend can now reach the service as postgres or database. This is handy when migrating an older app that already uses DATABASE_HOST=database, but I would not use aliases everywhere. Service names are simpler when you control the application config.

Host access is a separate problem

A container talking to another container is different from a container talking to the Docker host. On Docker Desktop, host.docker.internal is commonly available. On Linux, support depends on Docker version and configuration; many teams add it explicitly when needed:

docker run --add-host=host.docker.internal:host-gateway ...

Use this sparingly. If a container depends heavily on services running directly on the host, your setup may become harder to reproduce in CI or on another developer's machine. For databases and caches, running the dependency as another service on the same Docker network is usually cleaner.

Internal ports should match the process, not the Dockerfile comment

Docker networking does not care what a Dockerfile EXPOSE line says unless tooling uses it as metadata. The application must actually listen on the port you call. If a Node app listens on 3000, other containers should use api:3000 even if someone wrote EXPOSE 8080 by mistake.

Also check the bind address. A service listening on 127.0.0.1 inside its container may not be reachable from other containers. For container-to-container traffic, the process usually needs to listen on 0.0.0.0 or the container's network interface.

Keep network design boring

It is tempting to create many networks because the feature exists. Start with the communication paths you actually need. A small app might need only the default Compose network. A more realistic web app might need frontend and backend. Beyond that, each new network should have a reason someone can explain during an incident.

Good network design makes troubleshooting easier. When web cannot reach db, and you know they intentionally do not share a network, the answer is architectural rather than mysterious. When every service is attached to every network, the network no longer documents anything.

A real-world review pass before you ship

Before calling a script or container setup finished, read it once as if you are the next person who has to debug it at 2 a.m. That changes what you notice. A prompt that made sense while writing the script may be ambiguous when it appears in a CI log. A Docker service name that felt obvious may not match the variable name in the application. A Bash default may be safe for development and dangerous for production.

I like to do a short dry run with deliberately awkward values. Use a path with spaces. Use an empty optional value. Try a filename that starts with a dash. Run the script from a different working directory. Start the container without one expected environment variable. These tests are not fancy, but they catch the assumptions that usually break first.

Also check the failure message. If the only output is failed, the article's advice has not made it into the implementation. A useful failure says what value was used, what check failed, and what the operator can change. That does not mean dumping every environment variable or printing secrets. It means being specific where specificity helps: the config path, the missing command name, the network name, the service hostname, or the port the process tried to bind.

The final habit is to keep examples close to the way the system is actually run. If production uses Compose, test with Compose. If a script is launched by systemd, test it with systemd or with a similarly minimal environment. If a command is supposed to be safe for copy and paste, include the quoting, -- separators, and validation in the example itself. Readers copy working patterns more often than they copy warnings.

That review pass is not bureaucracy. It is how small automation stays boring. Boring is what you want from shell prompts, config loaders, variable expansion, container diagnostics, and Docker networking. The less surprising the behavior is, the easier it is for the next operator to trust it.

For Docker networking, document the intended traffic path beside the Compose file or in the service README. A short note such as web -> api:3000 -> db:5432 prevents a lot of confusion. It also makes reviews easier: if someone publishes the database port or attaches web to the backend network, the change has to justify itself against the intended path.

When the app grows, revisit the network map. Old aliases, unused published ports, and services attached to networks they no longer need are quiet sources of operational risk.