How to Isolate Services with Docker Networks and Internal-Only Containers

Keep databases, caches, and admin services off the public internet by splitting your stack into purpose-built Docker networks and only publishing ports that truly need outside access.

DockerNetworkingExposure reduction
What you learn

How Docker bridge networks work, when to use internal: true, how service-to-service access behaves, and how to verify that only intended services are reachable.

Best for

Small VPS stacks, Docker Compose projects, and self-hosters who want cleaner service boundaries without adding Kubernetes or a service mesh.

Risk to watch

Many containers are exposed by accident because they inherited a copied Compose example with ports: lines that were never really needed.

Before you begin

  • A server or local machine with Docker Engine and Docker Compose.
  • A Compose stack with at least one public-facing service and one internal dependency, such as an app plus Postgres or Redis.
  • Shell access and permission to restart the stack.
  • A recent backup if you are changing a production deployment.

Docker networking is one of those topics that sounds advanced but solves very practical problems. Most apps need to talk to each other, but not every app needs to be reachable from the internet or even from your host machine. A database should usually be reachable by the application container, not by random scanners probing your VPS.

Expected outcome: By the end, your reverse proxy or app frontend stays reachable where intended, while internal services like databases and caches only speak on private Docker networks.

Step 1: Understand what network isolation actually protects

By default, containers on the same user-defined bridge network can reach each other by service name. That is useful, but it also means you should be deliberate about which services share a network. Isolation helps in a few ways:

  • It reduces accidental port exposure on the host.
  • It keeps internal traffic off the public path.
  • It makes your Compose file easier to reason about.
  • It limits blast radius when one service is misconfigured.

Network isolation is not a complete security system by itself. It will not fix weak passwords, a vulnerable image, or a compromised container. But it is an excellent baseline habit, especially for beginner-friendly Compose stacks.

Warning: If a service has a published port like 0.0.0.0:5432:5432, Docker network isolation does not hide it from the host network. Published ports override the privacy you may think the network gives you.

Step 2: Inspect what is currently exposed

Start by listing running containers and published ports:

docker compose ps

docker ps --format 'table {{.Names}}\t{{.Ports}}'

You are looking for services that should probably stay private, such as:

  • Postgres on port 5432
  • MySQL or MariaDB on port 3306
  • Redis on port 6379
  • Admin dashboards that should only be reachable behind a reverse proxy or VPN

Inspect existing networks too:

docker network ls

docker network inspect myapp_default

In many Compose setups, every service lands on the same default network. That works, but it is often broader than necessary.

Step 3: Create separate public and private networks

A common pattern is:

  • frontend for the reverse proxy and the app that must receive traffic
  • backend for app-to-database or app-to-cache traffic only

In Compose, define networks explicitly:

networks:
  frontend:
    driver: bridge
  backend:
    driver: bridge
    internal: true

The internal: true setting tells Docker that containers on that network should not have external connectivity through that network. It is a strong hint that the network is for internal traffic only. Use it for databases, workers, caches, and other private services when it fits your app behavior.

If a service needs to download updates, talk to an external API, or send email, it may still need access through another network or a different design. That is why understanding traffic needs matters before you copy an example blindly.

Step 4: Update your Compose stack to use the networks intentionally

Here is a simple example with Nginx, an app, and Postgres:

services:
  proxy:
    image: nginx:alpine
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx.conf:/etc/nginx/nginx.conf:ro
    depends_on:
      - app
    networks:
      - frontend

  app:
    image: ghcr.io/example/myapp:1.0.0
    restart: unless-stopped
    env_file:
      - .env
    depends_on:
      - db
    networks:
      - frontend
      - backend

  db:
    image: postgres:16
    restart: unless-stopped
    environment:
      POSTGRES_DB: ${POSTGRES_DB}
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD_FILE: /run/secrets/db_password
    volumes:
      - db_data:/var/lib/postgresql/data
      - ./secrets/db_password.txt:/run/secrets/db_password:ro
    networks:
      - backend

volumes:
  db_data:

networks:
  frontend:
    driver: bridge
  backend:
    driver: bridge
    internal: true

Important details:

  • Only proxy publishes host ports.
  • app can talk to both the proxy and the database because it joins both networks.
  • db does not publish any host port and only joins the internal backend network.

If you do not need a reverse proxy in the same Compose file, you can still keep a single app on frontend and a database on backend. The point is to model real traffic paths instead of putting every service everywhere.

Step 5: Apply the changes and verify the result

Render the final Compose config first:

docker compose config

Then recreate the stack:

docker compose up -d

Check that only intended ports are published:

docker ps --format 'table {{.Names}}\t{{.Ports}}'

Test network reachability from inside containers:

docker compose exec app getent hosts db

docker compose exec app sh -lc 'nc -zv db 5432'

docker compose exec proxy sh -lc 'getent hosts db || true'

In the example above, the app should resolve and reach db, but the proxy should not if it is not attached to the backend network.

You can also test from the host:

ss -tulpn | grep -E ':5432|:6379|:3306'

Expected outcome: Internal services are still reachable to the containers that need them, but they no longer show up as host-published ports unless you explicitly kept them that way.

Practical patterns to copy

  • Put reverse proxies on a public-facing network and application dependencies on an internal network.
  • Keep admin or metrics tools private unless you really need public access.
  • Publish ports on 127.0.0.1 instead of all interfaces when only the local host should reach them, for example 127.0.0.1:3000:3000.
  • Use service names like db or redis inside Compose instead of host IP addresses.

Rollback and recovery notes

If the app stops talking to a dependency after the change, roll back by restoring the previous Compose file and recreating the stack:

cp compose.yml.bak compose.yml

docker compose up -d

Common rollback reasons include:

  • A service was removed from a network it still needed.
  • An app expected a published localhost port instead of Docker service discovery.
  • A healthcheck or startup dependency assumed an older network layout.

If this is production, keep the old Compose file in version control or as a dated backup before editing. Network changes are usually safe, but they can still break application connectivity if you guess wrong about traffic needs.

Troubleshooting common Docker network problems

The app cannot resolve the database hostname.
Make sure both services share at least one common user-defined network and that you are using the Compose service name, not a container name from an older setup.

The database still appears open on the server.
Look for leftover ports: entries, including in override files such as compose.override.yml.

The container lost outbound internet access.
That can happen if the only attached network is marked internal: true. Attach the service to another network if it legitimately needs external access.

My reverse proxy cannot reach the app anymore.
Confirm the proxy and app still share a network, and verify the proxy upstream hostname matches the Compose service name.

Healthchecks started failing after the network split.
Some images use localhost checks, others call service names. Re-check healthcheck commands after changing network topology.

Practical rule: Start with the smallest exposure you can justify, then add access deliberately. It is usually easier to open one needed path than to audit ten unnecessary ones later.

What to do next

Once your network boundaries are cleaner, the next hardening step is getting secrets out of plain Git history. Continue with How to Manage Secrets in Git with SOPS and age.