Key Takeaways
- Image vs container: an image is read-only layers plus config; a container is one running instance with a thin writable layer on top.
- Build speed is an ordering problem: copy the dependency manifest, install, then copy source. One misplaced
COPYrebuilds everything after it. - Multi-stage builds let you compile with a full toolchain and ship only the artifact, often cutting image size by an order of magnitude.
- Anything you want to keep goes in a volume. The container's writable layer dies with
docker rm.
Docker packages an application and everything it needs to run into an image, then runs that image as an isolated process on any machine with a container runtime. An image is a read-only stack of filesystem layers plus a small JSON config recording the default command, environment variables, working directory, and user. A container is one instance of that image running as a process, with a thin writable layer added on top. You describe the image in a Dockerfile, build it with docker build, run it with docker run, and wire several services together for local development in a compose.yaml file.
That is the whole model. Everything else in this guide is detail layered on those five ideas: how the build cache decides what to rebuild, how multi-stage builds keep the compiler out of your shipped image, where your data actually lives, how containers find each other on a network, and which habits turn into 3 a.m. pages once the thing is in production.
Code here is written for Docker Engine with BuildKit, which has been the default builder since Docker Engine 23.0, and for Compose V2, invoked as docker compose with a space. The old Python docker-compose command and the version: key at the top of a Compose file are both retired; if a tutorial still uses them, it predates the current tooling.
Images vs Containers: What Is Actually on Disk
An image is a stack of read-only filesystem layers plus a JSON config. A container is that stack mounted through a union filesystem with one thin writable layer on top, running as a process in its own namespaces. Many containers can share one image; deleting a container removes only its writable layer.
Each instruction in a Dockerfile that changes the filesystem produces a layer, and each layer is a content-addressed tarball identified by a SHA-256 digest. Because layers are content-addressed, two images built from the same base share those base layers on disk and over the wire. Pulling a second image from the same base downloads only the difference.
When a container starts, the runtime mounts those layers read-only and adds one writable layer using copy-on-write. Modify a file that came from a lower layer and the whole file is first copied up into the writable layer, then edited. That single mechanic explains two things beginners find surprising: writing large files inside a container is slower than writing to a volume, and a database that stores its data in the container filesystem loses everything the moment you run docker rm.
# Images on this machine, with size and creation time
docker images
# Containers, including stopped ones (-a)
docker ps -a
# The layers of an image and the instruction that made each one
docker history myapp:1.0
# The config baked into the image
docker image inspect myapp:1.0 --format '{{json .Config}}'
# Where disk is going: images, containers, volumes, build cache
docker system df
Two words people use loosely
Repository vs tag. In ghcr.io/acme/api:1.4.2, ghcr.io/acme/api is the repository and 1.4.2 is the tag. A tag is a movable pointer, not an identity. The identity is the digest, written api@sha256:..., and it is the only reference that cannot be repointed under you.
Installing Docker and Running Your First Container
On macOS and Windows, install Docker Desktop (or an alternative such as Colima, OrbStack, Rancher Desktop, or Podman Desktop). On Linux, install Docker Engine from Docker's apt or dnf repository rather than your distribution's default package, which is often several releases behind.
Verify the install with docker version, which prints both a Client and a Server block. If the Server block is missing, the daemon is not running or your user is not in the docker group. On Linux, adding yourself to that group grants root-equivalent access to the host, so on a shared machine prefer rootless mode instead.
# Throwaway interactive shell; --rm deletes the container on exit
docker run --rm -it alpine:3.20 sh
# Detached web server, host port 8080 mapped to container port 80
docker run -d --name web -p 8080:80 nginx:1.27-alpine
docker ps # what is running
docker logs -f web # follow stdout/stderr
docker exec -it web sh # shell inside a running container
docker stop web # SIGTERM, then SIGKILL after 10s
docker rm web # delete the container + writable layer
docker build -t myapp:1.0 . # build from ./Dockerfile
docker run --rm -p 3000:3000 myapp:1.0
docker cp web:/etc/nginx/nginx.conf ./nginx.conf
Two flags earn their keep immediately. --rm stops stopped containers from piling up. -p HOST:CONTAINER is the only reason anything on your machine can reach the container at all; without it the port is reachable only from inside Docker's network. Note the order: -p 8080:80 means "host 8080 goes to container 80," and getting it backwards is the single most common first-hour mistake.
Be careful with prune
docker system prune -af --volumes reclaims disk by deleting every image not used by a running container, every stopped container, every unused network, the entire build cache, and every unused named volume. That last one has eaten local development databases. If you only want disk back, start with docker builder prune, which touches nothing but build cache.
How to Write a Good Dockerfile
A good Dockerfile pins a specific base image, installs dependencies before copying source, runs as a non-root user, and uses the exec form of CMD so the application receives signals directly. Most of it is convention; the ordering is the part that matters for build speed.
# syntax=docker/dockerfile:1
FROM node:22-alpine
WORKDIR /app
# Dependency manifest first: this layer is cached until package.json changes
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
# Application source last: edits here do not reinstall dependencies
COPY . .
ENV NODE_ENV=production
EXPOSE 3000
# The node image already ships a non-root 'node' user
USER node
# Exec form: node becomes PID 1 and receives SIGTERM directly
CMD ["node", "server.js"]
Instruction by instruction, the parts worth understanding:
# syntax=docker/dockerfile:1tells BuildKit to fetch the current stable Dockerfile frontend. It is what enablesRUN --mount,COPY --link, and heredocs.FROMsets the base. Pin a real version.node:22-alpineis reproducible enough for daily work;node:latestmeans your build changes under you without warning.WORKDIRcreates the directory if needed and sets it for every following instruction. Use it instead ofRUN cd /app, which does not persist between instructions.COPYvsADD: useCOPY.ADDadditionally auto-extracts local tarballs and can fetch remote URLs, both of which surprise people. If you need a remote file,ADDwith a checksum or aRUN curlis clearer about intent.RUNexecutes at build time and creates a layer.CMDexecutes at run time and creates none.EXPOSEis documentation. It publishes nothing. Ports reach the host only through-pat run time or aports:entry in Compose.USERdrops privileges for everything after it. Put it after your installs, since package managers usually need root.
ENTRYPOINT vs CMD, exec form vs shell form
ENTRYPOINT is the command that always runs; CMD supplies default arguments that a user can replace. With ENTRYPOINT ["python", "app.py"] and CMD ["--port", "8000"], running docker run img --port 9000 swaps the port and keeps the interpreter. If you set only CMD, any argument passed to docker run replaces the whole thing.
The form matters more than the choice. CMD node server.js (shell form) becomes /bin/sh -c "node server.js", so PID 1 is the shell, and most shells do not forward SIGTERM to their child. docker stop then waits out its grace period and kills the process, dropping in-flight requests. CMD ["node", "server.js"] (exec form) makes your process PID 1, so it receives the signal and can shut down cleanly.
How Docker Layer Caching Works
Docker caches each instruction. For RUN, the cache key is the literal text of the command. For COPY and ADD, it is a checksum of the files being copied. When any instruction misses the cache, every instruction after it rebuilds too. Ordering your Dockerfile from least-frequently-changed to most-frequently-changed is the whole optimization.
Source copied before install
COPY . . then RUN npm ci. Every edit to any file changes the COPY checksum, so the install re-runs from scratch on every single build.
Manifest copied before install
COPY package*.json ./, RUN npm ci, then COPY . .. The install layer is reused until a dependency actually changes.
The same rule applies to system packages, with one extra wrinkle. Splitting apt-get update and apt-get install into separate RUN instructions caches the package index, and a stale index causes installs to fail weeks later with "404 Not Found." Chain them in one instruction and clean up in the same layer.
# syntax=docker/dockerfile:1
FROM python:3.12-slim
# One RUN: update, install, clean. Cleaning in a later layer saves nothing.
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential libpq-dev \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY requirements.txt .
# Cache mount: pip's download cache persists across builds
# but is never written into a layer of the final image
RUN --mount=type=cache,target=/root/.cache/pip \
pip install -r requirements.txt
COPY . .
CMD ["python", "-m", "app"]
When a build behaves unexpectedly, two flags tell you why. --progress=plain prints full output instead of the collapsed view, and --no-cache forces a clean rebuild so you can confirm whether a stale layer was the problem. In CI, the build cache lives on the runner and usually disappears between jobs; buildx solves that with --cache-from and --cache-to pointing at a registry or your CI provider's cache backend. Our GitHub Actions CI/CD guide covers wiring that into a pipeline.
Multi-Stage Builds: Ship the Artifact, Not the Toolchain
A multi-stage build uses several FROM instructions in one Dockerfile. Early stages hold compilers, headers, and dev dependencies; the final stage copies only the built artifact out of them with COPY --from. Nothing from the discarded stages ends up in the shipped image, including any secret used during the build.
# syntax=docker/dockerfile:1
# ---- stage 1: build (has the full Go toolchain) ----
FROM golang:1.24 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /out/app ./cmd/server
# ---- stage 2: test (optional, built only with --target test) ----
FROM build AS test
RUN go test ./...
# ---- stage 3: runtime (no shell, no package manager, no compiler) ----
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /out/app /app
EXPOSE 8080
ENTRYPOINT ["/app"]
BuildKit builds only the stages the target depends on, so the test stage above costs nothing during a normal docker build . and runs when you ask for it with docker build --target test .. That gives you one file describing both the CI test image and the production image, with no drift between them.
The pattern generalizes. For a front-end app, stage one runs npm run build and stage two copies dist/ into an nginx image. For Python, stage one builds wheels into a virtualenv and stage two copies the virtualenv into a slim base without build-essential. For Java, stage one runs Maven or Gradle and stage two copies the jar into a JRE image.
Why this is also a security win
A runtime image with no shell, no package manager, and no compiler gives an attacker far less to work with after a remote code execution bug. It also removes hundreds of packages from your vulnerability report that were never part of your application in the first place.
How to Reduce Docker Image Size
Four levers, in order of payoff: pick a smaller base image, use a multi-stage build so the toolchain never ships, add a .dockerignore so the build context stays small, and delete package-manager caches in the same layer that created them.
Start by measuring. docker images gives totals and docker history IMAGE shows which instruction added the weight. The open-source dive tool walks layers interactively and flags files that were added and then deleted higher up.
| Base image style | Contains | Good fit for | Watch out for |
|---|---|---|---|
Full (e.g. node:22, python:3.12) |
Debian plus build tools, git, curl | Build stages, debugging | Largest by far; rarely right as a runtime |
Slim (-slim) |
Debian, glibc, minimal userland | Default runtime for Python, Node, Ruby | May need a few apt-get additions |
Alpine (-alpine) |
musl libc, BusyBox | Go, Rust, static binaries, small tools | Prebuilt Python wheels target glibc, so pip may compile from source |
| Distroless | glibc and your binary; no shell | Compiled languages, hardened runtimes | No docker exec sh; debug with a separate image |
scratch |
Nothing at all | Fully static single binaries | No CA certificates, no timezone data unless you copy them in |
A .dockerignore file is the cheapest win available. Everything in the build context is sent to the builder before the build starts, so a repository with a large .git directory or a local node_modules pays that cost on every build and risks copying secrets into the image through a careless COPY . ..
.git
.gitignore
node_modules
dist
build
coverage
*.log
.env
.env.*
.venv
__pycache__
Dockerfile
compose.yaml
README.md
Deleting a file in a later layer saves nothing
Layers are additive. If layer 3 adds a 400 MB archive and layer 6 removes it, layer 3 still contains it and anyone who pulls the image gets those bytes. The same logic is why a secret copied in and deleted later is still recoverable from the image, and why build-time secrets belong in RUN --mount=type=secret,id=token, which mounts a file only for the duration of that instruction and writes nothing to any layer.
Docker Compose for Local Development
Compose describes a set of containers, their networks, and their volumes in one compose.yaml file, then starts them together with docker compose up. It is the right tool for local development and small single-host deployments. It is not a cluster scheduler.
# No 'version:' key: it is obsolete in the Compose Specification
services:
api:
build:
context: .
target: dev
ports:
- "127.0.0.1:8000:8000"
environment:
DATABASE_URL: postgresql://app:localdevpassword@db:5432/app
depends_on:
db:
condition: service_healthy
develop:
watch:
- action: sync
path: ./src
target: /app/src
- action: rebuild
path: ./requirements.txt
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: localdevpassword
POSTGRES_DB: app
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app"]
interval: 5s
timeout: 3s
retries: 10
volumes:
pgdata:
Three details in that file do real work. depends_on with condition: service_healthy waits for the database to answer, not merely to start, which removes the usual race where the API boots first and crashes on connection refused. Binding the published port to 127.0.0.1 keeps the service off your local network. And the develop.watch block syncs source changes into the running container and rebuilds when the dependency file changes, which is faster and less fragile than bind-mounting the entire project directory.
docker compose up --build # build and start in the foreground
docker compose up -d # start detached
docker compose watch # start with sync/rebuild rules active
docker compose ps # service status and ports
docker compose logs -f api # follow one service
docker compose exec db psql -U app # shell into a running service
docker compose down # stop and remove containers + network
docker compose down -v # ...and delete named volumes (data loss)
Compose reads compose.yaml by default and still accepts the older docker-compose.yml name. Multiple files merge, so a base compose.yaml plus a compose.override.yaml lets teammates change ports or mounts without touching the shared file.
Volumes and Bind Mounts: Where Your Data Lives
Docker has three mount types. A named volume is managed by Docker and is the right choice for databases and anything you must not lose. A bind mount maps a host directory into the container and is the right choice for source code during development. A tmpfs mount lives in memory and never touches disk.
| Mount type | Syntax | Survives docker rm |
Use it for |
|---|---|---|---|
| Named volume | -v pgdata:/var/lib/postgresql/data |
Yes | Databases, uploads, caches you want to keep |
| Bind mount | -v "$PWD/src:/app/src" |
Yes (it is your host directory) | Live source code in development |
| tmpfs | --tmpfs /tmp |
No, it is RAM | Scratch files, decrypted secrets |
The classic bind-mount trap in JavaScript projects: you install dependencies during the build so /app/node_modules exists in the image, then bind-mount the whole project over /app at run time, which hides the installed modules behind your host directory. The container then fails with "module not found." Two fixes work. Add an anonymous volume that masks the subdirectory back out, or install dependencies to a path outside the mounted tree.
# The anonymous-volume fix: keep the image's node_modules visible
docker run --rm -p 3000:3000 \
-v "$PWD":/app \
-v /app/node_modules \
myapp:dev
# On Linux, run as your own UID so new files are not owned by root
docker run --rm -v "$PWD":/app --user "$(id -u):$(id -g)" myapp:dev
# Back up a named volume to a tarball on the host
docker run --rm \
-v pgdata:/data:ro \
-v "$PWD":/backup \
alpine:3.20 tar czf /backup/pgdata.tar.gz -C /data .
On macOS and Windows, containers run inside a Linux virtual machine, and bind mounts cross that boundary on every file operation. Mounting a very large tree with thousands of small files is the usual reason a containerized dev server feels slow. Mount only what changes, keep dependency directories inside the container, and consider Compose watch instead of a whole-project bind mount.
Docker Networking: How Containers Reach Each Other
Containers on a user-defined bridge network resolve each other by container name through Docker's embedded DNS server. Containers on the default bridge network do not get that name resolution. Compose creates a user-defined network for every project automatically, which is why services can address each other by service name.
docker network create appnet
docker run -d --name db --network appnet \
-e POSTGRES_PASSWORD=localdevpassword postgres:16-alpine
# 'db' resolves because both containers are on appnet
docker run --rm --network appnet postgres:16-alpine \
pg_isready -h db -U postgres
docker network inspect appnet
Four rules cover most of the confusion:
- Inside a container,
localhostis the container. Your API cannot reach the database atlocalhost:5432; it reaches it atdb:5432. To reach a service running on your host machine, usehost.docker.internal(available on Docker Desktop, and on Linux by adding--add-host=host.docker.internal:host-gateway). - Containers talk to each other on container ports, not published ports. If Postgres is published as
15432:5432, other containers still connect on 5432. The published port is for your host only. - Publishing a port can bypass a host firewall. Docker writes its own iptables rules on Linux, so
-p 5432:5432may be reachable from your whole network even with ufw enabled. Bind explicitly with-p 127.0.0.1:5432:5432unless you intend otherwise. --network hostremoves network isolation and shares the host's stack directly. It sidesteps port mapping and is occasionally the right answer for latency-sensitive or multicast workloads, but it also means port conflicts and no isolation.
Common Docker Mistakes in Production
The failures that reach production are rarely exotic. They cluster into a short list: running as root, floating tags, secrets baked into layers, shell-form entrypoints that swallow SIGTERM, state stored in the container, no resource limits, and images that are never rebuilt.
Running as root
The default user is root, and a container escape or a mounted host path turns that into real damage. Create a user in the Dockerfile and switch to it with USER after the installs.
Deploying :latest
A floating tag means the image you tested and the image that deploys can differ. Tag with the git SHA in CI and, for base images, pin the digest.
Secrets in the image
ARG values and copied key files stay in the layer history and travel with every pull. Pass secrets at run time, or mount them during build with RUN --mount=type=secret.
Shell-form CMD
Wrapping the process in /bin/sh -c means SIGTERM never reaches your code, so every deploy kills in-flight requests after the grace period.
State in the writable layer
Uploads, SQLite files, and session data written inside the container vanish on the next deploy. Mount a volume or use external storage.
No resource limits
Without --memory and --cpus, one leaking container can starve everything else on the host, including the daemon that would restart it.
Logging to files
Log to stdout and stderr and let the platform collect it. Files inside a container are invisible to your log pipeline and fill the host disk quietly.
Never rebuilding
Vulnerabilities accumulate in the base image even when your code has not changed. Rebuild on a schedule and scan with docker scout cves or an equivalent.
One more that costs an afternoon the first time it happens: architecture mismatch. Building on an Apple Silicon laptop produces an arm64 image, and pushing it to an amd64 server yields "exec format error." Build for the target explicitly with docker build --platform linux/amd64, or produce a multi-architecture image with docker buildx build --platform linux/amd64,linux/arm64 --push.
A minimal production checklist
- Pinned base image, rebuilt on a schedule, scanned in CI
- Multi-stage build with no compiler or package manager in the runtime stage
USERset to a non-root account; read-only root filesystem where the app allows it- Exec-form
ENTRYPOINT, a SIGTERM handler, and a graceful shutdown period longer than your slowest request HEALTHCHECKor a platform-level readiness probe that checks a real dependency- Memory and CPU limits set, with the runtime configured to respect them
- All state in volumes or managed services; nothing in the writable layer
Docker vs Podman vs containerd vs Kubernetes
Docker builds and runs containers on one machine. Podman does the same thing without a background daemon and rootless by default. containerd is the low-level runtime underneath both Docker and most Kubernetes clusters. Kubernetes is not an alternative to Docker at all; it schedules the images Docker builds across a fleet.
| Tool | What it does | Strengths | Trade-offs |
|---|---|---|---|
| Docker Engine + CLI | Build, run, and manage containers on one host | Largest tooling and documentation base; Compose is mature | Background daemon runs as root by default |
| Podman | Same job, daemonless and rootless by default | Near-identical CLI; no root daemon; systemd integration | Compose support works through a Docker-compatible socket and lags at the edges |
| containerd + nerdctl | Low-level runtime plus a Docker-like CLI | What Kubernetes actually runs; minimal surface | Fewer conveniences; not aimed at everyday development |
| Buildah / Kaniko | Build images only, often without a daemon | Useful for building inside restricted CI environments | No runtime; you still need something to run the image |
| Kubernetes | Schedules containers across many machines | Rollouts, self-healing, autoscaling, service discovery | Substantial operational cost; overkill for one or two services |
On macOS, Docker Desktop is the default but not the only option. Colima, OrbStack, Rancher Desktop, and Podman Desktop all provide a Linux VM and a Docker-compatible socket. That choice matters commercially as well as technically: Docker's subscription terms require a paid plan for Docker Desktop in organizations above the employee and revenue thresholds Docker publishes, while Docker Engine on Linux and the open-source alternatives are free. Check the current terms before rolling Desktop out across a team.
Because they all produce and consume OCI images, the artifact is portable. An image built with docker build runs under Podman, under containerd, and on Kubernetes without modification. If Kubernetes is where you are heading, the Kubernetes guide picks up exactly where this one stops.
When Not to Use Docker
Docker earns its cost when an application has real dependencies, multiple services, or a deployment target that differs from the development machine. When none of those hold, it adds a build step and a debugging layer for no return.
- Static sites. If the output is HTML, CSS, and JavaScript, a CDN or static host is simpler and faster than shipping nginx in a container.
- Single-language projects with a good version manager. For a solo Python or Node project, a virtualenv or a version manager starts faster and debugs more easily than a container.
- Latency-sensitive local file work on macOS or Windows. Bind mounts cross a VM boundary; a large monorepo watched by a dev server can be noticeably slower inside a container than outside it.
- Untrusted multi-tenant code. Containers share the host kernel, so a container is an isolation boundary, not a security boundary equal to a VM. For running code you do not trust, use a hypervisor-backed sandbox such as Firecracker or Kata Containers, or a separate VM.
- GPU and driver-coupled workloads, which run fine in containers but require matching host drivers and a container toolkit; the setup cost is real and worth budgeting for.
The value of a container is not that it runs anywhere. It is that the thing you tested and the thing that ships are the same bytes.
A Two-Hour Practice Path
Reading Dockerfiles teaches syntax; the model only clicks when a build breaks and you have to reason about why. A tight sequence that produces that understanding, using an application you already have:
Containerize it badly on purpose
Write the naive Dockerfile with COPY . . before the install. Build it twice, changing one source file in between, and watch the dependency install run again.
Fix the ordering and measure
Move the manifest copy above the install, add a .dockerignore, rebuild, and compare both wall-clock time and the output of docker images.
Convert it to multi-stage
Split build from runtime, drop to a slim or distroless base, and check docker history to confirm the toolchain is gone.
Add a database with Compose
Write a compose.yaml with a healthcheck and a named volume. Run docker compose down, bring it back up, and confirm the data survived. Then try down -v and watch it not survive.
Break the signal handling
Switch CMD to shell form, run docker stop, and time it. Switch back to exec form and time it again. The difference is the ten-second grace period, and it is the clearest demonstration of PID 1 you will get.
The bottom line: Docker is a small model with a long tail of detail. Learn the difference between an image and a container, order your Dockerfile so the cache works for you, use multi-stage builds so the compiler never ships, put every byte you care about in a volume, and run as a non-root user with the exec form of CMD. Those five habits cover most of what separates a container that works on a laptop from one that behaves in production.
Frequently Asked Questions
What is the difference between a Docker image and a container?
An image is a read-only stack of filesystem layers plus a JSON config recording the default command, environment variables, working directory, and exposed ports. A container is one instance of that image running as an isolated process, with a thin writable layer added on top. Many containers can run from the same image and they all share the same read-only layers on disk. When you delete a container with docker rm, only its writable layer disappears; the image is untouched. That is why anything you want to keep must live in a volume rather than inside the container.
Why is my Docker build so slow?
Almost always cache invalidation order. Docker caches each instruction, and for COPY it keys the cache on the contents of the files copied. If you copy the whole source tree before installing dependencies, every source edit invalidates that layer and every layer after it, including the install. Copy the dependency manifest first, install, then copy the rest. The second common cause is a large build context, fixed with a .dockerignore that excludes .git, node_modules, build output, and local environment files. Third, use BuildKit cache mounts so the package manager's own cache survives between builds.
Do I need Kubernetes if I already use Docker?
No. Docker builds and runs containers on one machine; Kubernetes schedules containers across many machines and handles rollout, restart, service discovery, and scaling. For one or two services on a single host, Compose or a managed container service is simpler and cheaper to operate. Kubernetes earns its complexity with many services, several teams deploying independently, or hard autoscaling and zero-downtime requirements. The image you build with Docker runs unchanged on Kubernetes, so moving later costs orchestration work rather than a rewrite.
Should I use Alpine base images to make my image smaller?
Sometimes. Alpine is small because it uses musl libc and BusyBox rather than glibc and GNU coreutils. For Go or Rust producing a static binary, Alpine or even scratch is a good fit. For Python it often backfires: prebuilt manylinux wheels target glibc, so on Alpine pip falls back to compiling from source, which makes builds much slower and occasionally fails. The -slim Debian variants are usually the better default for interpreted languages. Measure with docker images before assuming Alpine wins.
How do I get data out of a container after it stops?
If the container still exists, docker cp copies files out of its filesystem and docker logs prints whatever the process wrote to stdout and stderr. If it was started with --rm, both are gone the moment it exits. The durable answer is to mount a named volume or bind mount at the path your application writes to, so the data outlives the container. Named volumes survive docker rm and docker compose down; they are removed only when you ask, with docker volume rm or docker compose down -v.
References: Dockerfile reference, Docker build cache documentation, the Compose Specification
Explore More Guides
- Docker for Beginners: Containers Without the Jargon
- Docker and Containers: From Zero to Production
- Kubernetes: Complete Beginner to Production Guide
- CI/CD Pipeline Guide: Build, Test, Deploy
- Terraform and Infrastructure as Code
- Microservices Architecture Guide
- FastAPI: Building Production APIs with Python
- Linux Commands Cheat Sheet