Skip to main content

Command Palette

Search for a command to run...

Docker Compose in Action: Multi-Container Apps, Nginx Load Balancing & Docker Hub

We built four containerized microservices for an bank application in the last post. Internet banking, mobile banking, insurance, and loans, each running in its own container, each exposed on a separate port. The setup worked. But the process of building and running each container individually by hand was repetitive, error-prone, and simply not practical at scale. This post introduces Docker Compose, and by the end, you will understand not just how to use it, but why it exists, what its real limitations are, how to combine it with Nginx to build a working high availability architecture, and how to push your images to Docker Hub so they are available beyond your local machine. There is also a hands-on project included here that builds a Flask-based Python application behind an Nginx load balancer, which you are expected to complete as a practical exercise.

Updated
17 min read
Docker Compose in Action: Multi-Container Apps, Nginx Load Balancing & Docker Hub
S
Passionate about Cloud and DevOps engineering. I write structured technical notes and beginner-friendly articles on AWS, Linux, CI/CD, networking, system architecture and modern software delivery workflows.

Why Docker Compose

Before running any commands, it helps to understand the problem Docker Compose is solving.

Right now, to create four containers for our services, you would need to run four separate docker run commands. Each command has its own flags, its own port mapping, its own container name, and its own image reference. If you make one typo, one container gets misconfigured. If someone else on your team needs to set up the same environment, they have to figure out which commands to run in which order.

Docker Compose replaces all of that with a single file. You describe every container, every port, every configuration in one YAML file, and then you run one command. That is it.

The official definition: Docker Compose is a tool that allows you to define and run multi-container Docker applications using a single YAML configuration file.


Step 1: Build the Four Images First

Docker Compose creates containers from images. So before writing the Compose file, the images need to exist locally.

Install Git and clone the project repository:

https://github.com/Bhattu-Sai-Praneeth/SHELL-SCRIPTS-REF/tree/main/SAMPLE-WSOnce cloned, you will see a directory structure like this:

Build the image for each service. The Dockerfile is the same structure for all four services since they all run a static HTML application on Apache.

Internet Banking:

cd internet-banking
docker build -t internet-banking:v1 .
cd ..

Mobile Banking:

cd mobile-banking
docker build -t mobile-banking:v1 .
cd ..

Insurance:

cd insurance
docker build -t insurance:v1 .
cd ..

Loans:

cd loans
docker build -t loans:v1 .
cd ..

Verify all four images are present:

docker images

You should see internet-banking:v1, mobile-banking:v1, insurance:v1, and loans:v1 in the list. Now we are ready to write the Compose file.


Installing Docker Compose

Docker Compose does not come bundled with Docker by default and needs to be installed separately. On a fresh EC2 instance, you can install it using the official installation script or a package manager. Once installed, confirm the installation:

docker-compose --version

Writing the docker-compose.yaml File

The Compose file must always be named docker-compose.yaml or docker-compose.yml. Just like Dockerfile cannot be renamed arbitrarily, the Compose file also has a fixed name. Both .yaml and .yml extensions are accepted, but docker-compose.yaml is the convention to stick with.

Create the file at the root of your project:

vi docker-compose.yaml

Here is the complete Compose file for all four services:

version: '3.8'
services:
  internet-banking:
    image: internet-banking:v1
    ports:
      - "81:80"

  mobile-banking:
    image: mobile-banking:v1
    ports:
      - "82:80"

  insurance:
    image: insurance:v1
    ports:
      - "83:80"

  loans:
    image: loans:v1
    ports:
      - "84:80"

That is the entire file. Let us walk through each part.

version: '3.8' specifies which version of the Compose file format you are using. You can use 3.7 or 3.8 or others. This is not strictly mandatory in newer versions of Docker Compose, but it is good practice to include it.

services: is the top-level key that contains all your container definitions. Every container you want to run is listed as a named entry under services.

Each service entry, such as internet-banking, corresponds to one container. It takes two pieces of information here: which image to use and which ports to expose.

image: internet-banking:v1 tells Docker Compose to create a container using that specific image. This must match exactly what you named your image during docker build.

ports: takes a list of port mappings in "host-port:container-port" format. Internet banking maps host port 81 to container port 80 because Apache inside the container always listens on port 80. The host port is what you use in the browser URL.

Notice that what previously required four separate docker run commands with multiple flags is now described in fewer than twenty lines of YAML. And critically, this file is readable, shareable, and version-controlled.


Running Everything with One Command

To start all four containers in detached mode:

docker-compose up -d

Watch what happens. All four containers come up almost instantly. No manual steps. No individual commands. Just one instruction and your entire application stack is running.

Verify:

docker-compose ps

This shows every service defined in your Compose file along with its current state, the command running inside it, and the port bindings.

You can also run the standard Docker command:

docker ps

All four containers will be listed as running.

Open a browser and navigate to your EC2 instance's public IP:

  • Port 81: Internet banking is live

  • Port 82: Mobile banking is live

  • Port 83: Insurance is live

  • Port 84: Loans is live

The same result you spent time building manually before is now up in under a second. That is the entire point of Docker Compose.


All Docker Compose Commands

Here is the complete set of Compose commands you need to know.

Start all containers:

docker-compose up -d

The -d flag runs everything in detached mode (background). Without it, the logs stream to your terminal and pressing Ctrl+C stops everything.

Stop all containers (without removing them):

docker-compose stop

The containers are stopped but not deleted. You can restart them later.

Start previously stopped containers:

docker-compose start

Restart all containers:

docker-compose restart

Pause all containers:

docker-compose pause

When paused, all processes inside the containers are frozen. Running docker-compose ps will show the state as Paused.

Unpause all containers:

docker-compose unpause

View logs of all containers:

docker-compose logs

This is essential for debugging. If a service is not responding, the first thing you check is its logs.

View running processes inside all containers:

docker-compose top

Kill all containers (forceful stop):

docker-compose kill

Remove stopped containers:

docker-compose rm

Note that rm only works on containers that are already stopped. You need to stop or kill first.

Stop and remove everything in one command:

docker-compose down

This is the most commonly used cleanup command. It stops all running containers and removes them in one shot. The images remain, but the containers are gone. The next time you run docker-compose up, fresh containers are created from the same images.

The difference between stop and down is important: stop only pauses the containers while keeping them intact so you can start them again. down stops and deletes them entirely.


The Scaling Problem

Here is something that comes up quickly in practice. What happens when load increases on one service? Say loans processing suddenly gets a spike in traffic and you want to run ten instances of it instead of one.

Docker Compose has a scale command for exactly this:

docker-compose scale loans=10

When you run this, it tries to create ten containers for the loans service. But it immediately fails with a port conflict error. The error message says something like: port is already allocated.

Here is why. Your Compose file maps host port 84 to loans. When you try to create ten containers for loans, all ten of them attempt to bind to host port 84 simultaneously. But a host port can only be bound to one process at a time. The second container fails, the third fails, and so on.

This is a genuine architectural limitation of Docker Compose. You cannot do automatic scaling with static host port mappings. Each container needs a unique host port, and Compose does not handle that automatically when you scale.

If you need auto-scaling and proper load distribution across multiple container instances, that is a problem that Kubernetes solves. Docker Compose is designed for running multi-container applications, not for orchestrating scaled fleets of services. Keep that distinction clear.


Project 1: High Availability with Nginx as Load Balancer

Since Docker itself does not provide a built-in load balancer, we build one ourselves using Nginx running as a container. This is how high availability works at the container level without Kubernetes.

The Architecture

The setup has four containers in total:

  • One Nginx container acting as the load balancer, exposed to the outside world on port 80

  • Three backend application containers running a Python app, each on port 5000

The user only ever talks to the Nginx container. Nginx distributes incoming requests across the three backend containers. The backend containers are not exposed directly to the outside. If one backend container goes down, Nginx routes traffic to the remaining two. The application stays up.

File 1: nginx.conf

This is the Nginx configuration file that sets up the load balancing behaviour. Create it in your working directory:

vi nginx.conf
upstream backend {
    server backend1:5000;
    server backend2:5000;
    server backend3:5000;
}

server {
    listen 80;

    location / {
        proxy_pass http://backend;
    }
}

The upstream block defines a group of backend servers by container name. When Docker Compose creates the containers, they are reachable by their service names on the internal Docker network. So backend1, backend2, and backend3 resolve to the correct container IP addresses automatically.

The proxy_pass directive in the location block tells Nginx to forward all incoming requests to the upstream group, distributing them in round-robin order by default.

File 2: Dockerfile for Nginx

Create a Dockerfile in the same directory:

vi Dockerfile
FROM nginx:latest
RUN rm /etc/nginx/conf.d/default.conf
COPY nginx.conf /etc/nginx/conf.d/
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

This pulls the official Nginx image, removes the default configuration that comes bundled with it, and replaces it with our custom nginx.conf.

The CMD ["nginx", "-g", "daemon off;"] line needs explanation. By default, Nginx starts as a background daemon process. In a Docker container, if the main process runs in the background, Docker sees no active foreground process and assumes the container has finished. The container stops. To keep the container alive, Nginx must run in the foreground. The daemon off; directive does exactly that. It tells Nginx: do not daemonize, stay in the foreground. The container keeps running as long as Nginx is running.

File 3: docker-compose.yaml

This single file defines all four containers, their relationships, and their port configurations:

version: '3.8'
services:
  nginx:
    build: .
    container_name: nginx-lb
    ports:
      - "80:80"
    depends_on:
      - backend1
      - backend2
      - backend3

  backend1:
    image: indiaalphadev/python-demo-app:latest
    container_name: backend1
    expose:
      - "5000"

  backend2:
    image: indiaalphadev/python-demo-app:latest
    container_name: backend2
    expose:
      - "5000"

  backend3:
    image: indiaalphadev/python-demo-app:latest
    container_name: backend3
    expose:
      - "5000"

Notice the difference between the Nginx service and the backend services regarding ports.

Nginx uses ports: - "80:80" which maps host port 80 to container port 80. This is what makes Nginx reachable from a browser.

The backend services use expose: - "5000" instead of ports. expose makes the port available only within the internal Docker network. Other containers can reach the backends on port 5000, but the outside world cannot. This is intentional. You do not want users bypassing Nginx and hitting backend containers directly.

build: . in the Nginx service tells Docker Compose to build the image from the Dockerfile in the current directory instead of pulling from Docker Hub. This is how you use a locally defined Dockerfile within a Compose setup.

depends_on ensures that Docker Compose starts the three backend containers before it starts Nginx. You do not want Nginx to start before its upstream servers are ready.

Running the Project

Since the Nginx image needs to be built from the local Dockerfile, use the --build flag:

docker-compose up --build -d

The --build flag tells Docker Compose to build any images that have a build directive before starting the containers. Without it, Compose would try to pull the Nginx image by name and fail.

Check running containers:

docker-compose ps

All four containers should show status Up.

Open a browser and navigate to your EC2 instance's public IP on port 80. The Python application loads. Nginx is serving it.

Testing High Availability

Stop one of the backend containers:

docker stop backend3

Now refresh the application in the browser. The app still loads. Nginx detects that backend3 is unavailable and routes requests to backend1 and backend2. The user experiences nothing. The application stays available.

Stop backend2 as well. Still works. Only one backend container is running and Nginx keeps routing to it.

This is high availability at the container level.


Project 2: Build Your Own Flask Application (Practical Assignment)

The previous project used a pre-built Python application pulled from someone else's Docker Hub. For this practical, you build the application yourself from scratch.

Step 1: Create app.py

vi app.py
from flask import Flask
import socket

app = Flask(__name__)

@app.route('/')
def index():
    hostname = socket.gethostname()
    return f'Response from container: {hostname}'

if __name__ == '__main__':
    app.run(host='0.0.0.0', port=5000)

The socket.gethostname() call returns the container's hostname, which is its container ID by default. This lets you see which backend container is serving each request, which is useful for verifying that Nginx is distributing load across all three.

Step 2: Create requirements.txt

vi requirements.txt
flask

Instead of running pip install flask manually inside the Dockerfile, you list all Python dependencies in requirements.txt. The Dockerfile then installs everything from this file in one step. When your application grows and needs more packages, you just add them to this file without changing the Dockerfile.

Step 3: Create the Dockerfile for the Flask App

vi Dockerfile
FROM python:3.9
WORKDIR /app
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY . .
EXPOSE 5000
CMD ["python", "app.py"]

WORKDIR /app sets the working directory inside the container to /app. All subsequent instructions operate relative to this path.

COPY requirements.txt . copies only the requirements file first, before the rest of the application code. Then RUN pip install -r requirements.txt installs all dependencies. This ordering takes advantage of Docker layer caching. If your code changes but your dependencies do not, Docker reuses the cached install layer and skips reinstalling packages on the next build.

COPY . . copies all remaining application files into the container.

Step 4: Create nginx.conf for the Flask Project

Same structure as the first project, pointing to the three Flask backends:

upstream backend {
    server backend1:5000;
    server backend2:5000;
    server backend3:5000;
}

server {
    listen 80;

    location / {
        proxy_pass http://backend;
    }
}

Step 5: Create the Nginx Dockerfile

Same as the first project:

FROM nginx:latest
RUN rm /etc/nginx/conf.d/default.conf
COPY nginx.conf /etc/nginx/conf.d/
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

Step 6: Create docker-compose.yaml

For this project, all backend images are built locally from your own Dockerfile:

version: '3.5'
services:
  nginx:
    build:
      context: .
      dockerfile: Dockerfile
    container_name: nginx-lb
    ports:
      - "80:80"
    depends_on:
      - backend1
      - backend2
      - backend3

  backend1:
    build:
      context: .
    container_name: backend1
    expose:
      - "5000"

  backend2:
    build:
      context: .
    container_name: backend2
    expose:
      - "5000"

  backend3:
    build:
      context: .
    container_name: backend3
    expose:
      - "5000"

Note that both Nginx and the backends use build: . (or build: context: . in expanded form). Docker Compose builds all images locally before creating any containers.

Step 7: Run It

docker-compose up --build -d

Once running, open the application in a browser. Refresh the page multiple times. The hostname in the response changes between backend1, backend2, and backend3. That is Nginx distributing requests in round-robin.

This is the full architecture that real teams use for containerized high availability: an Nginx container at the front, multiple application containers at the back, all wired together through a single Compose file.


Pushing Images to Docker Hub

Right now, all your images are stored locally on your EC2 instance. If you terminate that instance, the images are gone. The solution is to push them to Docker Hub, which is a remote image registry. Images on Docker Hub are accessible from anywhere.

Step 1: Create a Repository on Docker Hub

Go to hub.docker.com and sign up if you have not already. Once logged in, click on Create Repository, give it a name, set visibility to Public, and click Create.

Your repository path on Docker Hub will look like: yourusername/repository-name

Step 2: Tag Your Images

Before pushing, you need to tag each local image with your Docker Hub username and repository path. Docker uses the image tag to know where to push it.

docker tag internet-banking:v1 yourusername/ib-image:v1
docker tag mobile-banking:v1 yourusername/mb-image:v1
docker tag insurance:v1 yourusername/insurance-image:v1
docker tag loans:v1 yourusername/loans-image:v1

The format is: docker tag <local-image>:<tag> <dockerhub-username>/<repository-name>:<tag>

This does not create a copy of the image. It creates an additional name (alias) for the same image that follows Docker Hub's naming convention.

Step 3: Login to Docker Hub

docker login

You will be prompted for your Docker Hub username and password. After successful login, Docker stores the credentials locally and subsequent push commands work without re-authentication.

Step 4: Push the Images

docker push yourusername/ib-image:v1
docker push yourusername/mb-image:v1
docker push yourusername/insurance-image:v1
docker push yourusername/loans-image:v1

Each command uploads the corresponding image to your Docker Hub repository. Once pushed, these images are available to anyone with access to your repository. A colleague on a different machine can pull them with docker pull yourusername/ib-image:v1.

This is how images are shared in real workflows. Developers build and push. Operations teams pull and deploy. CI/CD pipelines automate both steps.


Docker Compose vs Raw Docker: The Clear Difference

Before wrapping up, this distinction is worth stating plainly.

With raw Docker commands, you create and manage containers one at a time. docker run, one container. docker stop, one container. docker rm, one container. It is fine for individual containers during experimentation.

With Docker Compose, you manage your entire application stack as a unit. docker-compose up starts everything. docker-compose down stops and removes everything. docker-compose logs shows logs from all services at once. You are working at the application level rather than the container level.

Docker Compose is the right tool when your application involves multiple services that need to run together, and when you need that configuration to be repeatable and shareable.


Summary

Here is what this post covered.

Docker Compose solves the problem of managing multiple containers manually. You write one docker-compose.yaml file describing all your services, their images, and their port mappings. One docker-compose up -d command creates everything.

The key Docker Compose commands to remember are up -d to start, down to stop and remove, stop to stop without removing, ps to list, logs to debug, pause and unpause for process management, and up --build -d when you need to rebuild images before starting.

Docker Compose has a scaling limitation. Static host port mappings prevent you from running multiple instances of the same service. This is not a bug you can work around. It is a design constraint of Docker Compose that Kubernetes addresses.

The Nginx load balancer pattern gives you high availability inside a Docker-only environment. You run Nginx as a container, configure it with an upstream block pointing to your backend containers by service name, and expose only Nginx to the outside world. Backend containers use expose instead of ports to stay reachable only within the Docker network.

All images should eventually be pushed to Docker Hub. Tag them with your Docker Hub username, run docker login, and push. Your images are then available from any machine, to any team member, as the foundation for further deployments including Kubernetes.

More from this blog

Ansible Playbooks Explained: From First YAML File to Managing Real Servers

If you have already run a few Ansible ad hoc commands and seen how they work, you already understand the core idea: one command, many servers. But ad hoc commands only take you so far. When you need to install software, start services, create users, copy files, and print confirmation messages, all in one automated run across multiple servers, that is when you move to playbooks. Playbooks are where Ansible truly earns its place in a DevOps workflow. Everything you do in Ansible at scale, you do through playbooks.

Jun 19, 202613 min read
Ansible Playbooks Explained: From First YAML File to Managing Real Servers

You Have 400 Servers to Configure. Now What? Let Ansible Do the Work.

Picture this. You have four EC2 instances running in your AWS account, and someone asks you to install Apache on all four of them. What do you do? The obvious answer most people go with is SSH into each machine, run the install command, repeat. Simple enough when it is four servers. But what happens when it is forty? Or four hundred? In a real enterprise environment, that number is not exaggerated at all. That is exactly the problem that Ansible was built to solve. And once you understand what it does and how it thinks, you will wonder how anyone managed large infrastructure without it.

Jun 18, 202612 min read
You Have 400 Servers to Configure. Now What? Let Ansible Do the Work.

Building Production-Ready Docker Deployments with Secrets, Stacks, and Distroless Images

This post wraps up the core Docker Swarm curriculum by covering four important topics that complete the picture of production-ready containerized deployments. We start with Docker Secrets, which solves the real-world problem of passing sensitive credentials into containers without hardcoding them. We then look at Docker Stack, which is how you run multi-service Docker Compose files across a Swarm cluster instead of a single host. After that we cover the distinction between replicated and global services, which is a concept that appears in Kubernetes as well. We close with a look at Portainer for those who prefer a visual interface, and a brief introduction to distroless images. Each of these topics builds on everything covered so far. If you have your Swarm cluster running, you can follow along with every command shown here.

Jun 17, 202614 min read
Building Production-Ready Docker Deployments with Secrets, Stacks, and Distroless Images

Beyond One Server: Solving Docker Scaling with Swarm and Container Networks

This post covers three separate but deeply connected topics. We start by finishing what was started with Docker Hub, pushing all four bank service images to a remote registry so they survive beyond any single machine. From there, we identify a real architectural problem with single-host Docker deployments and introduce Docker Swarm as the solution. Finally, we close with Docker networking, explaining how containers communicate with each other both on the same host and across different hosts. By the end of this article, you will understand how to push and pull images from Docker Hub, how to set up a multi-node Docker Swarm cluster, how to create and scale services across that cluster, what self-healing means in practice, and how Docker networking works under the hood.

Jun 17, 202618 min read
Beyond One Server: Solving Docker Scaling with Swarm and Container Networks
S

Sai Praneeth's Blogs

37 posts

From SDLC and Agile to DevOps and CI/CD, this blog is where I share structured technical notes, concepts and practical insights in Cloud and DevOps engineering.