There is a particular kind of bug that every developer eventually gets tired of.
The application works perfectly on your laptop. The tests pass, the API responds as expected, and everything looks good. Then someone else pulls the repository, runs the application, and suddenly nothing works.
Maybe they have a different version of Python. Maybe their local PostgreSQL installation is configured differently. Or perhaps a dependency was installed months ago and nobody remembers where it came from.
You can spend an afternoon fixing these problems, or you can start making your development environment reproducible.
That is where Docker comes in.
What Docker Actually Does
Docker packages an application and the environment it needs into a portable unit called a container.
Instead of asking every developer to install the correct runtime, configure the right dependencies, and manually set up supporting services, you describe the environment in code.
There are three concepts worth understanding before writing your first Dockerfile:
- Image: A template containing your application and the files it needs to run.
- Container: A running instance of an image.
- Dockerfile: The instructions Docker uses to build an image.
Think of an image as a recipe and a container as the meal prepared from it. You can create multiple containers from the same image without rebuilding the application from scratch each time.
Containers are also different from virtual machines. They share the host operating system’s kernel rather than running a complete guest operating system for every application. That generally makes them lighter and quicker to start.
The real benefit, however, is consistency. Your local environment becomes something you can describe, review, and reproduce instead of a collection of undocumented installation steps.
Your First Docker Container
Before containerizing your own application, try running an existing image:
docker run --rm hello-worldBashDocker downloads the hello-world image if it is not already available locally, creates a container, runs it, and removes the container when it exits.
That single command demonstrates the basic workflow. You do not need to install the application inside your own operating system or configure a project-specific runtime.
Once Docker is installed and running, you can use the same approach with more useful images, including databases, caches, and application runtimes.
But running an existing image is only the beginning. Let’s build something of our own.
Build a Small Python API
For this example, we will create a tiny HTTP API using Flask. You can follow the same general approach with a Node.js service, a Java backend, or another application stack.
Create a directory with these two files:
docker-demo/
├── app.py
├── requirements.txt
└── DockerfileJSONFirst, add the following code to app.py:
from flask import Flask
app = Flask(__name__)
@app.get("/")
def home():
return {"message": "Hello from Docker!"}DockerfileNext, create requirements.txt:
FlaskDockerfileNow comes the important part: the Dockerfile.
Create a file named Dockerfile in the project root, without a file extension:
FROM python:3.13-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app.py .
EXPOSE 8000
CMD ["flask", "--app", "app", "run", "--host=0.0.0.0", "--port=8000"]DockerfileLet’s break down what happens here.
FROM selects the base image, which provides the Python runtime. WORKDIR establishes the working directory inside the image.
We copy requirements.txt before copying the application code. This is intentional: Docker can reuse the dependency-installation layer when you change your Python code without changing your dependencies.
RUN installs Flask, while COPY adds the application file. EXPOSE documents the port the application listens on, and CMD defines the default command used to start it.
Notice the 0.0.0.0 address. The Flask development server must listen beyond the container’s loopback interface for connections forwarded from the host to reach it.
One important distinction: this setup is intended for local development. Flask’s built-in development server is not the right server to expose a production application to real users.
Build and Run It
Open a terminal in the docker-demo directory and build the image:
docker build -t docker-demo .BashThe -t option gives the image a name. The final dot tells Docker to use the current directory as the build context.
Once the build finishes, start a container:
docker run --rm -p 8000:8000 docker-demoBashOpen http://localhost:8000 in your browser. You should receive:
{
"message": "Hello from Docker!"
}JSONThe -p 8000:8000 option maps port 8000 on your machine to port 8000 inside the container. Without this mapping, the service would not be accessible through that host port.
You have now built an image from your own source code and run it as a container.
That is the foundation. The next step is making the workflow convenient enough to use every day.
Why Docker Compose Matters
Most backend applications need more than one process. A typical service might depend on PostgreSQL, Redis, or a message broker.
You could start every container manually, remember its ports, configure environment variables, and repeat the process whenever you return to the project.
Or you could describe the setup once in a Compose file.
Create compose.yaml alongside your existing files:
services:
api:
build: .
ports:
- "8000:8000"JSONNow run:
docker compose up --buildBashCompose builds the image if necessary and starts the API. The configuration is stored in the repository, so another developer can start with the same basic command.
When you are finished, press Ctrl+C. To remove the containers created by the Compose project, run:
docker compose downBashAs the application grows, you can add database and cache services to the same file. Compose creates a network for the services, allowing them to communicate using their service names.
For example, an API can connect to a PostgreSQL service using db as its hostname if the service is named db. There is no need to hardcode another container’s IP address.
This is one of the reasons Compose is useful even for small projects: the development environment becomes part of the project rather than a separate set of instructions that everyone has to remember.
Three Things Beginners Often Get Wrong
Getting an application running in Docker is relatively easy. Building a workflow that stays reliable takes a little more care.
1. Assuming containers are permanent.
Containers can be stopped, removed, and recreated. Data written only to a container’s writable filesystem should not be treated as durable storage.
Databases and other stateful services need appropriate persistent storage, such as named volumes, and production data needs a proper backup strategy.
2. Copying the entire project into every image.
A careless build context can include local caches, Git metadata, environment files, and other unnecessary files.
A .dockerignore file helps exclude them:
.git
.venv
__pycache__
*.pyc
.envBashThis keeps the build context smaller and reduces the risk of accidentally including local secrets. Remember that .dockerignore is not a substitute for proper secret management.
3. Treating a development container as production-ready.
A working container is not automatically a secure deployment.
Production images need appropriate runtime configuration, dependency management, and security review. Use a production-grade application server, avoid running as root where practical, and choose trusted, maintained base images. Pinning versions and regularly rebuilding and scanning images also help make deployments more predictable.
Docker does not remove the need for good engineering practices. It gives you a better foundation on which to apply them.
The Real Value Is in the Workflow
Docker is often introduced as a deployment tool, but I think its biggest benefit appears much earlier in the development process.
When the environment is defined in version control, onboarding becomes easier. Tests can run against a predictable setup. CI pipelines can build the same application image that you intend to deploy. Developers spend less time investigating differences between machines and more time working on the actual software.
That does not mean every project needs a complicated container architecture. Start with one service, understand how the image is built, and learn how to inspect logs and troubleshoot networking before adding more moving parts.
For a small backend project, a Dockerfile and a Compose file are often enough to make a noticeable difference.
And if your team already cares about small pull requests, fast feedback, and reliable deployments, containerizing your development environment is a natural next step.
The goal is not to put everything in Docker just because you can. The goal is to make the software easier to build, test, and run consistently — without another developer having to ask which version of Python you installed three months ago.

