Docker basics · Lesson 6 of 8 · about 30 minutes

Networking and Compose

Let containers find each other by name, then run an app and its database together from one file with Docker Compose.

By the end you'll know

  • How containers talk to each other, and why localhost doesn't work for that
  • How to let containers find each other by name
  • What Docker Compose is, and how to run a whole app from one file
  • Why you shouldn't publish your database's port

The problem: apps come in pieces

Real apps are rarely one container. A web app usually has a database, often a cache, sometimes more. Each runs in its own container, and they need to talk to each other.

You might think the app could reach its database at localhost. Lesson 4 showed why not: inside a container, localhost means that container itself. The database is in a different container, so the app knocking on its own door finds nobody home.

What the app needs is a way to say "the database", by name, and have Docker work out where that is.

Networks: containers that know each other's names

A Docker network is a private network that containers can join. On a network you create, Docker runs a small built-in DNS (the system that turns names into addresses, also covered in the Kubernetes Services lesson): every container can reach every other by its container name.

Think of an office's internal phone directory. Everyone on the same floor can call each other by name: "put me through to Cache". Nobody outside the building can dial those extensions. To let an outside caller in, you set up a line from the main number, which is what -p does. Most offices only need one outside line, at reception, and that's true of most apps too.

Try it: two containers on one network

Create a network, and start Redis on it. Redis is a fast in-memory database, often used as a cache:

BASH
docker network create appnet
docker run -d --name cache --network appnet redis:7.2.7-alpine

Now start a second, throwaway container on the same network, and ask it to reach Redis by name. redis-cli is Redis's own command-line tool, and ping asks Redis to answer:

BASH
docker run --rm --network appnet redis:7.2.7-alpine redis-cli -h cache ping

PONG. The second container found the first by its name, cache. Notice there's no -p anywhere: containers on the same network can reach each other's ports directly. -p is only for traffic from your laptop.

Now try the same thing without joining the network:

BASH
docker run --rm redis:7.2.7-alpine redis-cli -h cache ping

It fails: the name cache can't be found, because this container isn't on appnet. Only containers on the same network share the directory.

Clean up before the next part:

BASH
docker rm -f cache
docker network rm appnet

Docker Compose: the whole app in one file

That worked, but imagine typing all of it every time: a network, a database with a volume (Lesson 5), an app with its settings, ports and the right network. Miss one flag and something doesn't connect.

Docker Compose puts all of that in one file, compose.yaml, and starts everything with one command. It's included with Docker Desktop. The file lists services: each one is a container, with everything you'd otherwise type after docker run.

You'll make the app from Lessons 4 and 5 count its visits in Redis.

In your hello-app folder, add one line to requirements.txt, so it reads:

TEXT
flask==3.1.3
redis==8.1.0

Replace app.py with this version. The new parts connect to Redis and add one to a counter on every visit:

PYTHON
import os
import socket

import redis
from flask import Flask

app = Flask(__name__)

GREETING = os.environ.get("GREETING", "Hello")
cache = redis.Redis(host=os.environ.get("REDIS_HOST", "localhost"), port=6379)   # where to find Redis


@app.get("/")
def hello():
    visits = cache.incr("visits")   # add 1 to the counter called "visits", and return the new total
    return f"{GREETING} from {socket.gethostname()}! This page has been visited {visits} times.\n"

Your Dockerfile from Lesson 4 stays exactly as it is. Now save this as compose.yaml in the same folder:

YAML
services:
  web:
    build: .                    # build the image from the Dockerfile in this folder
    ports:
      - "8000:8000"             # the one outside line: your laptop's 8000 to the app
    environment:
      GREETING: Hello
      REDIS_HOST: cache         # find Redis by its service name
    depends_on:
      - cache                   # start cache before web

  cache:
    image: redis:7.2.7-alpine   # an existing image, nothing to build
    volumes:
      - cache-data:/data        # Redis saves its data here; keep it in a volume

volumes:
  cache-data:                   # create this named volume

Read it top to bottom and you'll recognize everything: build is docker build, ports is -p, environment is -e, volumes is -v. What you don't see is a network: Compose creates one for every app automatically, and each service is reachable by its service name. That's why REDIS_HOST is just cache.

What docker compose up createsYour browser reaches port 8000 on your laptop, which is published to the web container. Compose puts web and cache on their own network, where web reaches Redis by the name cache on port 6379. Redis keeps its data in the named volume cache-data. Redis's port is not published, so nothing outside the network can reach it.Browserlocalhost:8000-p 8000Network created by ComposeService: webyour Flask appREDIS_HOST=cachecache:6379Service: cacheRedis, port not publishedreachable only insidevolume: cache-dataServices find each other by name. Only the web app gets an outside line.
Compose creates a private network for the app. The web service reaches Redis by its service name; only the web service's port is published to your laptop.

Try it: run the whole app

From your hello-app folder:

BASH
docker compose up -d --build

up creates the network, the volume and both containers; -d runs them in the background, as with docker run; and --build builds the web image first. Check on them, and visit the app a few times:

BASH
docker compose ps
curl http://localhost:8000
curl http://localhost:8000
curl http://localhost:8000

visited 1 times, 2 times, 3 times: the app is talking to Redis by name. Logs work like docker logs, by service name:

BASH
docker compose logs web

Now stop and remove everything, then start it again:

BASH
docker compose down
docker compose up -d
curl http://localhost:8000

visited 4 times. down removed both containers and the network, but not the volume, so Redis's saved count was waiting when it came back. That's Lesson 5's volume, now set up by one line in a file.

To remove the volume too, and start from zero:

BASH
docker compose down -v

Don't publish your database's port

Notice that cache has no ports:. The web app reaches it over Compose's network, so there's no reason for anything else to. Publishing a database's port makes it reachable from outside, and databases left open on the internet are one of the most common ways data leaks. Give the outside world a line to the app, and only the app.

In my real setup

My production setup doesn't use Compose. It runs on Kubernetes, which the last lesson of this track maps Compose onto. But the idea is identical. My API reaches its Redis cache by name, civicgrid-redis, never by an IP address. Redis has no outside line at all, and a network rule goes further: only the API's pods are allowed to connect to it, so not even other apps in the same cluster can.

Redis is also the star of a story in Lesson 3: when my API had no Redis address to use, it didn't crash, it quietly counted on its own. That's why the break-it below matters. Make sure you can tell whether your app actually reached its database.

Break it on purpose

Point the app at localhost. In compose.yaml, change REDIS_HOST: cache to REDIS_HOST: localhost. Apply the change, and try the page:

BASH
docker compose up -d
curl http://localhost:8000
docker compose logs web

up -d notices the changed setting and recreates web. The page returns Internal Server Error, and near the bottom of the logs:

redis.exceptions.ConnectionError: Error 111 connecting to localhost:6379. Connection refused.

"Connection refused" means the address exists but nothing is listening there. That's the web container's own localhost, where there's no Redis. Inside a container, localhost is always the container itself.

Misspell the service name. Change it to REDIS_HOST: cahce, and do the same again. This time:

redis.exceptions.ConnectionError: Error -2 connecting to cahce:6379. Name or service not known.

"Name or service not known" means the name itself couldn't be found on the network. Those two messages are worth remembering, because they point to different fixes: refused means "right place, nothing listening", usually the wrong host or port; not known means "no such name", usually a typo or a container on a different network.

Set it back to cache and run docker compose up -d once more.

Clean up

BASH
docker compose down -v

That removes the containers, the network and the volume. Keep your hello-app folder for Lesson 7.

Check yourself

  1. Your app in one container tries to reach its database at localhost:5432, and the database runs in another container. What happens?

  2. How does a container find another container on a network you created?

  3. In compose.yaml, the app's setting is REDIS_HOST: cache. Where does the name cache come from?

  4. You run docker compose down, then docker compose up -d. The visit counter continues from where it was. Why?

  5. The logs say Connection refused when your app connects to its database. What does that usually mean?

  6. Why does the cache service in compose.yaml have no ports section?

Key terms

  • Network: a private network that containers can join. On networks you create, containers reach each other by name.
  • DNS: the system that turns names into addresses. Docker runs a small one for each network.
  • Docker Compose: a tool that runs a multi-container app from one file.
  • compose.yaml: the file that describes the app's services, volumes and settings.
  • Service: one container in a Compose app, reachable by its service name.
  • docker compose up / down: start everything / stop and remove containers and network. down -v also removes volumes.
  • depends_on: start one service before another. It doesn't wait for it to be ready, only started.

Go deeper