A Practical Intro to Docker for App Developers
If you've ever deployed a backend and watched it break on the server despite working perfectly on your laptop — wrong Python version, missing system library, an environment variable nobody documented — Docker was built for you. It packages your application together with everything it needs to run, so it behaves the same everywhere. App developers often postpone learning it because it looks like ops-team territory, but the core of Docker is a small set of ideas you can learn in an afternoon and use for a career. This is a plain-language introduction to what it is and how to actually use it.
The problem it solves
Software depends on its environment: a specific language version, particular libraries, system packages, configuration. Your machine has one setup; the server has another; your teammate's laptop has a third. That mismatch is the source of the eternal "but it works on my machine" — which isn't a excuse, it's a factual description of the problem: it does work on your machine, because your machine quietly provides things the server doesn't.
Docker fixes this by bundling your app and its entire environment into one portable unit. That unit runs identically on your laptop, a teammate's machine, a CI runner, and production — because it carries its environment with it instead of borrowing whatever the host happens to have. The deployment question changes from "does the server have everything my app needs?" (unknowable, changes over time) to "can the server run a container?" (yes).
It's worth a sentence on what Docker is not: it's not a virtual machine. Containers share the host's OS kernel and isolate at the process level, which is why they start in milliseconds and weigh megabytes rather than gigabytes. You can run a dozen containers on a modest laptop without noticing.
Two words you need: image and container
- An image is a blueprint — an immutable snapshot of your app plus everything it needs (runtime, dependencies, code, config). It's built once and doesn't change; versions of it are identified by tags.
- A container is a running instance of an image. You can start many containers from the same image, and each is isolated from the others and from the host.
The mental model programmers land on instantly: an image is like a class, a container is like an object created from it. You build an image, then run containers from it. Two corollaries follow. Anything a container writes inside itself disappears when the container is removed — persistent data (like a database's files) lives in volumes, storage that outlives any container. And containers don't expose their network by default — you map ports explicitly, which is what the -p 8000:8000 below does.
The Dockerfile
You describe how to build your image in a Dockerfile — a recipe of steps, each creating a cached layer. A simple one for a Python backend:
FROM python:3.12-slim # start from a base image with Python
WORKDIR /app # set the working directory
COPY requirements.txt . # copy dependency list first (for caching)
RUN pip install -r requirements.txt
COPY . . # copy the rest of the app
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
Build it into an image, then run a container from it:
docker build -t myapp .
docker run -p 8000:8000 myapp
That's the whole core loop: write a Dockerfile, build an image, run a container. Everything else in the Docker universe — registries, orchestrators, Compose — is infrastructure around this loop, and you can learn it as you need it rather than up front.
A few practices that matter
- Order layers for caching. Docker caches each step and reuses it if nothing that feeds it changed. Copying your dependency file and installing before copying the rest of the code means dependencies aren't reinstalled every time you touch app code — the difference between three-second and three-minute rebuilds, which compounds over every build you'll ever run. That's why the example above copies
requirements.txtalone first; it's the single most useful Dockerfile idiom. - Use small base images. Slim variants (
python:3.12-slimvs full) produce smaller, faster, more secure images — less software means less to download, less to update, and less attack surface. Compiled languages can go further: a Go binary in a minimal base image yields a production image measured in single-digit megabytes. - Don't bake in secrets. Never put passwords or API keys in the image — images get pushed to registries, pulled to machines, and inspected trivially (
docker historyshows every layer). Pass secrets as environment variables at run time, the same discipline as everywhere else. - Use
.dockerignore. Exclude junk —.git, local caches,node_modules, build artifacts — so images stay lean, builds stay fast, and nothing sensitive from your working directory sneaks into a layer by accident. - Pin versions.
FROM python:3.12-slimtoday andFROM python:latestare different promises. Pin your base images and dependencies so a rebuild next month produces the same image, not a surprise.
Running more than one service
Real apps often need several pieces — an API, a database, a cache — running together. Docker Compose lets you define them all in a single YAML file and start them with one command (docker compose up), wired to talk to each other on a private network, each service reachable by name.
The underrated payoff is local development: docker compose up gives every developer — including future-you on a new laptop — the entire stack, correct versions of Postgres and Valkey included, in one command. No "install Postgres 16, create this user, enable this extension" wiki page slowly going stale. The whole environment is a file in the repo, versioned like everything else.
Where it pays off
- Consistent environments across every developer and every stage — dev, CI, production all run the same image, so CI results actually predict production behavior.
- Easy onboarding — a new teammate runs one command instead of following a page of setup instructions with three outdated steps.
- Portable deployment — most modern hosting platforms take a container and run it, so you're not locked to one provider's runtime quirks; moving hosts becomes realistic instead of theoretical.
- Isolation — each app carries its own dependencies, so two services with conflicting requirements coexist peacefully on one machine.
The honest costs: some disk space (images accumulate — docker system prune is your friend), a mild learning curve around networking and volumes, and on Mac/Windows a virtualization layer with occasional file-syncing quirks. All modest, all worth it.
Summary
Docker packages your app with its whole environment into an immutable image, which you run as isolated containers that behave the same everywhere — ending "works on my machine" as a category of problem. Write a Dockerfile, order its steps for layer caching, keep base images small and pinned, pass secrets at run time rather than baking them in, and reach for Compose the moment you have more than one service. It's a modest amount to learn for a large gain in deployment confidence — and once your backend is a container, an entire ecosystem of hosting, CI, and orchestration options opens up on the same standard.
Further reading
- Docker: Getting started guide — the official hands-on tutorial.
- Dockerfile reference — every instruction, with the caching semantics.
- Docker Compose — multi-service apps and reproducible dev environments.
- Building a REST API with FastAPI — a backend worth containerizing.