We’ve all heard the timeless developer cliché:
"It works on my machine!"
"Well, then we'll ship your machine!"
...and that is practically how Docker was born.
Recently, I set out on a DevOps homelab project to containerize a Python Flask web application. On the surface, the tutorial playbook sounded deceptively simple: write an app.py, slap together a Dockerfile, run docker build, run docker run, and celebrate.
Except in real-world DevOps, getting the app to run is only 20% of the battle.
The real learning happened when things broke: when Unix socket permissions locked me out inside DevPod, when port conflicts greeted me on macOS, when containers exited unexpectedly, and when network interfaces refused to talk to each other.
Here is a comprehensive breakdown of my journey, the architecture, the "aha!" moments, and the systematic troubleshooting mindset that completely changed how I look at containerization.
🛠️ The Architecture & The Environment
Before writing any code, I wanted a modern, reproducible setup rather than polluting my personal macOS host machine.
Here is the tech stack I used:
- Host Machine: macOS (Apple Silicon / aarch64) running Docker Desktop
- Development Environment: DevPod (using Docker as its provider) with an Ubuntu 24.04 container
- Toolchain Manager: Mise (managing Python, pipx, and Docker CLI)
- Application Framework: Flask (Python 3.12)
- Container Engine: Docker
How the Pieces Fit Together
Here is the mental model of how the host, DevPod, the Docker daemon, and the containerized app communicate:
┌─────────────────────────────────────────────────────────────┐
│ macOS Host (Docker Desktop Engine & Daemon) │
│ │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ DevPod Development Container (Ubuntu 24.04) │ │
│ │ - Managed with Mise (Python 3.12, Docker CLI) │ │
│ │ - Connected via /var/run/docker.sock bind mount │ │
│ │ - Builds and controls containers │ │
│ └──────────────────────┬───────────────────────────────┘ │
│ │ Controls via Docker API │
│ ▼ │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ Application Container: containarize-web-app │ │
│ │ - Python 3.12-slim base │ │
│ │ - Flask app listening on 0.0.0.0:5000 │ │
│ └──────────────────────▲───────────────────────────────┘ │
│ │ │
│ Port Mapping (-p 5001:5000) │
│ │ │
│ Browser / Curl ────────┴─────────────────────────────── │
│ http://localhost:5001 │
└─────────────────────────────────────────────────────────────┘
🐍 1. The Application & The Dockerfile
The web application itself is a minimal Flask service in app.py:
from flask import Flask
app = Flask(__name__)
@app.route("/")
def hello():
return "Hello from my containerized application!"
if __name__ == "__main__":
app.run(host="0.0.0.0", port=5000)
Notice host="0.0.0.0". More on why that is critical in a moment!
Next came the Dockerfile:
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app.py .
EXPOSE 5000
CMD ["python", "app.py"]
💡 Lesson 1: Build Order & Layer Caching Matter
Notice that I didn’t just do COPY . . at the top.
Docker builds images in sequential, cacheable layers. By copying only requirements.txt and running pip install before copying app.py, Docker caches the installed packages. If I tweak a single string or route inside app.py, Docker re-uses the cached dependency layer and rebuilds in milliseconds instead of re-downloading packages every single time.
I also added a .dockerignore to ensure .git, __pycache__, and local .venv folders never bloat the Docker build context.
⚡ 2. The DevPod Challenge: The "Docker-in-Docker" Socket Mystery
Because I was developing inside a DevPod devcontainer, running docker info initially spat out a fatal error:
Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running?
What was happening?
The Docker CLI is just a client—a messenger. It doesn't actually build or run containers; the Docker Daemon does. My devcontainer had the Docker CLI installed, but the daemon lived outside on my Mac (Docker Desktop).
To fix this, I needed to bind-mount the Docker Unix socket in .devcontainer/devcontainer.json:
{
"build": {
"context": "..",
"dockerfile": "Dockerfile"
},
"mounts": [
"source=/var/run/docker.sock,target=/var/run/docker.sock,type=bind"
]
}
The Permissions Curveball: srw-rw---- root root
Once the socket was mounted, I ran docker info again, only to be stopped by another error:
permission denied while trying to connect to the Docker API
Checking the socket with ls -la /var/run/docker.sock revealed:
srw-rw---- 1 root root 0 /var/run/docker.sock
Unix sockets obey standard Linux file permission bits. The socket was owned by root:root, but DevPod runs as the non-root vscode user.
Instead of doing an insecure chmod 777, the clean fix was getting my active shell to recognize root/docker group privileges:
newgrp root
Immediately after running newgrp root, docker info connected cleanly, outputting:
Server Version: 29.7.2
Operating System: Docker Desktop
Architecture: aarch64
The bridge between DevPod and Docker Desktop was live! 🚀
🌐 3. Networking Lessons: The Tale of Two IP Addresses & Port Mapping
Once my image was built with:
docker build -t containarize-web-app .
it was time to run the container. That’s when container networking fundamentals kicked in.
Why 0.0.0.0 vs. 127.0.0.1?
When developing locally, you usually bind to 127.0.0.1 (localhost). But inside a container:
-
127.0.0.1is the loopback interface of the container itself. Any request originating from outside the container (even from the host through Docker's bridge) gets discarded at the door. - Binding to
0.0.0.0tells Flask: "Listen on all network interfaces inside this container." This allows traffic forwarded by Docker to actually reach the application.
The Port Conflict: 5001:5000
When I tried running:
docker run --name containarize-web-app -p 5000:5000 containarize-web-app
I got hit with:
bind: address already in use
Port 5000 was already occupied on my macOS host (often taken by macOS AirPlay Receiver or another local daemon).
Here is the beauty of Docker port mapping: You do not need to change your application code or container configuration.
The -p syntax represents:
-p <HOST_PORT>:<CONTAINER_PORT>
By changing the mapping to -p 5001:5000, requests hitting my Mac on port 5001 get transparently routed to port 5000 inside the container:
docker run -d \
--name containarize-web-app \
-p 5001:5000 \
containarize-web-app
🧪 4. Testing at Every Layer
One huge habit I developed during this project was testing layer-by-layer rather than guessing when something failed.
Step 1: Test directly inside the container
First, verify whether Flask is alive inside its own container boundary using docker exec:
docker exec containarize-web-app \
python -c "import urllib.request; print(urllib.request.urlopen('http://127.0.0.1:5000').read().decode())"
Output:
Hello from my containerized application!
(Flask is alive and answering on port 5000!)
Step 2: Test from the host via published port
Now, test through Docker’s network port forwarder from the host terminal:
curl http://localhost:5001
Output:
Hello from my containerized application!
And opening http://localhost:5001 in the browser yielded the same greeting. The end-to-end network chain was completely validated!
🧠 5. The Golden Rule of DevOps Debugging: Isolate the Layers
When an error strikes in containerized environments, our natural instinct is often to frantically change five things at once: modify app.py, change the Dockerfile, restart Docker Desktop, and tweak host settings.
This project taught me a much more structured debugging ladder:
1. Application Layer ──> Does Python/Flask run locally? Check syntax & deps.
↓
2. Container Lifecycle ──> Did the container start or crash? (docker ps -a, docker logs)
↓
3. Process Binding ──> Is Flask listening on 0.0.0.0 (not 127.0.0.1)?
↓
4. Port Forwarding ──> Is the host port conflicting? Is HOST:CONTAINER mapped right?
↓
5. Daemon & Socket ──> Is the Docker socket mounted and readable? (newgrp root)
↓
6. Host Ingress ──> Can curl or the browser reach localhost:HOST_PORT?
By moving down the ladder one rung at a time, finding the root cause becomes deterministic rather than a guessing game.
🧰 My Essential Docker Toolkit
Here are the commands I found myself relying on constantly:
| Command | What it does |
|---|---|
docker build -t <name> . |
Builds the image using the current directory context |
docker run -d --name <name> -p <host>:<container> <image> |
Runs the container detached in the background |
docker ps -a |
Lists all containers, including stopped/exited ones |
docker logs <container> |
Prints stdout/stderr logs from the container |
docker inspect <container> |
Inspects IP addresses, network settings, and state |
docker exec -it <container> sh |
Drops you into an interactive shell inside the running container |
docker rm -f <container> |
Stops and removes a container in one shot |
🔮 What’s Next on the Roadmap?
This project gave me solid operational foundations, but real-world production setups go even further. Here is what I plan to build next:
- Production WSGI Server: Replace Flask’s development server with Gunicorn.
-
Security Hardening: Add a non-root
USER appuserin the Dockerfile. -
Container Healthchecks: Add Docker
HEALTHCHECKinstructions. - CI/CD Pipeline: Automate builds and test suites with GitHub Actions.
- Orchestration: Expand to multi-container setups using Docker Compose and eventually deploy to Kubernetes!
💬 Over to You!
If you're starting your DevOps journey, what was the first weird Docker error that made you pull your hair out? Was it socket permissions, port collisions, or dangling volumes?
Drop a comment below—I’d love to hear your container war stories! 👇
Top comments (0)