Introduction to Containers and Docker
What a container actually is, how it differs from a VM, and how to build, run and ship your first image with Docker before moving it to the cloud.
Most deployment problems come down to one sentence: it worked on my machine. The code was fine, but the server had a different Python version, a missing system library or a config file in another place. Containers fix this by shipping the application together with everything it needs to run, packaged in a format any compatible host can start the same way.
Docker made that idea practical and popular, and every major cloud now runs containers as a first-class workload. This article explains what a container is underneath, how it compares with the virtual machines covered in the previous series, and how to build, run and publish an image with Docker. It opens the Container Services series; the following articles cover the managed platforms on AWS, Google Cloud and Azure.
What a container is
A container is an ordinary Linux process that the kernel has been told to isolate. It isn’t a small VM, and there is no second operating system inside it. Two kernel features do most of the work:
- Namespaces give the process its own view of the system. With its own PID namespace it sees itself as process 1 and can’t see other processes. With its own network namespace it has its own interfaces, IP address and port space. Mount, UTS (hostname), IPC and user namespaces isolate the file system, hostname, shared memory and user IDs in the same way.
- Control groups (cgroups) limit and account for what the process can use: CPU, memory, I/O and the number of processes it may create.
On top of that, the container gets its own root file system, unpacked from an image, so the process sees the files the image author put there rather than the host’s /usr and /etc. Security features such as capabilities, seccomp profiles and AppArmor or SELinux policies narrow what the process is allowed to ask the kernel for.
That is the whole trick. Because a container is just a process, it starts in roughly the time the application itself takes to start, and it uses no memory beyond what the application uses. The trade-off is that every container on a host shares that host’s kernel.
Containers compared with virtual machines
A VM virtualizes hardware: a hypervisor presents virtual CPUs, memory and disks, and each VM boots its own full operating system. A container virtualizes the operating system: the host kernel is shared, and only the user space differs.
flowchart TB
subgraph VMs["Virtual machines"]
direction TB
A1["App A"] --> G1["Guest OS"]
A2["App B"] --> G2["Guest OS"]
G1 --> H["Hypervisor"]
G2 --> H
H --> HW1["Hardware"]
end
subgraph CTs["Containers"]
direction TB
C1["App A plus libraries"] --> R["Container runtime"]
C2["App B plus libraries"] --> R
R --> K["Shared host kernel"]
K --> HW2["Hardware or VM"]
end
| Virtual machine | Container | |
|---|---|---|
| What is isolated | Whole machine, own kernel | Process, shared kernel |
| Typical size | Gigabytes (full OS image) | Megabytes to a few hundred MB |
| Start time | Tens of seconds to minutes | Usually under a few seconds |
| Isolation strength | Strong, enforced by the hypervisor | Weaker, enforced by the kernel |
| OS flexibility | Any OS the hypervisor supports | Must match the host kernel (Linux on Linux) |
| Unit of deployment | Server | Application or service |
| Best for | Mixed operating systems, strict isolation, legacy software | Packaging and scaling applications and services |
In the cloud the two are layered rather than competing. Your containers almost always run on VMs, whether you manage those VMs yourself or the provider hides them, as with AWS Fargate or Cloud Run. If you read Comparing VM Offerings Across AWS, GCP, and Azure, the instance types you met there are the machines your container platforms will be scheduling onto.
The shared kernel is the point to remember for security. A kernel vulnerability can let a process escape its container, so multi-tenant platforms that run code from strangers add another layer: lightweight VMs such as Firecracker, or a user-space kernel such as gVisor, around each workload.
Why can't you run a Windows container natively on a Linux host?
Correct! A container is a process on the host kernel, so its binaries must target that kernel. Running another OS takes a VM.
Images, layers and registries
An image is a read-only template for a container: a stack of file system layers plus metadata such as the default command, environment variables and exposed ports. A container is a running (or stopped) instance of an image with a thin writable layer on top. Delete the container and that writable layer goes with it, which is why anything that must survive belongs in a volume or an external service.
Each layer records the changes made by one build step. Layers are content-addressed by a SHA-256 digest, so two images built on the same base share those base layers on disk and over the network. Pushing a new version of your app usually uploads only the top layer or two.
A registry stores and serves images. Docker Hub is the default public registry, and each cloud has its own: Amazon ECR, Google Artifact Registry and Azure Container Registry. An image reference names the registry, repository and tag:
us-central1-docker.pkg.dev/my-project/apps/web:1.4.2
registry repository tag
Tags are mutable labels; someone can push a different image under the same tag tomorrow. The digest (web@sha256:...) always identifies exactly one image, so production deployments often pin by digest.
None of this is Docker-specific any more. The Open Container Initiative (OCI) publishes the image format, the runtime specification and the distribution (registry) API. An image built with Docker runs on containerd, CRI-O or Podman, and any OCI-compliant registry can store it. That standard is why “a container image” can be the unit you hand to any of the cloud services later in this series.
How Docker is put together
“Docker” names a company, a product line and a set of tools, which can be confusing. On a Linux host, the pieces stack up like this:
flowchart LR
CLI["docker CLI"] -->|"REST API"| D["dockerd"]
D --> CD["containerd"]
CD --> SH["containerd-shim"]
SH --> RC["runc"]
RC --> P["Container process"]
D -->|"Pull and push"| REG["Registry"]
B["BuildKit"] --- D
- The
dockerCLI is a client. It sends API calls to the daemon, locally or over a socket to a remote host. dockerd, the Docker Engine daemon, manages images, networks and volumes and exposes the API.- containerd manages the container lifecycle and image storage. It is a CNCF project used directly by Kubernetes on most managed clusters, without
dockerdat all. - runc is the low-level OCI runtime that sets up namespaces and cgroups and starts the process.
- BuildKit is the build engine behind
docker buildanddocker buildx, with parallel steps, better caching and multi-platform builds.
Docker Engine is the open source daemon and CLI you install on a Linux server. Docker Desktop is an application for macOS, Windows and Linux that runs the engine inside a small managed VM and adds a GUI, Compose, Kubernetes and other tools. Docker Desktop is free for personal use, education, non-commercial open source and small businesses (fewer than 250 employees and less than US $10 million in annual revenue); larger organizations need a paid subscription. Check the Docker Desktop license agreement for the current terms.
On macOS and Windows, that hidden Linux VM is why containers there feel slightly slower for file-heavy work: bind-mounted files have to cross the VM boundary.
Building and running your first image
Here is a small Python web service and everything needed to containerize it. The application:
# app.py
import os
from flask import Flask
app = Flask(__name__)
@app.get("/")
def hello():
return {"message": "hello from a container", "host": os.uname().nodename}
@app.get("/healthz")
def health():
return "ok"
Its dependencies, in requirements.txt:
flask==3.0.3
gunicorn==23.0.0
And the Dockerfile, the recipe Docker follows to build the image:
FROM python:3.12-slim
# Don't buffer stdout, so logs appear immediately
ENV PYTHONUNBUFFERED=1
WORKDIR /app
# Install dependencies first so this layer is cached between code changes
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app.py .
# Run as an unprivileged user
RUN useradd --create-home appuser
USER appuser
EXPOSE 8080
CMD ["gunicorn", "--bind", "0.0.0.0:8080", "--workers", "2", "app:app"]
The order of instructions matters. Docker caches each layer and reuses it until something it depends on changes. Copying requirements.txt and installing packages before copying the code means a code-only change rebuilds in seconds, because the dependency layer is still valid. Copy the whole directory first and every edit reinstalls every package.
Add a .dockerignore so the build context doesn’t drag in things that don’t belong in the image:
.git
__pycache__/
*.pyc
.venv/
.env
Build, run and test it:
# Build and tag the image
docker build -t hello-web:1.0 .
# Run it in the background, mapping host port 8080 to container port 8080
docker run -d --name hello -p 8080:8080 --memory 256m --cpus 0.5 hello-web:1.0
curl http://localhost:8080/
docker ps # running containers
docker logs -f hello # follow stdout and stderr
docker exec -it hello sh # open a shell inside the running container
# Stop and remove it
docker stop hello && docker rm hello
--memory and --cpus are the cgroup limits from earlier, applied from the command line. A container without limits can use as much of the host as it likes, which is fine on a laptop and a problem on a shared server.
EXPOSE in the Dockerfile is documentation; it doesn’t open anything. The -p 8080:8080 flag is what publishes the port on the host.
A Dockerfile copies the whole project and then runs pip install. Every one-line code change triggers a full dependency reinstall. What is the usual fix?
Correct! Layers are cached until an input changes, so installing dependencies from requirements.txt first keeps that layer valid across code edits.
State, networking and Compose
A container’s own file system is disposable. For data that must outlive it, Docker offers two options:
- Volumes are storage managed by Docker (
docker volume create pgdata, then-v pgdata:/var/lib/postgresql/data). Use them for databases and other persistent data on a single host. - Bind mounts map a host directory into the container (
-v "$PWD":/app). They are handy in development for live code reloading and a poor fit for production.
Containers on the same user-defined network can reach each other by container name, through Docker’s built-in DNS. Running more than one container by hand gets tedious quickly, so Docker Compose describes them in a single file:
# compose.yaml
services:
web:
build: .
ports:
- "8080:8080"
environment:
DATABASE_URL: postgresql://app:app@db:5432/app
depends_on:
- db
db:
image: postgres:16
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: app
POSTGRES_DB: app
volumes:
- pgdata:/var/lib/postgresql/data
volumes:
pgdata:
docker compose up -d builds the web image, creates a network, starts both services and attaches the volume; docker compose down removes them again and keeps the volume. The web service connects to the database at the hostname db. The hard-coded password is fine for a laptop; in the cloud, credentials come from a secrets manager, never from the image or the Compose file.
Compose is a development and single-host tool. Once you need several hosts, rolling updates, self-healing and autoscaling, you need an orchestrator, which is where the rest of this series goes.
Habits that make images production-ready
A container that runs isn’t the same as one you want in production. A few habits pay off on every platform:
- Start from a small, maintained base image.
-slimand distroless images contain far fewer packages than full OS images, which means fewer vulnerabilities to patch and faster pulls. - Use multi-stage builds for compiled languages: build in one stage with the full toolchain, then copy only the binary or artifacts into a minimal final stage.
- Run as a non-root user, as the example does. Root inside a container is still a risk if anything goes wrong with isolation.
- Never bake secrets into an image. Anyone who can pull the image can read every layer, including files you deleted in a later step. Pass secrets at runtime from the platform’s secret store.
- Configure through environment variables so the same image runs in development, staging and production.
- Run one main process per container and log to stdout and stderr. The platform collects the logs and restarts the process if it dies.
- Tag images with versions or commit hashes, not only
latest, and deploy by digest where your platform supports it. - Scan images in your registry or pipeline. ECR, Artifact Registry and Azure Container Registry (through Microsoft Defender for Cloud) all offer vulnerability scanning.
- Build for the right CPU architecture. An image built on an Apple silicon Mac is
linux/arm64by default;docker buildx build --platform linux/amd64,linux/arm64produces one image that runs on both x86 and Arm hosts, such as Graviton, Axion or Cobalt instances.
Docker Hub also limits how many images anonymous and free users can pull in a period of time. Busy CI pipelines and clusters that pull public images often hit those limits, so authenticate, or mirror the images you depend on into your own cloud registry. The current numbers are on Docker’s pull usage and limits page.
A Dockerfile copies an API key into the image, uses it in a RUN step, then deletes it in the next step. Is the key safe?
Correct! Layers are immutable, so a file added in one layer and removed in a later one is still stored in the image. Pass secrets at runtime instead.
Running containers in the cloud
The workflow is the same on every provider: build an image, push it to a registry, and point a compute service at it.
flowchart LR
DEV["Dockerfile and code"] --> CI["Build in CI"]
CI -->|"docker push"| REG["Cloud registry"]
REG --> SL["Serverless containers"]
REG --> ORC["Managed orchestrator"]
REG --> VM["Docker on a VM"]
SL --> U["Users"]
ORC --> U
VM --> U
Pushing the example image to Google’s Artifact Registry looks like this (the AWS and Azure versions differ only in the login command and registry hostname):
gcloud artifacts repositories create apps \
--repository-format=docker --location=us-central1
gcloud auth configure-docker us-central1-docker.pkg.dev
docker tag hello-web:1.0 us-central1-docker.pkg.dev/my-project/apps/hello-web:1.0
docker push us-central1-docker.pkg.dev/my-project/apps/hello-web:1.0
From there, each cloud gives you several places to run it, from fully managed to fully hands-on:
| AWS | Google Cloud | Azure | |
|---|---|---|---|
| Registry | Amazon ECR | Artifact Registry | Azure Container Registry |
| Serverless containers | ECS on Fargate, ECS Express Mode | Cloud Run | Azure Container Apps, Azure Container Instances |
| Managed Kubernetes | Amazon EKS | Google Kubernetes Engine (GKE) | Azure Kubernetes Service (AKS) |
| Provider’s own orchestrator | Amazon ECS | None (Cloud Run fills that role) | None (Container Apps fills that role) |
| Do it yourself | Docker on EC2 | Docker on Compute Engine | Docker on Azure VMs |
How to choose comes down to how much of the platform you want to operate. Serverless containers are the shortest path from an image to a URL: you give the service an image and a port, and it handles servers, scaling and TLS. Kubernetes gives you the most control and portability, at the cost of a cluster to understand and maintain. Running Docker on a VM is fine for a single small service, but you take on patching, restarts and scaling yourself, the work Auto Scaling VMs on AWS, GCP, and Azure describes. In service-model terms from Cloud Service Models: IaaS, PaaS, SaaS, these options range from IaaS (Docker on a VM) to something very close to PaaS (Cloud Run, Container Apps, ECS Express Mode).
The next articles in this series look at each provider in turn, starting with Amazon ECS and EKS.
For further reading, the Docker documentation has a good getting-started guide, and the Open Container Initiative publishes the image and runtime specifications.
Key takeaways
- A container is an isolated Linux process: namespaces control what it can see, cgroups control what it can use, and it shares the host kernel.
- VMs virtualize hardware and boot their own OS; containers virtualize the OS and start as fast as the application. In the cloud, containers usually run on VMs.
- An image is a stack of cached, content-addressed layers. Order Dockerfile instructions so dependencies build before code, and pin production deployments by tag version or digest.
- Docker is a toolchain (CLI, dockerd, containerd, runc, BuildKit) built on open OCI standards, so images run on any compliant runtime and registry.
- Keep images small, run as non-root, configure with environment variables, and never put secrets in a layer.
- Every cloud follows the same pattern: push to a registry, then run on serverless containers, managed Kubernetes or your own VMs.