I first tried Docker in 2019. I followed a tutorial, pulled an Ubuntu image, ran it, and felt nothing. It worked, but I had no idea why I should care. Over the next few years, I tried again three times. Each attempt ended the same way: I would read the docs, type some commands, get a container running, and then abandon it because my actual projects worked fine without it. Docker felt like a solution to a problem I didn’t have. Until last year, when I picked three side projects that forced me into situations where Docker was not just useful but the only sane approach. Those projects rewired my brain, and now I use Docker for almost everything. This is the story of those projects, the moments of clarity, and the mistakes that almost made me give up for good.
Why I Kept Failing to Learn Docker
The tutorials all started the same way: “Docker is like a lightweight virtual machine.” That metaphor confused me. I knew virtual machines. They were heavy, slow, and required installing an operating system. Docker was fast, but I could not map the concepts. What was an image versus a container? Why did containers disappear when I stopped them? Why did I need a Dockerfile when I could just install dependencies on my laptop? The tutorials answered these questions abstractly, but abstraction without context evaporates. I needed concrete problems where the alternative was so painful that Docker felt like a relief.
Another barrier was the command line. Docker’s CLI is verbose and full of flags that I did not understand. I would copy paste commands from tutorials, watch the output scroll by, and have no mental model of what was happening. When something broke, which was often, I could not debug it because I had not built the system myself. I was using a tool without understanding it, and that is a recipe for frustration. The three side projects forced me to build from scratch, make mistakes, and eventually develop that mental model.
Project One: A Python Script That Needed a Specific Environment
My first clarity came from a small data pipeline. I wrote a Python script that scraped a few websites and saved the results to a CSV. It worked on my laptop. I wanted to run it on a schedule on my Raspberry Pi home server. I copied the script, installed the dependencies, and it broke. The Pi had an older version of Python and a different version of a key library. I spent an hour debugging version conflicts before I remembered Docker. This was the exact problem containers solved: running the same code in the same environment everywhere.
I wrote a Dockerfile from scratch. It was maybe six lines. I started from a Python base image, copied the script and a requirements file, installed the dependencies, and set the command. I built the image on my laptop, pushed it to a private registry, and pulled it on the Pi. The script ran immediately, with no dependency errors and no version conflicts. The feeling was not just relief. It was understanding. The image was a self-contained environment that I could ship anywhere. The container was just a running instance of that image. The tutorial words finally had concrete meaning.
That first project taught me the core loop: write a Dockerfile, build an image, run a container. It also taught me the value of small images. My first build used the full Python image and was over a gigabyte. Later, I switched to a slim image and reduced it to 200 megabytes. That mattered on the Pi’s limited storage. I learned about layers, caching, and why the order of commands in a Dockerfile matters. None of this stuck when I was reading about it in the abstract. It stuck because I was fixing a real problem on a real device.
Project Two: A Multi-Container Web App That Needed a Database
The second breakthrough came when I built a small web app with a React frontend, a Node.js backend, and a PostgreSQL database. I started by running each service manually: one terminal for the backend, one for the frontend dev server, and PostgreSQL installed directly on my laptop. Keeping them all in sync was a nightmare. The database had to be running before the backend, the backend had to be running before the frontend could fetch data, and if I switched projects for a few days, I would forget which ports were in use and which environment variables I had set. I was spending more time managing the development environment than writing code.
Docker Compose changed everything. I wrote a single YAML file that defined three services: db, backend, and frontend. The backend depended on the database, and the frontend depended on the backend. With one command, docker-compose up, all three services started in the right order, on the right ports, with the right environment variables. I could stop them with one command. I could tear everything down and rebuild it from scratch in minutes. The reproducibility was liberating. When a teammate joined the project, they cloned the repository, ran docker-compose up, and had the exact same environment. No installation instructions, no version mismatch debates, no “works on my machine.”
The moment of true understanding came when I had to debug a network issue between the backend and the database. The backend could not connect to PostgreSQL. I had written “localhost” as the database host in the connection string. Inside a container, localhost refers to the container itself, not the host machine or other containers. The fix was to use the service name from Docker Compose, “db”, because Docker Compose creates a private network where containers can reach each other by service name. That lesson taught me about Docker networking, service discovery, and why environment variables should be parameterized. It was a small mistake that unlocked a deep concept, and I never made that error again.
Docker Compose also taught me about volumes. My database data would disappear every time I recreated the container. That was by design, containers are ephemeral, but I needed the data to persist. Adding a volume mount to the Compose file kept the database files on my host machine, surviving container restarts. The mental model solidified: containers are disposable, data is not. Volumes bridge the gap. These were not just commands anymore. They were tools with clear purposes that I had discovered through need.
Project Three: A CI/CD Pipeline That Needed Consistent Builds
The third project was the hardest and the most educational. I was contributing to an open source project that used GitHub Actions for continuous integration. The tests required a specific version of Node.js, a running Redis instance, and a few system libraries. The CI environment was a fresh Ubuntu runner with none of those things. The existing pipeline installed them manually with shell commands, and every few weeks a dependency would change and break the build. I volunteered to Dockerize the test environment so the build would be consistent and the configuration would live in code.
I wrote a Dockerfile that started from Ubuntu, installed the exact Node.js version, added Redis, and set up the test dependencies. Then I modified the GitHub Actions workflow to build that image and run the tests inside it. The first attempt failed because the image was too large and the runner timed out while building. I learned about multi-stage builds: using one base image to install build tools, then copying only the necessary artifacts into a smaller final image. I split the Dockerfile into stages, and the image size dropped by 60 percent. The build succeeded, and the test suite ran identically on my laptop, in CI, and on every contributor’s machine.
The multi-stage build was a revelation. I realized that Docker images are not just for deployment. They are a build environment that you can define, version, and share. I started using Docker for local development of every project that had system dependencies. If a project needed Redis, I added it to a Docker Compose file instead of installing it on my laptop. My host machine stayed clean, and each project’s dependencies were isolated. I stopped polluting my system with random packages. Docker became my default, not my exception.
This project also taught me about Docker layer caching in CI. By copying only the dependency files first and installing them before copying the rest of the code, I could cache the dependency layer and speed up builds. The CI pipeline went from eight minutes to under three. The optimization was a game, and I finally understood the mechanics well enough to play it.
The Mistakes That Nearly Stopped Me Again
The three projects gave me confidence, but there were dark moments. During the multi-container project, I accidentally deleted a volume that contained a week of test data. I had not backed it up. I learned the hard way that docker-compose down with the volumes flag is destructive. Now I always double-check my commands and keep database dumps. Another time, I filled my laptop’s disk with old Docker images and dangling volumes. I ran docker system prune without understanding what it would remove, and I lost a few built images I had not pushed to a registry. The lesson was to treat Docker cleanup with the same caution as any system administration task. I also learned to tag images properly, so I never have to guess which ones are safe to delete.
A more subtle mistake was using Docker for everything, even simple scripts. Docker adds a layer of indirection, and for a single Python file that needs no special environment, it is overkill. I now use Docker when the environment matters: when there are system dependencies, multiple services, or a need for reproducibility across machines. I use plain scripts for the rest. The right tool for the right problem.
What I Know Now That I Wish I Knew Then
Looking back at my years of failed Docker learning, I see the patterns. Tutorials taught me the commands, but projects taught me the concepts. I needed a concrete problem where the alternative was painful enough to motivate the learning. The data pipeline taught me the image and container model. The web app taught me Compose, networking, and volumes. The CI pipeline taught me multi-stage builds and layer caching. Each project added a piece to the mental model, and together they made Docker feel natural.
I also wish I had started with Docker Compose sooner. Compose wraps the verbose CLI commands into a readable YAML file, and it handles the networking and lifecycle automatically. It is a gentle introduction that hides complexity until you are ready to dig deeper. I would recommend new learners start with a simple Compose file for a project they already understand, rather than trying to grok the entire CLI first.
What I Would Do Differently If Starting Over
If I could advise my past self, I would give three pieces of guidance. First, stop reading and start building with a real project. The concepts only stick when you need them. Pick a small script that requires a specific environment, Dockerize it, and run it on a different machine. That single success will teach more than a hundred tutorials. Second, start with Compose, not raw Docker commands. Compose is the friendly wrapper that will keep you from drowning in flags and network configurations. Third, treat Docker as a tool for consistency, not a universal solution. Use it when environment matters, and not when a simple script suffices. The skill is knowing the difference.
How Docker Fits Into My Workflow Now
Today, Docker is the foundation of my development workflow. Every new project starts with a Docker Compose file. I spin up databases, caches, and queue systems in seconds, without installing anything on my host. I share projects with teammates via Git and a Compose file, and they can run everything with a single command. I deploy to production using the same images I tested locally, which eliminates deployment surprises. Docker has not just made me more productive. It has changed how I think about software environments. The laptop is just a thin client. The real consistency lives in the images.
The three side projects were the bridge from confusion to competence. They were not large or glamorous. They were small, practical needs that Docker solved elegantly. That is the secret I wish more tutorials shared: you do not learn Docker by studying Docker. You learn Docker by needing it, failing with it, and slowly building the muscle memory that turns a verbose command into a trusted tool. If you are struggling with Docker as I did, pick a project that hurts without it. The pain will be your teacher, and the reward will be a skill that transforms how you build software.
