Docker · 45 min

Persist Data with Docker Volumes

Stop losing data when a container is removed. Learn Docker's three storage types — named volumes, bind mounts and tmpfs — by persisting a real PostgreSQL database, inspecting a volume, editing files live through a bind mount, and using volumes in Compose.

Problem

You run a database in a container, insert important rows, then remove the container to upgrade it — and every row is gone. This isn't a bug: a container's writable layer is **ephemeral**. It lives and dies with the container. When you `docker rm` a container, its writable layer — and everything your app wrote there — is deleted permanently. That's fine for a stateless web server, but catastrophic for a database, uploaded files, or any state you care about. The fix is to store data **outside** the container's lifecycle, in storage that Docker manages separately: **named volumes** (Docker-managed, the preferred choice), **bind mounts** (a folder on your host, great for development), and **tmpfs** (in-memory, for sensitive or throwaway data). In this lab you'll prove the problem is real — write data, destroy the container, and watch a *new* container find the same data still there. You'll inspect where Docker keeps a volume on disk, edit a website live through a bind mount, and declare volumes in a `docker-compose.yml`. By the end you'll know exactly which storage type to reach for and why.

Objectives

Prerequisites

What you will build

You won't write much application code in this lab. Instead you'll run a real PostgreSQL database and
an nginx web server, and attach storage that outlives the container. You'll write data, destroy
the container, and watch a brand-new container find the data intact — the proof that volumes work.
Then you'll inspect a volume on disk, edit a live website through a bind mount, and declare storage in
Docker Compose.

The one mental model: the container layer is ephemeral

Every container gets a thin writable layer on top of its read-only image. Anything the app writes
— database files, uploads, logs — lands there by default. The catch:

When you remove the container, that writable layer is deleted with it. The data is gone.

That's by design: containers are meant to be disposable. So persistent data must live outside the
container, in storage Docker manages independently. Docker gives you three options:

Type Where it lives Managed by Best for
Named volume Docker's own area on the host (/var/lib/docker/volumes/) Docker Databases, persistent app data — the default choice
Bind mount A specific folder you pick on the host You Development: editing source/config live
tmpfs Host RAM, never written to disk Docker (in memory) Sensitive or throwaway data (secrets, caches)

How to choose

How to work through this lab

Do the steps in order — each builds on the last. Run every command yourself and read its real output.
Along the way you'll collect the evidence (proof of persistence, volume inspect, live bind-mount edit)
you need for the submission in Step 6.

Steps

  1. The problem: the writable layer dies with the container

    Before touching volumes, feel the pain they solve. You'll write a file inside a container, remove
    the container, and confirm the file is gone.

    Prove data is lost

    Run a throwaway Alpine container, write a file into it, then look at it:

    docker run --name temp -d alpine:3.20 sh -c "echo 'important data' > /data.txt && sleep 3600"
    docker exec temp cat /data.txt        # prints: important data
    

    The file exists — it lives in the container's writable layer. Now destroy the container and try to
    get it back:

    docker rm -f temp
    docker run --rm alpine:3.20 cat /data.txt   # error: no such file
    

    The new container is a fresh instance of the same image. It never saw your data.txt, because that
    file lived only in the writable layer of the removed container — which was deleted with it.

    The three storage types (and when to use each)

    Docker's answer is to keep data outside the container's lifecycle. There are three mechanisms —
    commit these to memory, because every later step is one of them:

    • Named volumes — fully managed by Docker, stored in Docker's own area on the host. The preferred
      way to persist data. Isolated from the host filesystem, easy to back up, migrate and share between
      containers. Use for databases and any persistent app data.
    • Bind mounts — a file or directory on the host is mounted into the container. Less isolation, more
      flexibility; edits on either side are instantly visible on the other. Use for development — editing
      source or config live.
    • tmpfs mounts — stored in the host's memory, never written to disk. Ephemeral by definition. Use
      for sensitive data you don't want on disk, or scratch data that needn't survive a restart.

    Checkpoint

    • Where did data.txt actually live? (In the writable layer of the temp container.)
    • Why couldn't the second container read it? (That layer was deleted with docker rm; the new container has its own empty layer.)
    • Which storage type would you use for a PostgreSQL database? (A named volume — persistent and Docker-managed.)

    Deliverable for this step: the two outputs — the successful cat inside temp, and the "no such file" error afterward.

  2. Named volumes: prove data survives container removal

    Now solve the problem. You'll create a named volume, run a PostgreSQL database that stores its
    files there, add data, destroy the container, and bring up a new container on the same volume —
    with your data intact.

    Create a volume

    docker volume create pgdata
    

    pgdata is a Docker-managed named volume. It exists independently of any container.

    Run PostgreSQL using the volume

    docker run -d --name lab-postgres \
      -e POSTGRES_PASSWORD=minhasenha \
      -v pgdata:/var/lib/postgresql/data \
      postgres:16-alpine
    
    • -v pgdata:/var/lib/postgresql/data mounts the named volume pgdata at the exact path where
      PostgreSQL keeps its database files. The format is VOLUME_NAME:CONTAINER_PATH.
    • Because the left side is a plain name (not a path starting with / or ./), Docker treats it as a
      named volume, not a bind mount.

    Give it a few seconds to initialize, then create a table and insert a row:

    docker exec -it lab-postgres psql -U postgres -c \
      "CREATE TABLE alunos (id serial, nome text); INSERT INTO alunos (nome) VALUES ('Ada');"
    docker exec -it lab-postgres psql -U postgres -c "SELECT * FROM alunos;"
    

    You should see one row: Ada. That data now lives in the pgdata volume, not in the container's
    writable layer.

    Destroy the container — keep the volume

    docker rm -f lab-postgres
    docker volume ls          # pgdata is still listed
    

    The container is gone, but pgdata survives. This is the whole point: removing a container does not
    remove its named volumes
    .

    Bring up a NEW container on the same volume

    docker run -d --name lab-postgres-2 \
      -e POSTGRES_PASSWORD=minhasenha \
      -v pgdata:/var/lib/postgresql/data \
      postgres:16-alpine
    docker exec -it lab-postgres-2 psql -U postgres -c "SELECT * FROM alunos;"
    

    The Ada row is still there — read by a completely different container. That's persistence: the
    data outlived the container that created it.

    Deliverable for this step: the SELECT * FROM alunos; output from lab-postgres-2 (a different container) showing the row survived.

  3. Inspect a volume: ls, inspect, Mountpoint

    A volume isn't a black box. Docker gives you commands to list volumes and see exactly where and how
    each one is stored.

    List available volumes

    docker volume ls
    

    This shows every volume, with its driver (usually local) and name. You should see pgdata
    in the list.

    Inspect a specific volume

    docker volume inspect pgdata
    

    The output is JSON. The fields that matter:

    • Name — the volume's name (pgdata).
    • Driver — the volume driver; local for standard volumes.
    • Mountpoint — the path on the host where the volume's data actually lives (e.g.
      /var/lib/docker/volumes/pgdata/_data). This is Docker's own managed area — you generally don't
      touch it directly.
    • Labels — any labels attached to the volume.
    • Scope — local (this host) or global.

    What the Mountpoint tells you

    The Mountpoint is where Docker stores the bytes. On Docker Desktop (Windows/macOS) this path lives
    inside Docker's Linux VM, so you won't find it in your normal file explorer — that's expected and
    correct. Inspecting the Mountpoint is useful to:

    • confirm where a volume's data is stored,
    • diagnose storage-related issues,
    • gather info for backup or migration (you'll use this in Step 5).

    A caution from the manual: editing files directly at the host Mountpoint can corrupt data a running
    container is using. Let the container (or a backup command) be the one that touches it.

    Deliverable for this step: the docker volume inspect pgdata output, with the Mountpoint line highlighted.

  4. Bind mounts: edit a file on the host, see it live in the container

    A named volume is Docker-managed and lives in Docker's own area. A bind mount is different: you
    point the container at an exact folder on your host, and both sides see the same files in real
    time. This is the tool for local development.

    Create a site folder on the host

    mkdir -p lab-volume/site
    echo "<h1>Version 1 — served from a bind mount</h1>" > lab-volume/site/index.html
    

    Serve it with nginx via a bind mount

    docker run -d --name site -p 8080:80 \
      -v "$(pwd)/lab-volume/site:/usr/share/nginx/html:ro" \
      nginx:1.27-alpine
    
    • The left side of -v is now a path ($(pwd)/lab-volume/site), so Docker treats it as a bind
      mount, not a named volume.
    • It's mounted at nginx's web root, /usr/share/nginx/html.
    • :ro mounts it read-only inside the container — nginx only needs to read the files. Dropping
      :ro would let the container write back to your host folder.

    On Windows PowerShell, replace $(pwd) with ${PWD}. On classic Windows CMD, use %cd%.

    Open http://localhost:8080 — you'll see "Version 1".

    Edit on the host, refresh in the browser

    Without touching the container, edit the file on your host:

    echo "<h1>Version 2 — edited live on the host</h1>" > lab-volume/site/index.html
    

    Refresh http://localhost:8080 — it now says "Version 2". You never rebuilt or restarted the
    container. The bind mount means the container reads the same file you just edited on the host.

    Named volume vs bind mount — the practical difference

    Named volume Bind mount
    Location Docker-managed area (you don't pick it) An exact host path you choose
    Isolation High — isolated from host FS Low — it is a host folder
    Best for Databases, persistent data, backups Live-editing source/config in dev
    Portability Portable, easy to back up Tied to this host's path layout

    Rule of thumb: bind mounts for development (you want to edit files), named volumes for data you
    must persist
    (you want Docker to manage it).

    Deliverable for this step: note that editing index.html on the host changed the page from "Version 1" to "Version 2" with no container restart.

  5. Volumes with Docker Compose + tmpfs + backup

    In real projects you don't type long docker run -v commands — you declare storage in
    docker-compose.yml, where it's versioned and reproducible. You'll wire up a named volume and a bind
    mount, meet tmpfs, and back up a volume.

    Declare a named volume in Compose

    In lab-volume/, create docker-compose.yml:

    services:
      postgres:
        image: postgres:16-alpine
        environment:
          POSTGRES_PASSWORD: minhasenha
        volumes:
          - pgdata:/var/lib/postgresql/data   # named volume (top-level, below)
        ports:
          - "5432:5432"
    
      web:
        image: nginx:1.27-alpine
        volumes:
          - ./site:/usr/share/nginx/html:ro   # bind mount (host path)
        ports:
          - "8080:80"
    
    volumes:
      pgdata:
    

    Two kinds of storage in one file:

    • pgdata:/var/lib/... — the left side is a plain name, so it's a named volume. It must also
      be declared in the top-level volumes: block, which is what tells Compose to manage it.
    • ./site:/usr/share/... — the left side is a path (./site), so it's a bind mount to the
      folder you created in Step 4.

    Bring it up, then prove persistence across a full teardown:

    docker compose up -d
    # ... create a table / add data via psql as in Step 2 ...
    docker compose down          # stops AND removes the containers
    docker compose up -d         # fresh containers, same pgdata volume
    # ... query again: the data is still there ...
    

    docker compose down removes the containers but not the named volume, so the data survives. (To
    also delete volumes you'd have to say docker compose down -v — a useful reset, and a dangerous typo.)

    tmpfs — for sensitive or throwaway data

    When data must never touch disk (a secret, a scratch cache), use tmpfs — it lives in RAM and is
    wiped when the container stops. In Compose:

      web:
        image: nginx:1.27-alpine
        tmpfs:
          - /tmp        # in-memory, gone on container stop
    

    Anything written to /tmp exists only while the container runs; stop it and the data is discarded.
    That's a feature for secrets and caches, not a place for anything you need to keep.

    Back up a volume

    Because a named volume is just a directory Docker manages, you back it up by tarring its contents from
    a throwaway container:

    docker run --rm \
      -v pgdata:/data \
      -v "$(pwd):/backup" \
      alpine:3.20 tar czf /backup/pgdata-backup.tar.gz -C /data .
    

    This mounts pgdata read-side at /data, mounts your current host folder at /backup, and writes
    pgdata-backup.tar.gz to your host. To restore, you'd run the reverse: extract the tar into a fresh
    volume. This tar-through-a-container trick is the standard way to back up and migrate Docker volumes.

    Security notes

    • Bind mounts expose a real host folder to the container — mind file permissions, and prefer
      :ro when the container only needs to read (as with the nginx site above).
    • Sensitive data belongs in tmpfs (in memory) or behind proper encryption — not in a plain
      bind mount on disk.
    • Use separate volumes for separate services unless they genuinely need to share data.

    Deliverable for this step: your docker-compose.yml, and proof that data survived a docker compose down + up cycle.

  6. Submit: document your persistence proof

    Prove you made data outlive its container by capturing the evidence in a small repository.

    Clean up first

    docker rm -f lab-postgres-2 site 2>/dev/null
    docker compose down          # if you have Compose running
    # keep or remove the pgdata volume as you like:
    # docker volume rm pgdata
    

    Build your submission

    Create a folder, initialize git, and write a file named SUBMISSION.md. Paste the real outputs
    you collected — not invented text.

    # Docker Lab 5 — Persist Data with Volumes
    
    ## 1. The problem (ephemeral layer)
    <paste the successful `cat /data.txt` inside `temp`, then the "no such file" error after `docker rm`>
    
    ## 2. Persistence proof (named volume)
    <paste `SELECT * FROM alunos;` from `lab-postgres-2` — a DIFFERENT container — showing the row survived>
    
    ## 3. docker volume inspect pgdata
    <paste the JSON; point out the Mountpoint line>
    
    ## 4. Bind mount live edit
    Editing `site/index.html` on the host changed the page from "Version 1" to "Version 2" with no restart. (yes / no)
    
    ## 5. Compose + persistence across down/up
    <paste your docker-compose.yml, and evidence the data survived `docker compose down` then `up -d`>
    
    ## Reflection (2-3 sentences)
    In my own words: why the writable layer is ephemeral, and when I'd choose a named volume vs a bind
    mount vs tmpfs.
    

    Submit

    git init
    git add SUBMISSION.md docker-compose.yml
    git commit -m "Docker lab 5 — persist data with volumes"
    # push to a public repo or create a gist, then submit that URL
    

    Submit the repository or gist URL as your lab deliverable.

    Submission criteria (self-check — no stubs)

    • Step 1 shows the real "no such file" error proving the writable layer is ephemeral
    • The persistence proof is from a different container (lab-postgres-2), not the one that wrote the data
    • docker volume inspect output is included, with the Mountpoint identified
    • You confirmed a host-side edit reached the container live through the bind mount
    • Your docker-compose.yml declares both a top-level named volume and a bind mount
    • You showed data surviving a docker compose down + up -d cycle
    • Your reflection correctly states, in your words, when to use volume vs bind mount vs tmpfs
    • All outputs are real command output you ran — no placeholder or invented text

    What's next

    Congratulations — that was the last free lab of the Docker track. You can now run containers,
    build images, use Compose, connect containers over networks, and persist data with volumes: the full
    core of day-to-day Docker.

    From here the track continues with premium content that takes you to production:

    • Docker Security — hardening images and containers, non-root users, secrets, scanning.
    • Docker in Production — observability with Prometheus and Grafana, healthchecks, resource limits.
    • Kubernetes track — orchestrating containers at scale beyond a single host.

    You've built the foundation; premium is where you take it to production.