Every developer remembers their "it works on my machine" moment. You spend two days debugging a production issue that can't be reproduced locally — different OS, different Python version, a system library that's one minor version off. Someone suggests Docker. You nod, open a tab, and close it an hour later because every tutorial assumes you already understand what a container actually is.
This article doesn't do that. It starts at the real beginning — what Docker solves and why it exists — and ends with a working container running a real application. No hand-waving, no skipping the uncomfortable parts.
The Problem Docker Was Built to Solve
Software doesn't run in isolation. It runs on top of an operating system, a runtime (Node, Python, the JVM), system libraries, environment variables, and configuration files. When all of those line up correctly, your app works. When any one of them drifts — different between your laptop, your colleague's laptop, staging, and production — you get failures that are nearly impossible to diagnose quickly.
The traditional fix was virtual machines. Spin up a VM that mirrors production, run everything inside it. This worked, but VMs are heavy — each one runs a full operating system kernel, consumes gigabytes of RAM, and takes minutes to boot.
Docker took a different approach. Instead of virtualizing hardware, it virtualizes at the operating system level using Linux kernel features called namespaces and cgroups. Namespaces isolate what a process can see (its own filesystem, network, process tree). Cgroups control how much CPU and memory it can use. The result is a container: a lightweight, isolated environment that shares the host kernel but behaves like its own machine.
A container starts in milliseconds, uses megabytes instead of gigabytes, and can be moved between machines with a single command. That's the actual value proposition — not just "it works everywhere," but why it works everywhere.
Core Concepts Before You Write a Single Line
Understanding three concepts before touching the CLI will save you hours of confusion later.
Images vs. Containers
An image is a read-only template — a snapshot of a filesystem with your application code, dependencies, and configuration baked in. Think of it like a class definition in code.
A container is a running instance of that image. You can run five containers from the same image simultaneously, each isolated from the others. Stop a container and the image remains unchanged. This is why Docker is reproducible: you're always starting from the same template.
Layers
Docker images are built in layers. Every instruction in a Dockerfile adds a layer on top of the previous one. Layers are cached — if you change the last line of a Dockerfile, Docker reuses every cached layer above it and only rebuilds what changed.
This is why Dockerfile instruction order matters in production. More on this in a moment.
The Registry
Images live in registries. Docker Hub is the public default — it's where official images for Nginx, Postgres, Redis, Node, and Python live. You pull images from registries to your machine and push your own images up when you want to deploy or share them. Private registries (AWS ECR, Google Artifact Registry, GitHub Container Registry) work the same way, with access control.
Installing Docker and Understanding What You Just Installed
Docker Desktop handles installation on macOS and Windows — it includes the Docker daemon, CLI, and a lightweight Linux VM (since containers require a Linux kernel). On Linux, you install the Docker Engine directly.
After installation, run:
docker run hello-world
What just happened: Docker checked if the hello-world image exists locally, didn't find it, pulled it from Docker Hub, created a container from it, ran it, and printed output. The container exited when the process inside it finished. That sequence — pull, create, run, exit — is the fundamental Docker loop.
Check your running containers with docker ps, and all containers (including stopped ones) with docker ps -a. Clean up stopped containers with docker container prune.
Writing Your First Dockerfile
A Dockerfile is a recipe for building an image. Every line is an instruction. Here's a Dockerfile for a simple Python FastAPI application:
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
Breaking this down line by line:
FROM python:3.11-slim — Every Dockerfile starts with a base image. python:3.11-slim is the official Python image built on Debian with non-essential packages stripped out. Using slim instead of the full image cuts the final image size by several hundred megabytes. For production, alpine-based images go even smaller but sometimes cause issues with packages that need glibc.
WORKDIR /app — Sets the working directory inside the container. All subsequent commands run from here. Without this, you're working from the filesystem root.
COPY requirements.txt . followed by RUN pip install — Notice that dependencies are installed before the application code is copied. This is deliberate. Docker caches each layer. If you copy everything first and then install, any code change busts the dependency cache and forces a full reinstall. Copy and install dependencies first — they change infrequently. Copy application code last — it changes constantly. This one ordering decision can cut your rebuild times from minutes to seconds.
EXPOSE 8000 — Documents which port the container listens on. This is metadata; it doesn't actually publish the port. Publishing happens at runtime.
CMD — The default command that runs when the container starts. Note --host 0.0.0.0: this is critical. Without it, the server binds to localhost inside the container, which is unreachable from outside. Binding to 0.0.0.0 tells it to accept connections on all interfaces.
Building and Running the Image
Build the image from the directory containing your Dockerfile:
docker build -t my-api:v1 .
The -t flag tags the image with a name and version. The . tells Docker where the build context is — all files in that directory are available to the build process. This is why a .dockerignore file matters: exclude node_modules, .git, __pycache__, and any secrets or local config files before Docker sends the build context to the daemon.
Run it:
docker run -d -p 8000:8000 --name api-container my-api:v1
-d runs the container in detached mode (background). -p 8000:8000 maps port 8000 on your host to port 8000 inside the container. --name gives it a human-readable name instead of a random hash.
Your API is now accessible at http://localhost:8000. The application running inside the container has no idea it's in a container — it just sees its filesystem, its network interface, and its environment variables.
Environment Variables and Secrets
Hardcoded credentials in container images are a significant security risk. Images get pushed to registries, shared across teams, and occasionally exposed. Sensitive values should never be baked into an image.
Pass environment variables at runtime:
docker run -d -p 8000:8000 \
-e DATABASE_URL=postgresql://user:pass@host:5432/db \
-e SECRET_KEY=your-secret-key \
my-api:v1
For local development, an --env-file flag reads from a .env file (which is in your .gitignore and .dockerignore). In production, orchestration platforms like Kubernetes use Secrets objects, and cloud providers offer secret management services (AWS Secrets Manager, GCP Secret Manager) that inject values at runtime without them ever touching your filesystem.
The principle: the image is code. Secrets are configuration. They never merge.
Persistent Data with Volumes
Containers are ephemeral by design. Stop a container and restart it — any data written to the container's filesystem is gone. For databases, file uploads, and any state that should survive container restarts, you need volumes.
docker run -d \
-v postgres-data:/var/lib/postgresql/data \
-e POSTGRES_PASSWORD=secret \
postgres:15
postgres-data is a named volume managed by Docker. It lives on the host filesystem and is mounted into the container at the specified path. The container can be stopped, removed, and recreated — as long as the volume persists, the data persists.
For local development, bind mounts are often more useful: -v $(pwd):/app mounts your current directory into the container, so code changes appear immediately without rebuilding the image.
Docker Compose: Running Multi-Container Applications
Real applications aren't single containers. A typical stack might be an API container, a Postgres container, a Redis container, and an Nginx container. Orchestrating these manually with docker run commands is tedious and error-prone.
Docker Compose solves this with a declarative docker-compose.yml file:
version: "3.9"
services:
api:
build: .
ports:
- "8000:8000"
environment:
- DATABASE_URL=postgresql://postgres:secret@db:5432/app
depends_on:
- db
volumes:
- .:/app
db:
image: postgres:15
environment:
- POSTGRES_PASSWORD=secret
- POSTGRES_DB=app
volumes:
- postgres-data:/var/lib/postgresql/data
volumes:
postgres-data:
docker compose up starts the entire stack. docker compose down stops and removes the containers (volumes persist unless you add -v). Service names (db, api) become DNS hostnames on the internal Docker network — that's why the database URL references db as the host, not localhost.
This compose file is your development environment. A new engineer clones the repo, runs docker compose up, and has a fully working local stack in under two minutes — regardless of their OS or what they have installed. For teams building applications in environments like website development in pekin illinois, this kind of environment parity dramatically reduces onboarding friction and eliminates the "it worked last week" class of problems.
Deploying a Container to Production
Local containers and production containers are the same artifact — that's the point. Deployment is the process of getting your image to a registry and telling a server to run it.
Step 1: Push your image to a registry
docker tag my-api:v1 your-dockerhub-username/my-api:v1
docker push your-dockerhub-username/my-api:v1
For production, use a private registry tied to your cloud provider to avoid exposing images publicly and to keep pulls fast.
Step 2: Pull and run on the server
On a production VM or server (assuming Docker is installed):
docker pull your-dockerhub-username/my-api:v1
docker run -d \
-p 8000:8000 \
--restart unless-stopped \
--env-file /etc/app/.env \
your-dockerhub-username/my-api:v1
--restart unless-stopped tells Docker to restart the container automatically if it crashes or if the host reboots.
Step 3: Nginx as a reverse proxy
You don't expose your application container directly on port 80 or 443. Nginx sits in front, handles SSL termination, and proxies requests to your container. It also handles static file serving, rate limiting, and request buffering — things your application server shouldn't manage.
Run Nginx in its own container, mount your config and SSL certificates as volumes, and proxy traffic to the api container. Teams shipping production APIs — including those doing website development in north chicago illinois — commonly use this Nginx + containerized app pattern as a baseline before graduating to managed orchestration.
What Comes After This
A single server with Docker Compose running production workloads is where many projects live for longer than intended. It's more reliable than bare metal and more manageable than a mess of systemd services, but it has limits: no automatic horizontal scaling, no rolling deployments, no built-in health checks that move traffic away from unhealthy instances.
The next layer is Kubernetes for teams that need orchestration at scale, or managed container services like AWS ECS, Google Cloud Run, or Fly.io for teams that want container benefits without Kubernetes complexity. Cloud Run in particular has strong ergonomics for stateless API containers: push an image, set environment variables, and it handles scaling from zero to hundreds of instances automatically.
CI/CD integration is the other natural next step. Your pipeline should build the Docker image, run tests inside a container to guarantee environment parity, push the image to the registry, and trigger a deployment — all without manual steps. GitHub Actions, GitLab CI, and CircleCI all have first-class Docker support.
Common Mistakes and How to Avoid Them
Running as root inside the container. The default. Don't do it in production. Add a user to your Dockerfile and switch to it before the CMD instruction. If the container is compromised, a root process inside it has far more leverage against the host.
Not setting resource limits. A container with no memory limit can consume all host memory and bring down unrelated services. Set --memory and --cpus at runtime, or define resources.limits in Compose.
Storing secrets in environment variables baked into the image. If you set ENV SECRET_KEY=value in a Dockerfile, that value is visible to anyone who inspects the image. Use runtime injection, not build-time.
Ignoring the .dockerignore file. A build context that includes your entire node_modules, .git history, and local environment files is slow to send, bloats the image, and potentially leaks information. .dockerignore is not optional — treat it the same way you treat .gitignore.
Using latest as a production tag. Tags are mutable. latest today isn't latest tomorrow. Tag images with git commit SHAs or semantic versions so deployments are reproducible and rollbacks are a single command.
The Mental Model That Makes Everything Click
Docker is not magic. It's Linux kernel features wrapped in a good developer experience. Once you understand that a container is just a process with restricted visibility — it sees its own filesystem, its own network namespace, its own process tree — the behavior stops feeling mysterious.
Images are snapshots. Layers are a caching optimization. Registries are version-controlled storage for those snapshots. Compose is a declarative way to describe how multiple containers relate to each other. Everything else is either a deployment strategy or a convenience wrapper around these fundamentals.
Start with a working Dockerfile, get it running locally, add Compose for the full stack, then deploy. The complexity you'll encounter after that — Kubernetes, service meshes, distributed tracing — all builds on these same primitives. Get comfortable with them first and the rest becomes pattern recognition.
Docker's official documentation is genuinely good: docs.docker.com. For production hardening specifically, the Docker security guide is worth reading before you push anything to a public-facing server.
Top comments (0)