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.

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.





