The moment Docker clicked for me wasn't when I ran hello-world. It was
when I understood what a container actually is: not just "your app, but
packaged" — more like a whole room, built exactly to spec, with everything
inside it already arranged. The walls, the furniture, and the app itself
are all part of the same package. You're not just shipping code, you're
shipping the room it lives in.
That idea became a lot more concrete once I built something that needed
two rooms talking to each other.
Building the room — the Dockerfile
A Dockerfile is the blueprint for that room — a step-by-step recipe
that says: start with this foundation, add these tools, put your code
here, and here's how to turn the lights on.
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY app.py .
EXPOSE 5000
CMD ["python", "app.py"]
A few things worth explaining:
-
FROM python:3.12-slimsets the foundation — a lightweight image that already has Python installed, so I don't have to build an operating system from scratch. -
WORKDIR /appsets where everything happens inside the room — every command after this runs from that folder. -
COPY requirements.txt .andRUN pip install -r requirements.txthappen before copying the actual app code. That ordering matters: Docker caches each step, so if I only change my app code later, it won't reinstall every dependency from scratch — only the steps after the change get rebuilt. -
EXPOSE 5000documents which port the app listens on inside the container. It doesn't open anything by itself — that happens later, when the container actually runs. -
CMDis the last step: what to run the moment the room is occupied — in this case, start the Flask server.
Building the image from this blueprint is one command:
docker build -t hello-flask .
Running a container from it is another:
docker run -d -p 5000:5000 hello-flask
The -p 5000:5000 part maps a port on my machine to a port inside the
container — without it, the app would be running, but there'd be no way
to reach it from outside.
Two rooms, one hallway — docker-compose
One container is simple. But real applications rarely live alone — mine
needed a place to store data that survives beyond a single request. I
added Redis, a key-value store, to keep a visit counter that wouldn't
reset every time the app restarted.
This meant two containers now had to run together and talk to each other.
That's what docker-compose.yml is for:
services:
web:
build: .
ports:
- "5000:5000"
depends_on:
- redis
redis:
image: redis:alpine
build: . tells Compose to build the web service from my Dockerfile,
the same way I did manually before. image: redis:alpine does the
opposite — instead of building anything, it just pulls Redis's official,
lightweight image.
The part that actually made everything click was realizing how the two
containers find each other. It's not through the ports section — that
only maps my machine to the web container, and has nothing to do with
web talking to redis. The real connection happens because Compose
automatically creates a shared network between all the services in the
file, and inside that network, each container can be reached by its
service name.
That's why the app code just does this:
cache = redis.Redis(host="redis", port=6379)
"redis" isn't a placeholder — it's the literal name of the service
from the compose file. No IP addresses, no manual network configuration.
Docker handles the discovery on its own.
With both files in place, starting everything is one command:
docker compose up -d
Compose reads the file, starts redis first (because of depends_on),
then builds and starts web. Refreshing the page a few times confirmed
it was actually working:
Hello from Docker! This page has been viewed 8 times.
The count kept climbing with every refresh — proof that the state was
living in Redis, not disappearing every time the page reloaded.
An unexpected git detour
Pushing this project to GitHub hit a snag that had nothing to do with
Docker. I'd created the GitHub repository with an auto-generated README,
then tried to push my local commits on top of it:
! [rejected] main -> main (fetch first)
error: failed to push some refs to '...'
Git was refusing to overwrite history it didn't recognize — my local
repo and the GitHub repo had each started with their own independent
first commit, so there was nothing connecting them yet. The fix was to
pull the remote history in and let Git combine the two:
git pull origin main --allow-unrelated-histories --no-rebase
That flag exists specifically for this situation — Git normally refuses
to merge two histories with no common ancestor, as a safety measure
against accidentally combining unrelated projects. Since I knew exactly
why these two histories had diverged, it was safe to allow it. No
conflicts came up this time, just a merge commit to confirm, and the
push went through right after.
Small reminder that most of the real learning in this stuff doesn't
come from the parts that go smoothly.
What I learned
Before this, Docker felt like a black box — something everyone in DevOps
uses, but hard to picture without actually building something with it.
Writing a Dockerfile from scratch and connecting two containers through
Compose made it concrete.
A few things stuck with me:
- The order of steps in a Dockerfile matters, not just for correctness but for how efficiently images rebuild
- Containers in the same Compose file find each other by service name, not by ports or IP addresses
- Not every problem along the way is about the tool you're learning — sometimes it's just Git, doing what Git does
Top comments (0)