Welcome back! In our previous lesson, we established how Docker Compose allows us to define a multi-service application in a single docker-compose.yml file. We've moved from running tedious manual docker run commands to a clean, declarative approach.
Now that we can define our services, it's time to make them work together robustly, as they would in a production environment. This lesson tackles two fundamental challenges:
- Data Persistence: How do we ensure that data in a stateful service, like a database, isn't lost every time we restart our application?
- Secure Communication: How do services talk to each other, and how can we structure their communication to be more organized and secure?
By the end of this lesson, you'll be able to configure persistent data storage using Docker volumes and set up secure inter-service communication using custom Docker networks.
1. Persisting Data with Volumes
A core principle of containers is that they are ephemeral, or short-lived. When you run docker compose down, the containers are not just stopped; they are removed. If you then run docker compose up, new containers are created from the base images.
For a stateless service like an API, this is fine. But for a stateful service like a PostgreSQL database, this is a critical problem. Any data written to the database—new users, orders, etc.—would be completely wiped out.
The solution is to store the data outside the container's filesystem in a Docker volume. Think of a volume as a dedicated hard drive for your container, managed by Docker, which persists even when the container is removed.
There are two main ways to mount data into a container:
- Bind Mounts: This maps a directory from your host machine directly into the container (e.g.,
- ./my-app:/app). This is great for development, like mounting your source code for live reloading, but it's not ideal for database data as it ties your application to a specific host file structure. - Named Volumes: This is the preferred method for persisting application data. You give a volume a name (e.g.,
postgres-data), and Docker takes care of managing the actual storage location on the host machine. This makes your setup more portable and decouples it from the host's filesystem.
The following video provides an excellent demonstration of this concept. It first shows the problem of data loss, then illustrates how both bind mounts and named volumes solve it, highlighting why named volumes are superior for database persistence.
Docker Compose Tutorial for Beginners (Networks - Volumes - Secrets - Postgres - Letsencrypt)
This video demonstrates the problem of data loss in ephemeral containers and shows how to solve it using both bind mounts and named volumes in Docker Compose.
First, watch the section explaining the data persistence problem to solidify your understanding of why volumes are necessary. Next, see the demonstration of using bind mounts. Finally, focus on the section explaining and demonstrating the preferred named volumes approach.
Configuring Volumes in docker-compose.yml
To use a named volume, you need to do two things in your docker-compose.yml:
- Declare a top-level
volumeskey to define the named volume. - In your service definition, mount this named volume to the correct path inside the container.
Here's a typical example for a PostgreSQL database:
version: '3.8'
services:
db:
image: postgres:15-alpine
environment:
- POSTGRES_PASSWORD=supersecret
volumes:
# Mounts the named volume 'db-data' to the directory where Postgres stores its data
- db-data:/var/lib/postgresql/data
# Top-level declaration of the named volume
volumes:
db-data:
Now, when you run docker compose down -v (the -v flag is needed to explicitly remove volumes), the db-data volume and its contents will be preserved. The next time you run docker compose up, a new db container will be created and will attach to the existing db-data volume, restoring all your data.
2. Service-to-Service Communication with Networks
As we touched on in the last lesson, when you run docker compose up, Docker Compose automatically creates a default bridge network and connects all defined services to it. This is why services can seamlessly communicate with each other using their service names as hostnames (e.g., your API can connect to postgres://db:5432).
While the default network is convenient, for more complex or production-like setups, using custom networks provides better organization and security.
The most important reason to use custom networks is isolation. Consider a typical web application architecture:

In this model, you can create two networks: frontend and backend.
- The
nginxreverse proxy is on thefrontendnetwork to accept public traffic. - The
databaseis only on thebackendnetwork. - Your
apiservice acts as the bridge, connecting to both thefrontendnetwork (to receive requests from Nginx) and thebackendnetwork (to query the database).
This setup ensures that even if your Nginx proxy were compromised, the attacker would have no direct network path to your database, significantly improving your security posture.
The following video segments demonstrate how to create and use custom networks to achieve this secure architecture.
Docker Compose Tutorial for Beginners (Networks - Volumes - Secrets - Postgres - Letsencrypt)
This video first shows how to create a custom network to connect a Flask app to a PostgreSQL database. It then enhances the security by adding an Nginx proxy and creating a two-network (public/private) architecture.
First, watch how a service and its database are connected via a custom private network. Notice how the service name (postgres) is used as the hostname. Then, see how a second network is introduced to create a more secure public and private network setup with Nginx.
Configuring Networks in docker-compose.yml
Similar to volumes, configuring custom networks involves a top-level networks key and a networks key within each service.

Here is the YAML structure for the public/private network architecture described above:
version: '3.8'
services:
nginx:
image: nginx:alpine
ports:
- "80:80"
networks:
- frontend
api:
build: ./api
networks:
- frontend
- backend
db:
image: postgres:15-alpine
volumes:
- db-data:/var/lib/postgresql/data
networks:
- backend
networks:
frontend:
backend:
volumes:
db-data:
3. Hands-on Exercise
Let's put this all together. Below is a docker-compose.yml for a simple e-commerce system with an API gateway, a user service, and a database. Analyze the file and answer the questions that follow.
version: '3.8'
services:
api-gateway:
build: ./api_gateway
ports:
- "8000:8000"
environment:
- USER_SERVICE_HOST=user-service
networks:
- public-net
user-service:
build: ./user_service
environment:
- DATABASE_HOST=user-db
- DATABASE_PASSWORD=supersecret
networks:
- public-net
- private-net
user-db:
image: postgres:15-alpine
environment:
- POSTGRES_PASSWORD=supersecret
volumes:
- user-data:/var/lib/postgresql/data
networks:
- private-net
networks:
public-net:
private-net:
volumes:
user-data:
Questions:
- How is the data for the
user-dbservice being persisted? What would happen to the data if you randocker compose downfollowed bydocker compose up? - Explain the networking setup. Why is
user-serviceon two networks whileapi-gatewayanduser-dbare each on only one? - What is a potential security vulnerability in this configuration? How would you change the
networksconfiguration to fix it?
Click for solution
-
Data Persistence: The data is persisted using a named volume called
user-data. This volume is mounted to/var/lib/postgresql/datainside theuser-dbcontainer. If you rundocker compose downand thendocker compose up, the data will be preserved because the named volume exists independently of the container's lifecycle. -
Networking Setup: The
user-serviceis on bothpublic-netandprivate-netso it can receive requests from theapi-gatewayand also connect to theuser-db. Theapi-gatewayis only onpublic-netto expose its endpoint. Theuser-dbis only onprivate-netto store data. -
Security Vulnerability: The
api-gatewayanduser-serviceare both on the samepublic-net. This means if the gateway were compromised, the attacker would have direct network access to theuser-service. A better design would be to have theapi-gatewayon apublic-net, theuser-dbon aprivate-net, and theuser-serviceon both, acting as a bridge. The current setup puts both the gateway and the service in the same security zone. The fix would be to ensureapi-gatewayanduser-serviceonly communicate across a shared network, but theuser-serviceshould be the only service (besides other backend services) that can also access theprivate-netwhere the database resides. The provided configuration is almost correct but places theuser-serviceon thepublic-net, which is not ideal. A better setup would be:api-gateway:networks: [public-net]user-service:networks: [public-net, private-net]-> This is the mistake in the initial prompt! Theuser-serviceshould not be on the public network if theapi-gatewayis handling public traffic. A better setup is:api-gateway:networks: [frontend-net]user-service:networks: [frontend-net, backend-net]user-db:networks: [backend-net]
Let's correct the example's intended architecture: The
api-gatewayshould be the only service on the public network. It then communicates with theuser-serviceover a second, internal network.Corrected Secure Configuration:
services: api-gateway: # ... networks: - public-net # Exposed to the outside world - internal-net # Talks to backend services user-service: # ... networks: - internal-net # Only accessible from gateway - private-net # Talks to database user-db: # ... networks: - private-net # Only accessible from user-service networks: public-net: internal-net: private-net:
Conclusion
You have now mastered two of the most critical aspects of using Docker Compose for microservices: ensuring data persistence with named volumes and structuring communication securely with custom networks. These are not just configuration details; they are fundamental architectural decisions that impact the resilience and security of your entire system.
Key Takeaways:
- Use named volumes to persist data for stateful services like databases.
- Use custom networks to isolate services and improve security. A multi-tier network design (e.g., public, internal, private) is a standard pattern.
- Services on the same Docker network can resolve each other using their service names as hostnames.
Preview of the Next Lesson
We've connected our services and made their data persistent. However, there's still a race condition lurking. What happens if our api container starts faster than our db container and tries to connect before the database is ready? The API will crash. In the next lesson, we will solve this by learning how to "Manage service startup order and dependencies using depends_on in Docker Compose."