Walk through your first Dockerfile, container run, and image push in 30 minutes. No theory dumps — just the commands and what each one is doing.
By the end of this post you'll have built a container image from a tiny Python web app, run it locally, and pushed it to Docker Hub. You'll understand what each command actually does — not just the magic incantations.
You'll need Docker installed (Docker Desktop on Mac/Windows, or docker on Linux), a free Docker Hub account, and about 30 minutes.
A container is a process — a regular Linux process — running with extra kernel features that make it think it's alone on the machine. Its filesystem is isolated, its network is its own, its process tree starts fresh at PID 1. But it shares the host kernel; there's no second OS booting up.
That's why containers start in milliseconds while VMs take minutes. They're not little operating systems. They're regular processes with isolation features applied.
A "Docker image" is a packaged filesystem + metadata that tells Docker how to start the container. You build images from a Dockerfile. You run them as containers. You ship them by pushing to a registry.
That's the whole model. Let's run it.
docker --version
docker run hello-world
You should see Docker print version info, then pull and run the hello-world test image, which prints a friendly message confirming everything works. If this fails, fix Docker before moving on — nothing else will work.
Make a new directory and create two files:
mkdir docker-tutorial && cd docker-tutorial
app.py:
from http.server import BaseHTTPRequestHandler, HTTPServer
import os
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
self.send_response(200)
self.send_header("Content-Type", "text/plain")
self.end_headers()
self.wfile.write(f"Hello from {os.uname().nodename}\n".encode())
if __name__ == "__main__":
HTTPServer(("0.0.0.0", 8000), Handler).serve_forever()
That's a minimal HTTP server. No framework, no dependencies — pure stdlib. It listens on port 8000 and responds with the hostname.
Test it locally first:
python3 app.py &
curl http://localhost:8000
kill %1
You should see something like Hello from your-laptop-name. If that works, the app is fine — any issues from here are container-related, not code.
Create Dockerfile in the same directory:
FROM python:3.12-slim
WORKDIR /app
COPY app.py .
EXPOSE 8000
CMD ["python", "app.py"]
Each instruction:
FROM python:3.12-slim — start from an official Python image. slim means without extra tooling we don't need.WORKDIR /app — set the working directory inside the container. Subsequent commands run relative to here.COPY app.py . — copy the local app.py into /app/app.py inside the image.EXPOSE 8000 — document that the container listens on port 8000. (This doesn't actually publish the port — it's just metadata.)CMD ["python", "app.py"] — what to run when the container starts.That's a complete Dockerfile. No tricks.
docker build -t hello:1 .
The -t hello:1 tags the image as hello with tag 1. The . is the build context — the directory whose files Docker can copy into the image.
You'll see output like:
[+] Building 4.2s
=> [internal] load build definition from Dockerfile
=> [1/2] FROM docker.io/library/python:3.12-slim
=> [2/2] COPY app.py .
=> exporting to image
=> => writing image sha256:abc123...
=> => naming to docker.io/library/hello:1
Confirm:
docker images | head -3
You should see hello 1 abc123... 45 seconds ago 125MB.
docker run --rm -p 8000:8000 --name hello hello:1
Flag breakdown:
--rm — delete the container when it stops (avoids leaving dead containers around)-p 8000:8000 — publish container port 8000 to host port 8000 (now accessible from your laptop)--name hello — give it a friendly name we can refer tohello:1 — the image to runIn a second terminal:
curl http://localhost:8000
You should see Hello from <some random hex string>. That string is the container's hostname — different from your laptop, because the container has its own UTS namespace. That's isolation in action.
Stop it with Ctrl-C in the first terminal.
Sign up for a free Docker Hub account if you don't have one. Then:
docker login
docker tag hello:1 yourusername/hello:1
docker push yourusername/hello:1
Replace yourusername with your Docker Hub username. After the push completes, anyone (including you, on another machine) can pull and run it:
docker run --rm -p 8000:8000 yourusername/hello:1
That's the whole loop: build, run, ship. Everything else in Docker is variations and optimizations on those three steps.
Forgetting the port mapping. docker run hello:1 (without -p) starts the container but doesn't expose its port. curl localhost:8000 will fail. Always remember -p host:container.
Building with no tag. docker build . (without -t) creates an image with no name. You can run it by ID but it's annoying. Always tag.
Editing files inside the running container. Anything you do inside a container is gone when the container stops (unless you mounted a volume). Edit on the host, rebuild, re-run.
Using :latest tag for everything. latest is the default but unspecific. When you push v2, anyone running :latest silently gets the new version. Use real version tags (:1, :1.2.3, :abc123 for git SHAs) in production.
You've got the bones. The next levels:
docker runDocker isn't a complicated technology. Once you've shipped one image you've used most of the surface area. The rest is patterns.
Get the latest tutorials, guides, and insights on AI, DevOps, Cloud, and Infrastructure delivered directly to your inbox.
A working retrieval-augmented generation app you can run today. Markdown ingestion, embeddings, semantic search, and an LLM answer — start to finish in one afternoon.
Walk through a working GitHub Actions workflow — install, test, build, deploy — for a tiny Node app. Every line explained.
Explore more articles in this category
Run your first three Kubernetes objects — Pod, Deployment, Service — on a local cluster, then understand why each one exists and how they fit together.
Walk through a working GitHub Actions workflow — install, test, build, deploy — for a tiny Node app. Every line explained.
Three layers of pooling, three different jobs. We learned the hard way which to use when. Real numbers from a 8k-connection workload.