File System & Layer #

Almost everyone who uses Docker knows two things: images are immutable, and containers are ephemeral. But very few truly understand why those two properties exist. The answer lies in one place — the layered filesystem Docker uses beneath the surface.

Without understanding the layered filesystem, you’ll keep writing Dockerfiles with bloated images, slow builds, and inconsistent deployments. With it, you can write lean Dockerfiles, fast builds, and efficient storage in production.

This article breaks down the foundation of Docker storage from the bottom up: union filesystems, image layers, the container writable layer, copy-on-write, and how they all work together. The goal isn’t for you to memorize terms, but to give you the correct mental model of what actually happens every time you run docker build or docker run.

Why Understanding Docker Storage Matters #

Containers are often described as “lightweight virtual machines”, but that analogy is misleading. A container is not a mini VM. It doesn’t carry its own filesystem, has no disk image, and does no disk provisioning on start. What it does is bind many read-only filesystem layers into a single mount point, then add one writable layer on top at runtime.

flowchart TB
    subgraph IMG["Image — Read-Only Layers"]
        L1[Base Image Layer<br/>ubuntu:22.04 — 77 MB]
        L2[apt install nginx — 50 MB]
        L3[COPY config — 0.1 MB]
        L4[CMD nginx — 0 MB]
        L1 --> L2 --> L3 --> L4
    end
    L4 --> UNION[Union Mount<br/>filesystem seen by the container]
    UNION --> RUNTIME[Container Writable Layer<br/>runtime changes — top layer]

This structure has three big consequences you’ll encounter throughout this storage series:

  • Small images, high cache rates. The same layers are shared by many images. Incremental builds only download changed layers.
  • Fast container startup. No filesystem copy at start. Only one empty writable layer is added.
  • Containers are easy to lose. The writable layer is the only place runtime changes live — and it dies with the container.

These three consequences drive every other storage decision you’ll learn in the coming articles: why volumes are needed, why data must be separated, and why backups focus on data, not containers.

What Is the Docker Filesystem? #

Docker doesn’t create a new filesystem from scratch for every image. Docker leverages mature Linux filesystems to do two things:

  • Union mounts — merge many directories into a single view.
  • Copy-on-write (CoW) — duplicate blocks only when something changes.

The combination of the two is the core of Docker’s entire storage system. It makes images small, containers fast to start, and lets many containers share the same layers without duplication on disk.

flowchart LR
    A[Image Layer A] --> U[Union View]
    B[Image Layer B] --> U
    C[Image Layer C] --> U
    U --> CON[Container sees<br/>one complete filesystem]

This filesystem is what makes Docker images immutable (unchangeable) and containers ephemeral (temporary). These two properties aren’t add-on features — they’re the direct consequence of how layers are arranged.

Interesting detail: The union filesystem is one of the technologies that made Docker genuinely practical. Without it, every code change would require rebuilding the image from scratch and re-downloading all dependencies. With layering, incremental Docker builds usually take just seconds.

Types of Storage Drivers #

Docker doesn’t mandate a single union filesystem implementation. It provides a storage driver abstraction that can pick an implementation based on the OS and host conditions.

Storage DriverStatusUse Case
overlay2Default & recommendedModern Linux (kernel 4.0+)
btrfsSupportedBtrfs host filesystem
zfsSupportedZFS host filesystem
fuse-overlayfsSupportedRootless containers
vfsFallbackNo CoW (testing only)
aufsLegacyNot recommended for new installs
devicemapperDeprecatedOld RHEL, CentOS 7

overlay2 is the default on all modern Linux distributions and Docker’s recommended choice. It’s stable, fast, memory-efficient, and fully supported by the community. Unless you have a specific reason, use overlay2.

Check which driver your host uses:

docker info | grep "Storage Driver"

Expected output:

Storage Driver: overlay2
Avoid legacy drivers. aufs is no longer developed and only exists on old kernels. devicemapper is deprecated in modern Docker. If you find either on a production server, consider migrating to a host with overlay2 — legacy drivers can cause performance and stability issues that are hard to debug.

The Union Filesystem Concept #

A union filesystem is technology that lets several directories be mounted together at one path, so users (or containers) see them as a single complete filesystem.

A conceptual example:

Layer A:  /app/main.go
Layer B:  /app/config.yaml

Union View at /app:
  /app/main.go        ← from Layer A
  /app/config.yaml    ← from Layer B

Docker uses this concept to stack image layers and present them as one filesystem to the container. The container doesn’t know how many layers are behind it — all it sees is one complete directory tree.

How It Works in Overlay2 #

To understand the real implementation, look at the internal structure of overlay2:

flowchart TB
    subgraph HOST["Host Filesystem"]
        LOWER["/var/lib/docker/overlay2/.../diff<br/>LOWERDIR — read-only"]
        UPPER["/var/lib/docker/overlay2/.../diff<br/>UPPERDIR — writable, per container"]
        WORK["/var/lib/docker/overlay2/.../work<br/>WORKDIR — internal atomic ops"]
        MERGED["/var/lib/docker/overlay2/.../merged<br/>MERGED — view for the container"]
    end
    LOWER --> MERGED
    UPPER --> MERGED
    WORK -.-> UPPER
  • Lowerdir — the collection of image layers (read-only). Shared by many containers.
  • Upperdir — the writable layer owned by one container. This is where runtime changes are stored.
  • Workdir — an internal directory the kernel uses for atomic operations during copy-up.
  • Merged — the union result the container sees when reading paths.

The container never directly accesses lowerdir or upperdir. It only sees merged — one complete filesystem that’s the combination of both.


What Is a Layer in Docker? #

A layer is a read-only filesystem snapshot. Each layer represents one filesystem change — usually one Dockerfile instruction.

Example Dockerfile:

FROM alpine:3.19
RUN apk add --no-cache curl
COPY app.sh /app.sh
CMD ["sh", "/app.sh"]

The layers formed:

  1. Base layer — Alpine Linux 3.19 (~7 MB).
  2. RUN layer — the result of installing curl (~1 MB).
  3. COPY layer — the app.sh file (~0.01 MB).
  4. CMD layer — adds nothing to the filesystem (metadata instruction, not data).

Each layer is:

  • Immutable — can’t be changed once created. Layer content is hashed (SHA256), and that hash becomes the layer’s identity.
  • Reusable — can be shared by other images with identical instructions.
  • Cacheable — the Docker build cache keys layers by instruction hash + context. If unchanged, the layer is reused from cache.
flowchart TB
    A[FROM alpine:3.19] --> B[RUN apk add curl]
    B --> C[COPY app.sh /app.sh]
    C --> D[CMD app.sh]
    
    A -.->|Layer 1: base alpine| LA[alpine-base.tar]
    B -.->|Layer 2: apk result| LB[curl-install.tar]
    C -.->|Layer 3: app.sh| LC[app-sh.tar]
    D -.->|Metadata| LD[cmd-meta.json]

Layers and Dockerfiles #

The crucial principle to hold onto:

One Dockerfile instruction = one new layer (for instructions that change the filesystem).

InstructionCreates a Layer?Reason
FROMYesPulls the base image (multiple layers)
RUNYesExecutes a command producing filesystem changes
COPYYesAdds files to the image
ADDYesSame as COPY, plus tar extraction & URL features
CMDNoJust default command metadata
ENTRYPOINTNoJust executable metadata
ENVNoEnvironment variables, adds no files
WORKDIRNoOnly changes the working directory
EXPOSENoJust port metadata
LABELNoJust key-value metadata
ARGNoBuild-time variable
USERNoChanges the user context, adds no files
VOLUMENoDeclares a mount point

The immediate implication: instruction order in a Dockerfile heavily affects image size and build speed. Every RUN, COPY, and ADD adds a permanent layer to the final image.


Copy-on-Write — Docker’s Efficiency Key #

Docker uses copy-on-write (CoW) while containers run. CoW is an optimization: files are only copied when something tries to modify them, not when they’re read.

The flow:

  1. Container reads a file → directly from the image layer (lowerdir), no copy.
  2. Container modifies a file → the file is copied from lowerdir to upperdir (the writable layer), then modified there.
  3. The change only happens in upperdir — the image layer stays intact and usable by other containers.
flowchart TB
    subgraph READ["Container reads /etc/nginx.conf"]
        R1[Read request] --> R2{File exists<br/>in upperdir?}
        R2 -- No --> R3[Read directly<br/>from lowerdir]
        R2 -- Yes --> R4[Read from upperdir]
    end
    
    subgraph WRITE["Container writes /etc/nginx.conf"]
        W1[Write request] --> W2{File exists<br/>in upperdir?}
        W2 -- No --> W3[COPY-UP<br/>copy file to upperdir]
        W2 -- Yes --> W4[Modify in upperdir]
        W3 --> W4
    end

CoW’s Performance Benefits #

  • Fast reads — unchanged files are read directly from the image layer. No overhead.
  • Writes only when needed — containers that only read configuration never trigger a copy.
  • Images stay safe — runtime changes never touch the original image layers. Images are always reusable.
  • Containers are isolated — each container has its own upperdir, so changes in one container never affect another container from the same image.
Practical insight: this explains why containers start in seconds, not minutes. When docker run executes, Docker doesn’t copy the image filesystem. It only creates an empty upperdir and mounts the union. The copy work truly happens only when the application first writes a file — and even that is lazy.

The Container’s Writable Layer #

When a container is created from an image, Docker adds one writable layer on top of all the image layers. This layer is often called the container layer or runtime layer.

Its characteristics:

  • Unique per container — every container from the same image has its own upperdir.
  • Temporary — disappears when the container is deleted.
  • Not for permanent data — because of its temporary nature, storing important data here is an anti-pattern.
flowchart TB
    subgraph SHARED["Shared Image Layers (read-only)"]
        L1[Base]
        L2[RUN]
        L3[COPY]
    end
    
    subgraph C1["Container A"]
        L1 --> UA[Upperdir A<br/>writable]
        L2 --> UA
        L3 --> UA
    end
    
    subgraph C2["Container B"]
        L1 --> UB[Upperdir B<br/>writable]
        L2 --> UB
        L3 --> UB
    end
    
    SHARED --> C1
    SHARED --> C2

This explains many Docker behaviors that often confuse people:

  • Why can docker commit create a new image? Because docker commit freezes the upperdir into a new image layer. The upperdir’s contents become a layer in the new image.
  • Why is MySQL data lost when the container is deleted? Because /var/lib/mysql lives in the upperdir, and the upperdir disappears with the container.
  • Why is docker run from the same image so fast? Because image layers aren’t copied. Only an empty upperdir is created.

This is why volumes and bind mounts are so important in Docker — they move data from the upperdir to the host filesystem, so the data survives even if the container is destroyed.

Critical anti-pattern: Never store important data in the container’s writable layer. Databases, user file uploads, application logs, and runtime configuration must always live in a volume or bind mount. The writable layer disappears when the container is deleted, restarted with a new image, or recreated by an orchestrator. This is the most common source of data loss in Docker.

Images Are Immutable #

Because image layers are read-only, an image can’t be changed after build. Every change produces a new image. That might sound like a limitation, but it’s actually one of Docker’s greatest strengths.

The benefits of immutability:

  • Reproducible builds — the same image always produces the same container. No mysterious “configuration drift”.
  • Easy rollback — if a new image has problems, just go back to the old tag. No uninstalling or reconfiguring.
  • Consistent across environments — the same image runs identically on developer laptops, CI, staging, and production.
  • Audit-friendly — image content can be inspected (e.g. with docker history) and hashed for integrity verification.
  • Cache friendly — the same image layers are shared, so deployments don’t download images from scratch.

This concept is the foundation of several important industry workflows:

  • CI/CD — Docker images become the same build artifact from pipeline to production.
  • GitOps — image tags are references that can be versioned in Git (e.g. :v1.2.3).
  • Immutable infrastructure — running servers (or containers) aren’t patched in place, but replaced with new instances from new images.
# View an image's layer history
docker history nginx:1.25

# Output:
# IMAGE          CREATED       CREATED BY                                      SIZE
# a4d8d7c4b1f0   3 weeks ago   CMD ["nginx" "-g" "daemon off;"]              0B
# e9b1f5c0a2c3   3 weeks ago   EXPOSE 80                                       0B
# ...

Layer Sharing and Storage Efficiency #

Docker stores layers by content hash (SHA256). If two images have a layer with identical content, that layer is stored only once on disk and shared.

Example impact:

ScenarioWithout SharingWith Layer Sharing
10 images based on alpine:3.1910 × 7 MB = 70 MB7 MB (1 shared layer)
5 images with identical apt install curl5 × 1 MB = 5 MB1 MB (1 shared layer)
50 microservices with the same base image50 × base size1 × base size

This is why Docker saves storage at scale. Imagine you have 50 microservices in the registry, all based on node:20-alpine. Without sharing, the base layer is downloaded and stored 50 times. With sharing, only once.

flowchart LR
    subgraph REG["Docker Host Disk"]
        B[alpine-base.tar<br/>7 MB]
        C[curl-install.tar<br/>1 MB]
    end
    
    IMG1[Image: app1] --> B
    IMG1 --> C
    IMG2[Image: app2] --> B
    IMG2 --> C
    IMG3[Image: app3] --> B
    IMG3 --> C
    
    B -.one copy.-> REG
    C -.one copy.-> REG

Practical implications:

  • Faster image pulls — Docker only downloads layers not already on the host.
  • Faster pushes to the registry — layers already in the registry don’t need re-uploading.
  • More efficient host storage — shared layers aren’t duplicated.
Optimization trick: when writing a Dockerfile, deliberately create layers that can be shared between images. For example, separate system package installation (rarely changes) from COPY of source code (changes often). This keeps the system package layer cached for a long time, and only the COPY layer gets invalidated on builds.

How Layers Affect Build Performance #

The Docker build cache works on layer content. The cache key for each layer is the hash of:

  1. The Dockerfile instruction’s content.
  2. The parent layer’s hash.
  3. For COPY/ADD: the hash of the copied file contents.

If all of that matches a previous build, the layer is used from cache. If any part changes, the layer is rebuilt — and every layer below it is rebuilt too.

Anti-Pattern: Copying Source Code Too Early #

# ANTI-PATTERN: source code copied before dependencies
FROM node:20-alpine
WORKDIR /app
COPY . .                  # ← a 1-line code change invalidates all cache
RUN npm install           # ← npm install runs again on every build
CMD ["node", "index.js"]

The problem: every time you change one line of code in the source, Docker invalidates the cache from the COPY . . line down. That includes npm install, which usually takes a long time.

The Solution: Separate Dependencies from Source Code #

# CORRECT: install dependencies first, source code last
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./     # ← only invalidated when package.json changes
RUN npm install           # ← cached as long as package.json doesn't change
COPY . .                  # ← invalidated whenever source code changes
CMD ["node", "index.js"]

Result: source code changes don’t re-run npm install. Incremental builds usually take just 1–2 seconds.

flowchart LR
    subgraph BAD["Anti-Pattern"]
        B1[COPY . .] --> B2[RUN npm install]
        B2 --> B3[code change<br/>→ npm install reruns]
    end
    
    subgraph GOOD["Best Practice"]
        G1[COPY package.json] --> G2[RUN npm install]
        G2 --> G3[COPY . .]
        G3 --> G4[code change<br/>→ npm install cached]
    end

Extra Build Cache Tips #

Use BuildKit (default in modern Docker) for advanced caching features:

  • Cache mounts — mount a cache directory into the build (--mount=type=cache). The cache persists across builds.
  • Registry build cache — push/pull build cache from Docker Hub or ECR for CI running on fresh machines.
  • Multi-stage builds — separate build artifacts from the runtime image. The runtime image doesn’t carry the heavy toolchain.

Enable BuildKit:

# Via environment variable
export DOCKER_BUILDKIT=1

# Or in the Dockerfile (modern syntax)
# syntax=docker/dockerfile:1

Mounts You Should Know About #

Docker also provides three types of mounts you can add when a container runs. This matters because the layer filesystem is the foundation, but volumes and bind mounts are the layer on top for persistent data.

flowchart TB
    subgraph TYPES["Mount Types in Docker"]
        V[Volume<br/>managed by Docker]
        B[Bind Mount<br/>manual host path]
        T[tmpfs<br/>stored in RAM]
    end
    
    V --> PERSIST[Persistent data<br/>for production]
    B --> DEV[Live reload<br/>for development]
    T --> SECRET[Sensitive data<br/>non-persistent]

These three mount types will be covered in depth in later articles in this section. For now, just understand that all three operate on top of the layered filesystem we’ve discussed — they don’t replace image layers, but add data persistence outside them.

Mount TypePhysical LocationPersistent?Main Use Case
Volume/var/lib/docker/volumes/...Yes (managed by Docker)Databases, uploads, production data
Bind MountAny host path you specifyYes (depends on the host)Source code for live reload, config
tmpfsRAMNoSecrets, sessions, sensitive caches

Filesystem and Layer Best Practices #

These principles summarize the lessons of the whole article.

1. Minimize the Number of Layers #

Combine related commands into one RUN:

# CORRECT: one layer for install + cleanup
RUN apt-get update && \
    apt-get install -y curl nginx && \
    rm -rf /var/lib/apt/lists/*
# ANTI-PATTERN: many small layers
RUN apt-get update
RUN apt-get install -y curl
RUN apt-get install -y nginx
RUN rm -rf /var/lib/apt/lists/*

The first version makes 1 layer; the second makes 4. The first also removes the apt cache in the same layer (important for image size), while the second leaves it in a previous layer.

2. Order Instructions Correctly #

Put what changes least often on top, what changes most often at the bottom:

  1. Base image (changes least often).
  2. System package installs (rarely change).
  3. Package-manager dependency installs (change when dependencies update).
  4. COPY of source code (changes most often).
  5. CMD/ENTRYPOINT (metadata, adds no layers).

3. Use Multi-Stage Builds #

For languages that need a toolchain at build time (Go, Rust, Java, TypeScript), separate the build environment from the runtime:

# Stage 1: build
FROM golang:1.22 AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o myapp

# Stage 2: runtime
FROM alpine:3.19
COPY --from=builder /app/myapp /myapp
CMD ["/myapp"]

Result: the runtime image is only 10–20 MB, not the 800 MB of the build image.

4. Pick the Smallest Base Image Possible #

Base ImageSizeUse Case
scratch0 MBStatic binaries (Go, Rust)
alpine5–7 MBLightweight apps, needs a package manager
distroless10–20 MBProduction Java/Node/Go, no shell
debian-slim30–50 MBNeeds libc but wants smaller than full
ubuntu/debian70–120 MBDefault, but bigger than necessary

For production, alpine or distroless are the best choices. For development, ubuntu or debian are more familiar.

5. Understand Layer Caching Before Optimizing #

Don’t blindly combine layers. Every decision is a trade-off — between cache hits and image size, between layer count and Dockerfile cleanliness. Understand the implications:

  • Too many layers → large images.
  • Too few layers → frequent cache invalidation.
  • The sweet spot → separate rarely-changing content (long cache) from frequently-changing content (short cache).

6. Clean Up Artifacts in the Same Layer #

Remove package manager caches and temporary files in the same RUN as the installation:

# CORRECT: remove in the same layer
RUN apt-get update && \
    apt-get install -y --no-install-recommends curl && \
    rm -rf /var/lib/apt/lists/*

# DON'T: remove in a separate layer (the files still exist in the previous layer)
RUN apt-get update
RUN apt-get install -y curl
RUN rm -rf /var/lib/apt/lists/*   # ← this does NOT reduce image size!
Common mistake: deleting files in a separate RUN doesn’t reduce image size. Docker layers are immutable — files added in a previous layer stay there. To truly delete, do it in the same RUN that creates the files.

Summary #

  • A Docker image = a collection of read-only layers merged via a union filesystem. Containers add one writable layer on top at runtime. This explains why images are small, containers start fast, and container data is lost on deletion.
  • The default storage driver is overlay2 for modern Linux. Avoid legacy drivers like aufs and devicemapper unless you have a specific reason.
  • Copy-on-write (CoW) is the key mechanism: files are only copied to the writable layer when they change. Reads go straight to the image layer; writes trigger a copy-up.
  • Images are immutable because layers are read-only. Every change produces a new image. That’s a feature, not a limitation — it supports reproducible builds, rollback, and environment consistency.
  • Layer sharing is based on content hashes. Images with identical layers share disk storage. That’s why Docker is space-efficient when you have many images from the same base.
  • The build cache works per layer. Dockerfile order matters a lot — put rarely-changing content on top, frequently-changing content at the bottom. Separate COPY package.json from COPY . . for effective npm install caching.
  • Multi-stage builds separate the build environment from the runtime image, producing small production images without sacrificing the development toolchain.
  • Don’t store important data in the container’s writable layer. The writable layer disappears when the container is deleted. Use volumes or bind mounts — the topic of the next articles in this section.

← Previous: How It Works   Next: Ephemeral Container →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact