Docker: A Practical Guide for Developers

Images and containers, Dockerfiles that cache well, multi-stage builds, Compose for local development, volumes and networks, smaller images, and the production mistakes that cause outages.

writable layer CMD / ENTRYPOINT COPY . . RUN install deps FROM base image read-only layers, shared between containers IMAGE
1
Writable layer per container
3
Mount types: volume, bind, tmpfs
2
CMD forms: exec and shell
0
Ports EXPOSE actually publishes

Key Takeaways

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.

1
Changed line in a Dockerfile invalidates every layer after it
2
COPY steps that make builds fast: manifest first, source second
0
Bytes reclaimed by deleting a file in a later layer

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.

01

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.

inspecting what you have
# 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.

02

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.

the ten commands you will use daily
# 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.

03

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.

Dockerfile · Node.js service
# 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:

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.

04

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.

Slow

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.

Fast

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.

Dockerfile · system packages and cache mounts
# 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.

05

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.

Dockerfile · Go service, compiler discarded
# 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.

06

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 . ..

.dockerignore
.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.

07

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.

compose.yaml · API plus PostgreSQL
# 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.

everyday Compose commands
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.

08

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.

bind mounts, node_modules, and permissions
# 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.

09

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.

name resolution on a user-defined network
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:

  1. Inside a container, localhost is the container. Your API cannot reach the database at localhost:5432; it reaches it at db:5432. To reach a service running on your host machine, use host.docker.internal (available on Docker Desktop, and on Linux by adding --add-host=host.docker.internal:host-gateway).
  2. 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.
  3. Publishing a port can bypass a host firewall. Docker writes its own iptables rules on Linux, so -p 5432:5432 may be reachable from your whole network even with ufw enabled. Bind explicitly with -p 127.0.0.1:5432:5432 unless you intend otherwise.
  4. --network host removes 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.
10

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.

01

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.

Add USER; run rootless where you can
02

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.

Immutable tags, digests for bases
03

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.

Never bake a credential into a layer
04

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.

Use exec form; handle SIGTERM
05

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.

Containers are replaceable; data is not
06

No resource limits

Without --memory and --cpus, one leaking container can starve everything else on the host, including the daemon that would restart it.

Set limits; set them in Compose too
07

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.

stdout is the container log interface
08

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.

A stale image is a growing liability

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

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.

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:

1

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.

2

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.

3

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.

4

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.

5

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

The Bottom Line
Docker's value is not portability for its own sake. It is that the artifact you tested and the artifact you deploy are the same bytes, built the same way, every time.
PA
Our Take

Most Docker pain is really packaging pain that was always there.

The complaints about Docker tend to be complaints about the honesty it forces. An application that "just works" on a developer laptop is usually relying on a dozen unwritten facts: a system library installed years ago, an environment variable in a shell profile, a database running on the host, a specific interpreter patch version. A Dockerfile makes every one of those explicit, and the first attempt fails because the list was never written down. That is not the tool creating work; it is the tool surfacing work that was being paid for later, in onboarding time and deployment incidents.

The second thing worth saying plainly: containers are an operational abstraction, not a security one. They share the host kernel, and the isolation comes from namespaces and cgroups rather than a hypervisor. That is fine for your own code and a real problem for arbitrary user-submitted code. Teams that assume otherwise end up surprised. If the workload is untrusted, reach for a VM-backed sandbox and accept the extra milliseconds.

Where the tooling has genuinely improved is the build. BuildKit's cache mounts, build secrets, and multi-stage targets removed most of the old workarounds, and Compose's watch mode made container-based development feel close to running natively. If your mental model of Docker was formed before BuildKit became the default builder, a fresh look at the Dockerfile reference is a good use of an hour.

PA

About the Publisher

Precision AI Academy

Practitioner-focused AI education · tech news, guides, and 145 free courses

Precision AI Academy publishes deep-dives on applied AI engineering for working professionals. Founded by Bo Peng — Kaggle Top 200 data scientist and former university instructor.

Kaggle Top 200 Federal AI Practitioner 5 U.S. Cities Thu–Fri Cohorts