DEV Community

Cover image for From Code to Container: What Building & Debugging My First Dockerized App Taught Me About Real-World DevOps
Alan Varghese
Alan Varghese

Posted on

From Code to Container: What Building & Debugging My First Dockerized App Taught Me About Real-World DevOps

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                                     │
└─────────────────────────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

🐍 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)
Enter fullscreen mode Exit fullscreen mode

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"]
Enter fullscreen mode Exit fullscreen mode

💡 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?
Enter fullscreen mode Exit fullscreen mode

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"
  ]
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Checking the socket with ls -la /var/run/docker.sock revealed:

srw-rw---- 1 root root 0 /var/run/docker.sock
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Immediately after running newgrp root, docker info connected cleanly, outputting:

Server Version: 29.7.2
Operating System: Docker Desktop
Architecture: aarch64
Enter fullscreen mode Exit fullscreen mode

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 .
Enter fullscreen mode Exit fullscreen mode

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.1 is 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.0 tells 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
Enter fullscreen mode Exit fullscreen mode

I got hit with:

bind: address already in use
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

🧪 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())"
Enter fullscreen mode Exit fullscreen mode

Output:

Hello from my containerized application!
Enter fullscreen mode Exit fullscreen mode

(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
Enter fullscreen mode Exit fullscreen mode

Output:

Hello from my containerized application!
Enter fullscreen mode Exit fullscreen mode

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?
Enter fullscreen mode Exit fullscreen mode

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:

  1. Production WSGI Server: Replace Flask’s development server with Gunicorn.
  2. Security Hardening: Add a non-root USER appuser in the Dockerfile.
  3. Container Healthchecks: Add Docker HEALTHCHECK instructions.
  4. CI/CD Pipeline: Automate builds and test suites with GitHub Actions.
  5. 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)