Docker Compose networking is simple until a production stack accidentally publishes a database, hard-codes container IPs, or assumes the host firewall will automatically protect every mapped port.
The production goal is not to create the most complicated network layout. It is to make traffic ownership obvious: only services that need host-level access should publish ports, application services should communicate by service name, and sensitive dependencies should stay on networks that do not need public exposure.
This guide owns that networking decision. For the broader single-host operating model, use Docker Compose for Production. For host hardening beyond Compose, use Docker Host Security Checklist for Production VMs.
Docker Compose networking starts with one project network
By default, Docker Compose creates a network for the application and connects the project's services to it. Services on that network can discover one another by service name through Docker's internal DNS.
That means an application service should normally connect to PostgreSQL as something like:
postgres://db:5432
—not by using the database container's current IP address.
Compose service names are stable discovery names; container IP addresses are not.
When a container is recreated, Docker can assign it a new IP address while keeping the service name available. Applications should reconnect by name rather than persisting container IPs in configuration.
A simple topology looks like this:
Internet ↓ Host public interface ↓ published 80/443 Reverse proxy ↓ Compose network Web / API ├── db:5432 ├── redis:6379 └── worker service
The key distinction is that service-to-service traffic uses the container port on the Compose network. It does not need the host port used by external clients.
If the database listens on 5432 inside its container, the application usually connects to db:5432. Publishing 5432 on the VM is unnecessary unless something outside the Compose network genuinely needs direct access.
ports and internal service access solve different problems
One of the most important Docker Compose networking decisions is whether a service needs a host port at all.
Use this rule:
| Requirement | Better default |
|---|---|
| Internet users need the service | Publish a deliberately chosen host port |
| Reverse proxy on the same Compose network needs the app | Do not publish the app port |
| App needs PostgreSQL or Redis | Connect by service name and container port |
| Host-only administration needs access | Bind the published port to 127.0.0.1 when suitable |
| Another VM needs access | Use an intentional private/public network path, not accidental 0.0.0.0 exposure |
| Service should have no external connectivity | Consider an internal: true network |
In Compose, ports creates a mapping between a port on the Docker host and a port in the container.
For example:
services: proxy: image: nginx:alpine ports: - "80:80" - "443:443"
This makes the proxy reachable through host ports 80 and 443.
A backend application on the same Compose network does not need that mapping merely so the proxy can reach it:
services: proxy: image: nginx:alpine ports: - "80:80" - "443:443" networks: - frontend app: image: example/app:1.0 networks: - frontend - backend
The proxy can address the application by its service name on the shared frontend network.
This is the cleaner production pattern: publish the edge, not every service behind the edge.
expose is not the same as publishing a port
Compose also supports expose, but it is commonly misunderstood.
expose declares container ports intended for service-to-service access. It does not publish those ports onto the Docker host.
For example:
services: app: image: example/app:1.0 expose: - "3000"
Other services on an appropriate shared Docker network can use the application's container port without a host-level ports mapping.
Also remember that an image may already declare exposed ports in its Dockerfile. The important security boundary is not whether expose appears in the Compose file; it is whether the service shares a network with another service and whether a port is published to the host.
A useful mental model is:
Container port ├── reachable by permitted containers on shared Docker network └── reachable from host/outside only when intentionally published or otherwise routed
Do not add ports merely because you want one container to talk to another.
Published ports need an explicit bind decision
A Compose port mapping without a host IP normally binds on all host interfaces.
For example:
ports: - "8080:8080"
can make port 8080 reachable through the VM's network interfaces, subject to the host's surrounding network controls.
Docker's current documentation warns that publishing a port without a host IP binds to 0.0.0.0 by default. On a public VM, that can expose the service beyond what the operator intended.
If a service only needs to be reached from the VM itself, bind it to loopback:
ports: - "127.0.0.1:8080:8080"
That pattern is useful when:
- a host-level reverse proxy connects to a container;
- an administrative interface should only be accessible through SSH tunneling;
- a local monitoring agent needs the service;
- a host process needs a container endpoint but external clients do not.
The decision is not “published or unpublished” only. It is also which host address owns the published port.
Docker port publishing and host firewalls must be designed together
A common production mistake is assuming that a host firewall rule automatically behaves the same for normal host services and Docker-published ports.
On Linux, Docker creates firewall rules for bridge networks, port publishing, NAT, and traffic isolation. Docker's documentation specifically warns that published container traffic can interact with host firewall tooling such as UFW in ways operators do not expect.
A Docker-published port should be treated as an explicit network exposure, not as a harmless Compose setting.
Before publishing a port on a production VM, verify all three layers:
- Compose: which host IP and port are mapped?
- Docker: which bridge/network and firewall rules implement the mapping?
- Infrastructure: which public or private traffic can reach the VM interface?
At Raff, we prefer a narrow exposure model for single-host Compose stacks: the reverse proxy owns public 80/443, while databases, caches, workers, and internal APIs stay private unless there is a specific operational reason to expose them.
That separation keeps application architecture readable and reduces the number of host ports an operator must defend.
For the host layer, continue with Docker Host Security Checklist.
Separate frontend and backend networks when the trust boundary matters
The default Compose network is often sufficient for small applications, but it allows every service attached to that network to reach the others at the network layer.
When the stack has clearly different trust zones, define explicit networks.
A common model is:
Internet ↓ proxy ↓ frontend network app ↓ backend network postgres / redis
In Compose:
services: proxy: image: nginx:alpine ports: - "80:80" - "443:443" networks: - frontend app: image: example/app:1.0 networks: - frontend - backend db: image: postgres:17 networks: - backend networks: frontend: {} backend: {}
Now the proxy and database do not share a Docker network. The application becomes the intended path between them.
This is not a replacement for application authentication, database credentials, TLS where required, or host security. It is network-level reduction of unnecessary reachability.
Use segmentation when it expresses a real boundary. Do not create six networks for six services merely to make the Compose file look sophisticated.
internal: true creates a stronger isolated network boundary
Compose networks support internal: true for networks that should be externally isolated.
For example:
networks: backend: internal: true
This is useful for service groups that should communicate with one another but do not need ordinary external network access through that network.
Typical candidates include:
- database-only networks;
- internal processing pipelines;
- isolated test components;
- services that should only receive traffic through another container.
Use it carefully. An application that needs to call external APIs, download updates, reach managed databases, or contact SaaS services still needs an appropriate path for that traffic.
Network isolation should reflect actual dependency needs, not a blanket assumption that every backend can be cut off from external destinations.
Docker Compose DNS should use service names, not fixed IPs
Within a Compose project, Docker's internal DNS lets services resolve one another by service name.
That gives you a stable application contract:
app → db:5432 app → cache:6379 proxy → app:3000
instead of:
app → 172.x.x.x
The second model is fragile because container addresses can change during recreation.
This matters especially during deployments. A service replacement can join the network under a new IP address while keeping the same service name. Existing long-lived connections may break and need to reconnect, but new DNS lookups resolve the current endpoint.
Applications should therefore:
- use service names for Compose-local dependencies;
- tolerate dropped connections during dependency recreation;
- reconnect using DNS rather than cached container IP assumptions;
- avoid embedding Docker subnet addresses in application configuration.
This is one reason a production Compose stack should be designed around names and roles rather than container identities.
Network aliases are useful, but ownership should stay clear
Compose can assign additional aliases to services on a network.
Aliases are useful when:
- an application expects a legacy hostname;
- a migration needs a temporary compatibility name;
- multiple environments need a consistent dependency hostname;
- an internal service needs a domain-like name separate from its Compose service key.
But aliases can also make troubleshooting harder if several services claim ambiguous names.
Prefer one obvious canonical service name and add aliases only for a defined compatibility or routing reason.
The production question is: if an operator sees this hostname in an application error, can they quickly identify which service owns it?
If not, the naming model is too clever.
Custom DNS settings solve external resolution problems, not Compose discovery
Compose supports service-level DNS settings such as dns, dns_search, and dns_opt. It also supports extra_hosts for adding hostname-to-address mappings inside the container.
These controls are useful when a container must resolve infrastructure outside the normal Compose service-discovery model—for example:
- a corporate DNS resolver;
- a private internal domain;
- a fixed legacy system;
- an environment-specific host entry;
- a host service addressed through a deliberate host gateway mapping.
Do not replace normal Compose service discovery with manually maintained extra_hosts entries.
If app needs to reach the Compose service db, use db. A hard-coded host mapping turns a dynamic container environment back into manual static networking.
Use custom DNS controls for genuine external or infrastructure-specific resolution requirements.
External networks can connect separate Compose projects
Sometimes two Compose projects on the same Docker host need to communicate without being merged into one large Compose file.
Compose can attach services to an existing external Docker network.
This can be useful for:
- one shared reverse proxy serving multiple stacks;
- a shared observability agent;
- controlled communication between separately deployed applications;
- migration from one project layout to another.
The trade-off is coupling.
Once multiple projects share a network, changes to that network can affect multiple deployments. Naming collisions, unexpected reachability, and ownership questions become more important.
Use an external network when there is a clear shared boundary, not as a shortcut to make every container on the host reachable from every other container.
A practical rule is:
| Scenario | Recommendation |
|---|---|
| One application, several services | Project-local network |
| Proxy shared by multiple stacks | Dedicated external proxy network |
| Database belongs to one app | Keep on app-specific backend network |
| Monitoring needs access across stacks | Shared network only if required and controlled |
| Unrelated applications | Keep networks separate |
network_mode: host removes much of the normal Compose network boundary
Compose supports host networking, where a container uses the host's network stack directly.
That is a specialized option, not a faster default for production web applications.
With host networking:
- normal port mapping is not used;
- the container shares the host network namespace;
- Compose service-name DNS behavior changes;
- the container has broader visibility into the host's networking environment.
Use host networking only when the workload genuinely needs host-level network access—for example, certain monitoring, networking, or low-level system tools.
For ordinary web applications, APIs, databases, workers, and reverse proxies, user-defined bridge networks provide a clearer security and operations model.
Private services should remain private even when debugging is inconvenient
Operators sometimes publish internal ports “temporarily” because it makes debugging easy. Those temporary mappings have a habit of becoming permanent infrastructure.
Prefer controlled debugging paths:
docker compose execinto an appropriate service;- SSH access to the VM;
- loopback-only published ports;
- a temporary, reviewed mapping removed after the incident;
- application-level admin endpoints behind authentication and deliberate routing.
Do not leave PostgreSQL, Redis, internal dashboards, metrics endpoints, or management APIs listening publicly simply because an operator once needed remote access.
The production network should describe the application's intended steady state, not its easiest debugging state.
Networking failures should be debugged from the dependency path inward
When two Compose services cannot communicate, debug the intended path rather than immediately adding ports.
Check in this order:
- Are both services running?
- Do they share the intended Compose network?
- Does the client use the service name rather than a stale IP?
- Is the destination process listening on the expected container port?
- Does the application bind to an address reachable inside the container rather than localhost only?
- Are custom DNS settings or
extra_hostsoverriding expected resolution? - Is a network marked
internalwhen external access is required? - For host/external traffic, is the host IP and port mapping intentional?
docker network inspect and docker compose port are useful inspection tools, but the goal is to understand the model rather than memorize commands.
A common diagnosis is simply this: the services already share a Docker network, so publishing another host port does not solve the actual problem.
Production Compose networking baseline
Use this table as the networking review for a single-host production stack:
| Area | Production baseline |
|---|---|
| Public edge | Only the intended entry service publishes internet-facing ports |
| Reverse proxy | Usually owns 80/443 |
| App/API | Private behind proxy unless direct exposure is required |
| Database | No public host port by default |
| Cache/queue | No public host port by default |
| Workers | No inbound host port unless explicitly required |
| Service discovery | Use Compose service names |
| Container IPs | Never treated as stable configuration |
| Host-only access | Bind to 127.0.0.1 where appropriate |
| Segmentation | Separate frontend/backend networks when it expresses a real trust boundary |
| External networks | Shared only between projects that intentionally need communication |
| DNS overrides | Used for external/infrastructure needs, not normal Compose discovery |
| Host networking | Avoid unless the workload genuinely needs host network access |
| Firewall review | Verify Docker port-publishing behavior together with host/infrastructure rules |
This baseline is intentionally boring. Boring networks are easier to secure, explain, and recover.
Raff VM networking should preserve a narrow public surface
For a production Compose application on a Raff VM, start by defining what the internet actually needs to reach.
For most web stacks, that is the reverse proxy or web entry point on ports 80 and 443. Internal services remain reachable through Docker networking, while broader VM-level access is controlled separately.
At Raff, we treat the VM network boundary and the Compose network boundary as two different controls:
- the VM boundary decides which traffic can reach the host;
- the Compose boundary decides which containers can reach each other and which container ports are mapped onto that host.
Keeping those layers separate makes changes easier to review. Adding a new internal service should not automatically create a new public host port.
Use the live Raff VM page when selecting the VM that will host the stack.