This hands-on portfolio project demonstrates how to design, connect, secure, and troubleshoot a multi-service Docker application using raw docker commands, before ever introducing Docker Compose.
The project follows a business scenario in which Nimbus Analytics is prototyping a small application that needs a web tier, a cache, and a database. Before committing to Docker Compose, the task was to prove - and understand - that multi-container networking, service discovery, and resource governance work correctly using Docker's core primitives alone.
The implementation begins by running four independent containers on Docker's default bridge network, deliberately observing where that network falls short, then progressively resolves those gaps with a user-defined bridge network, container-to-container DNS resolution, and enforced resource limits. The project concludes by building a custom Flask application backed by Redis, proving both state persistence and graceful handling of a dependent service failing.
This is a project-based learning environment designed to demonstrate Docker networking fundamentals, container resource management, Dockerfile authorship, and technical documentation. It is not presented as a production deployment.
A detailed Medium walkthrough documenting the complete build process, screenshots, troubleshooting, validation steps, engineering decisions, and lessons learned is available here:
graph LR
subgraph "app-net (user-defined bridge network)"
R[redis]
M[mysql:8.0]
RC[redis-commander]
PA[phpmyadmin]
F[flask-counter]
F -- "REDIS_HOST=redis" --> R
RC -- "REDIS_HOST=redis" --> R
PA -- "PMA_HOST=mysql" --> M
end
Browser -- "localhost:5000" --> F
Browser -- "localhost:8081" --> RC
Browser -- "localhost:8080" --> PA
All five containers share a single user-defined bridge network, which provides embedded DNS resolution so each service can find the others by container name, with no hardcoded IP addresses anywhere in the configuration.
- Docker Desktop
- Docker Engine and Docker CLI
- Windows PowerShell
- Redis
- MySQL 8.0
- redis-commander (Redis web GUI)
- phpMyAdmin (MySQL web GUI)
- Python 3.11 and Flask
- Docker user-defined bridge networks
- Docker resource limits (--memory, --cpus)
- Git and GitHub
- Markdown and Mermaid diagrams
- Run multiple containers independently on Docker's default bridge network.
- Observe and explain the networking limitations of the default bridge network.
- Create a user-defined bridge network and attach multiple containers to it.
- Configure containers to resolve one another by container name via embedded DNS.
- Apply CPU and memory limits to running containers and validate them under real load.
- Author a custom Dockerfile for a Python/Flask application.
- Connect a custom application container to an existing service (Redis) over a shared network.
- Demonstrate that application state can survive a container restart when stored externally.
- Demonstrate and explain a dependent-service failure and recovery scenario.
- Practice systematic troubleshooting of unexpected errors without guessing at fixes.
.
|-- .gitignore
|-- README.md
`-- app/
|-- app.py
|-- requirements.txt
`-- Dockerfile
Nimbus Analytics is prototyping a small application that needs a web tier, a cache, and a database. Before committing to Docker Compose for orchestration, the team wants proof that the underlying networking, service discovery, and resource controls are actually understood, not just copied from a Compose file that "just works."
GUI tools (redis-commander for Redis, phpMyAdmin for MySQL) were used in place of a hand-written application for the first two phases, keeping the focus on Docker networking rather than application code. The final phase replaces this with an actual custom application.
Four containers were run independently, each with no custom networking configuration, landing them all on Docker's default bridge network:
docker run -d --name redis redis:latest
docker run -d --name mysql -e MYSQL_ROOT_PASSWORD=rootpass mysql:8.0
docker run -d --name redis-commander -p 8081:8081 rediscommander/redis-commander:latest
docker run -d --name phpmyadmin -p 8080:80 phpmyadmin/phpmyadminAll four were confirmed running concurrently:
docker ps
docker stats --no-streamBoth GUI containers were loaded in a browser to confirm they could not yet reach their respective databases:
http://localhost:8081 (redis-commander)
http://localhost:8080 (phpMyAdmin)
redis-commander defaulted to 127.0.0.1:6379 and failed with ECONNREFUSED:
setUpConnection Redis error Error: connect ECONNREFUSED 127.0.0.1:6379
127.0.0.1 is a loopback address scoped to each container's own network namespace. It can never reach a sibling container, regardless of physical proximity on the same Docker host.
phpMyAdmin defaulted to a hostname of db and failed with:
mysqli::real_connect(): php_network_getaddresses: getaddrinfo for db failed: Name or service not known
This is a different class of failure - db is a naming convention the phpMyAdmin image assumes for a typical Docker Compose stack, and the lookup fails immediately because Docker's default bridge network has no embedded DNS server. There is no name resolution step to even attempt.
While loading redis-commander for the first time, the browser initially displayed a generic "It works!" placeholder page instead of the actual UI. This was resolved by loading the page in an incognito/private window, confirming it was a browser caching artifact rather than an application or networking problem.
A user-defined bridge network was created and all four containers were attached to it:
docker network create app-net
docker network connect app-net redis
docker network connect app-net mysql
docker stop redis-commander phpmyadmin
docker rm redis-commander phpmyadmin
docker run -d --name redis-commander --network app-net -p 8081:8081 -e REDIS_HOST=redis rediscommander/redis-commander:latest
docker run -d --name phpmyadmin --network app-net -p 8080:80 -e PMA_HOST=mysql phpmyadmin/phpmyadminredis and mysql needed no new configuration and were attached to the existing network live via docker network connect. redis-commander and phpmyadmin required recreation, since their environment variables (REDIS_HOST, PMA_HOST) are read once at container startup and cannot be injected into an already-running container.
Docker's default bridge network provides basic container-to-container IP connectivity but includes no automatic service discovery. Containers cannot resolve each other by name on it.
A user-defined bridge network runs an embedded DNS server, so any container attached to it can resolve another attached container purely by its container name. This is what allows REDIS_HOST=redis and PMA_HOST=mysql to work as real, functioning hostnames rather than placeholder values.
The user-defined network was selected here because the entire goal of the project was demonstrating name-based service discovery between independently-run containers.
With both GUIs recreated on app-net, the network was inspected to confirm all four containers were attached with real IP addresses:
docker network inspect app-netBoth GUIs were reloaded in the browser:
- redis-commander displayed local (redis:6379:0) with an active connection and a working CLI prompt.
- phpMyAdmin successfully logged in and displayed Server: mysql via TCP/IP.
This confirmed that container-name DNS resolution, provided only by the user-defined network, was functioning correctly.
CPU and memory limits were applied to the running redis and mysql containers:
docker update --memory="256m" --memory-swap="256m" --cpus="0.5" redis
docker update --memory="512m" --memory-swap="512m" --cpus="0.5" mysqlThe limits were confirmed with:
docker inspect redis --format='{{.HostConfig.Memory}} {{.HostConfig.NanoCpus}}'
docker inspect mysql --format='{{.HostConfig.Memory}} {{.HostConfig.NanoCpus}}'Limits were then validated under genuine CPU-bound load rather than trusting the configuration alone:
docker exec -it redis redis-benchmark -q -n 200000 -c 50
docker exec -it mysql mysql -u root -prootpass -e "SELECT BENCHMARK(500000000, MD5('stress-test'));"docker stats was monitored live during both tests. Both containers plateaued at approximately 50% CPU usage, matching the configured --cpus="0.5" limit, confirming that Docker enforces these limits at the kernel cgroup level rather than as advisory configuration.
The initial attempt to apply a memory limit failed:
Error response from daemon: Cannot update container ...: Memory limit should be smaller than already set memoryswap limit, update the memoryswap at the same time
Docker's --memory-swap setting (total memory plus swap) is coupled to --memory. Since no swap limit had been previously set, it defaulted to unlimited, which conflicted with the new, smaller memory ceiling. The fix was to set both flags together in the same command, with --memory-swap equal to --memory to disable additional swap entirely.
The official mysql:8.0 image does not ship with mysqlslap or even basic utilities like which:
OCI runtime exec failed: exec: "mysqlslap": executable file not found in $PATH
Rather than installing additional tooling into the running container, MySQL's built-in BENCHMARK() SQL function was used instead, generating genuine CPU-bound load entirely through the standard mysql client, which was confirmed present.
A small Flask application was written to replace the GUI-based approach with an actual application, connecting to Redis to persist a hit counter.
app/app.py
import time
import redis
from flask import Flask
app = Flask(__name__)
cache = redis.Redis(host='redis', port=6379)
def get_hit_count():
retries = 5
while True:
try:
return cache.incr('hits')
except redis.exceptions.ConnectionError as exc:
if retries == 0:
raise exc
retries -= 1
time.sleep(0.5)
@app.route('/')
def hello():
count = get_hit_count()
return 'Hello World! I have been seen {} times.\n'.format(count)app/requirements.txt
flask
redis
app/Dockerfile
# syntax=docker/dockerfile:1
FROM python:3.11-slim
WORKDIR /code
ENV FLASK_APP=app.py
ENV FLASK_RUN_HOST=0.0.0.0
COPY requirements.txt requirements.txt
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 5000
CMD ["flask", "run"]The application logic deliberately stores its state (the hit count) in Redis, an external service, rather than in a Python variable inside the Flask process. The retry loop in get_hit_count() handles the case where Redis is not yet ready to accept connections when the application first starts.
The Dockerfile copies requirements.txt and installs dependencies before copying the rest of the application code, so Docker's layer cache can skip re-installing dependencies on rebuilds that only change application code, not requirements.txt.
The image was built and run on the same network as the existing services:
docker build -t flask-counter ./app
docker run -d --name flask-counter --network app-net -p 5000:5000 flask-counterSaving the Dockerfile in Notepad without explicitly selecting "All Files" in the save dialog silently appended a .txt extension, producing Dockerfile.txt instead of Dockerfile. This was caught by attempting to type the file back out and getting a "path not found" error, and resolved by renaming the file.
The application was confirmed working at http://localhost:5000, with the hit counter incrementing on each page refresh.
State persistence was verified by restarting only the Flask container:
docker restart flask-counterThe counter continued from its previous value rather than resetting, confirming that state lived in Redis and not in the Flask process itself.
Dependency failure handling was verified by stopping Redis while the application was running:
docker stop redisRefreshing the application triggered the retry loop in app.py, followed by a clean failure after five attempts, visible in:
docker logs flask-counterRestarting Redis (docker start redis) and refreshing again showed the application recover automatically, with the counter continuing from where it left off, since Redis's own data was never lost, only briefly unreachable.
This is the exact failure mode that Docker Compose's depends_on and healthcheck features are designed to solve, providing direct, hands-on motivation for adopting Compose in a future iteration of this project.
The completed deployment was validated with:
docker ps
docker network inspect app-net
docker inspect redis --format='{{.HostConfig.Memory}} {{.HostConfig.NanoCpus}}'
docker inspect mysql --format='{{.HostConfig.Memory}} {{.HostConfig.NanoCpus}}'Validation confirmed:
- All five containers ran concurrently and were attached to app-net.
- redis-commander and phpMyAdmin both resolved their databases by container name and connected successfully.
- Memory and CPU limits were present in each container's HostConfig and held under genuine load.
- The Flask application's hit counter persisted across a container restart.
- The Flask application degraded gracefully and recovered automatically when its dependency was temporarily unavailable.
A user-defined bridge network was created specifically to gain embedded DNS-based service discovery, which the default bridge network does not provide.
Containers that needed no new configuration (redis, mysql) were attached to the new network live, rather than recreated, to demonstrate that a running container can be given additional network access without downtime.
Containers whose behavior depended on environment variables (redis-commander, phpmyadmin) were stopped and recreated rather than modified in place, since Docker does not support injecting new environment variables into an already-running container.
--memory and --memory-swap were always set together, with swap disabled beyond the memory ceiling, to keep the container's total memory behavior simple and predictable rather than relying on Docker's implicit swap defaults.
The Flask application was deliberately built to store no state of its own, pushing the hit counter into Redis. This keeps the application container stateless and disposable, a standard cloud-native application design pattern.
- Use official container images from trusted publishers.
- Pin specific image versions or digests for production deployments instead of relying indefinitely on latest.
- Do not commit secrets, passwords, or tokens to the repository; the MYSQL_ROOT_PASSWORD used here is a local development-only placeholder.
- Apply explicit CPU and memory limits to any container that could otherwise consume unbounded host resources.
- Validate that a service is actually reachable and functioning, not just that its container shows a "running" status.
- Treat application containers as stateless and disposable; persist real data only in dedicated data services.
- Design application code to fail gracefully and retry when a dependent service is temporarily unavailable, rather than crashing outright.
- Docker's default bridge network provides IP connectivity but no automatic service discovery.
- A user-defined bridge network's embedded DNS is what makes container-name-based configuration (REDIS_HOST=redis) actually work.
- Environment variables are read once at container startup; changing them requires recreating the container, not just reconfiguring the network.
- --cpus and --memory limits are real, kernel-enforced ceilings, verified under genuine load rather than assumed from configuration alone.
- Minimal official images often omit convenience tooling (mysqlslap, which); built-in alternatives (BENCHMARK()) or Docker-native commands can substitute without modifying the image.
- Applications that depend on other containers should handle "not ready yet" and "temporarily unavailable" gracefully through retry logic.
- This exact dependency-ordering problem is what Docker Compose's depends_on and healthchecks exist to solve.
- Windows with Docker Desktop
- Docker Engine running
- PowerShell
- Git, if cloning from GitHub
Clone the repository and enter it:
git clone https://github.com/rester12/Docker-Project-Multi-Container-without-Compose.git
cd Docker-Project-Multi-Container-without-ComposeCreate the network and base services:
docker network create app-net
docker run -d --name redis --network app-net redis:latest
docker run -d --name mysql --network app-net -e MYSQL_ROOT_PASSWORD=rootpass mysql:8.0
docker run -d --name redis-commander --network app-net -p 8081:8081 -e REDIS_HOST=redis rediscommander/redis-commander:latest
docker run -d --name phpmyadmin --network app-net -p 8080:80 -e PMA_HOST=mysql phpmyadmin/phpmyadminBuild and run the custom Flask application:
docker build -t flask-counter ./app
docker run -d --name flask-counter --network app-net -p 5000:5000 flask-counterOpen:
http://localhost:5000 (Flask hit counter)
http://localhost:8081 (redis-commander)
http://localhost:8080 (phpMyAdmin)
Stop and remove everything when finished:
docker stop redis mysql redis-commander phpmyadmin flask-counter
docker rm redis mysql redis-commander phpmyadmin flask-counter
docker network rm app-net- Introduce Docker Compose to declaratively manage all five services and their startup order.
- Add depends_on and healthchecks to eliminate the manual dependency-failure scenario tested here.
- Add a .dockerignore file to the Flask application build context.
- Pin specific image versions instead of relying on latest.
- Replace the plaintext MYSQL_ROOT_PASSWORD with Docker secrets or an environment file excluded from version control.
- Add a proper WSGI server (e.g. Gunicorn) in place of Flask's development server for anything beyond local learning.
- Add automated tests and a CI pipeline (e.g. GitHub Actions) to validate the build on every push.
- Add persistent named volumes for MySQL and Redis data so it survives container removal, not just restarts.