Volumes & the Gotchas
Here's where the calm picture from Phase 1 - "a container is a running instance, not a little computer" - turns into something you have to act on. Because containers are instances, anything they wrote vanishes when they're removed. That's not a bug; it's the design. This phase shows you where to keep data that must survive, how to feed config in cleanly, and the traps that catch nearly everyone - each named before it can ruin your afternoon.
Cheat-card: symptom → calm fix
Arrived here mid-problem? Find your symptom, then read the matching section below.
| Symptom | Likely cause | Calm fix |
|---|---|---|
Data gone after docker rm / restart |
Wrote to the container's throwaway layer | Mount a volume for that path - see Containers are ephemeral |
| "It works in the container but I can't reach the port" | Forgot -p, or wrong HOST:CONTAINER order, or app bound to 127.0.0.1 |
See The unpublished port |
| Image is enormous (hundreds of MB / GBs) | Heavy base image, build tools left in, files copied in | See The huge image |
| A secret you set is still in the image | Baked in with ENV / COPY at build time - it's in a layer forever |
See Secrets in layers |
| Config differs per environment but it's hardcoded | Values baked into the image | Pass environment variables at run time - see Config goes in env vars |
Containers are ephemeral - volumes are how data survives
Recall from Phase 1 that a running container gets a thin writable layer on top of the frozen image. Every file the app creates - uploaded images, a database's data, logs - lands in that writable layer. And that layer belongs to the container, so when the container is removed, the layer is removed with it. The data is gone.
A volume is storage that lives outside the container's lifecycle - managed by Docker on the host - and is mounted into the container at a path you choose. The app writes to that path as normal, but the bytes land in the volume, which survives the container being stopped, removed, and replaced.
📝 Terminology. Volume - Docker-managed persistent storage you attach to a container. There's also a bind mount, which maps a specific folder from your host machine into the container (handy in development, so editing a file on your laptop shows up live inside the container - the exact thing Phase 1 said wouldn't happen unless you mount it in).
Run a database with a named volume so its data outlives the container:
$ docker run -d --name db \
-v pgdata:/var/lib/postgresql/data \
-e POSTGRES_PASSWORD=secret \
postgres:16
a7c3e9f1b2d4...
What just happened: -v pgdata:/var/lib/postgresql/data created (or reused) a named volume pgdata and
mounted it at the path Postgres stores its data. Now you can docker rm -f db and start a fresh postgres
container pointing at the same pgdata volume, and your tables are still there. Confirm the volume exists
independently:
$ docker volume ls
DRIVER VOLUME NAME
local pgdata
What just happened: the volume is listed as its own object, not tied to the container. That separation is the whole point - the container is disposable, the volume is not.
💡 Key point. Treat containers as throwaway. Any data that must survive a restart belongs in a
volume, never in the container's writable layer. If you ever think "I'll just keep it inside the
container," that's the moment the data is one docker rm away from gone.
Config goes in environment variables, not into the image
You want the same image to run in development, staging, and production - that's the reproducibility promise. But those environments need different database URLs, API keys, and feature flags. Bake those values into the image and you've made three different images and lost the promise.
The fix: pass configuration in at run time with -e (you already saw -e POSTGRES_PASSWORD=secret
above). The image stays generic; the environment is supplied when the container starts.
$ docker run -d --name web \
-e DATABASE_URL=postgres://db:5432/app \
-e LOG_LEVEL=info \
-p 8080:3000 \
my-app:1.0
What just happened: the app reads DATABASE_URL and LOG_LEVEL from its environment at startup. The
same my-app:1.0 image runs everywhere; only the -e values change. (Many of these? Graduate to an
--env-file or to Compose - the next guide.)
The gotchas everyone hits
The unpublished port
This is the most common "but it works in the container!" moment, so it earns first place. Three distinct causes wear the same disguise - the app runs fine inside, but you can't reach it from your machine:
- You forgot
-pentirely.EXPOSEin the Dockerfile is only documentation (Phase 2). Without-p HOST:CONTAINERondocker run, no port is published. Checkdocker ps- an emptyPORTScolumn is the tell. - You reversed the mapping.
-pisHOST:CONTAINER. If your app listens on 3000 inside, it's-p 8080:3000, not-p 3000:8080. Backwards, and you'll connect to a port nothing is listening on. - The app bound to
127.0.0.1inside the container. A program listening onlocalhostinside the container is only reachable from inside that container - Docker's port forwarding can't reach it. The app must listen on0.0.0.0(all interfaces) for-pto work. This one is sneaky because the Docker command is correct; the app's own config is the problem.
$ docker ps
CONTAINER ID IMAGE STATUS PORTS NAMES
3f9a2c1b7e4d my-app:1.0 Up 4 seconds web
What just happened: the PORTS column is empty - nothing is published. That's cause #1. Stop the
container and re-run it with -p 8080:3000, and the mapping will appear.
The huge image
⚠️ Gotcha. Reach for a full ubuntu or default node base "to be safe" and your image balloons to
hundreds of megabytes or more - slow to push, pull, and deploy. Two habits keep it lean:
- Start from a slim base.
node:20-slimor-alpinevariants,python:3.12-slim, etc., ship far less than the full image. (Verify a variant's contents before committing to it -alpineuses a different C library that occasionally trips up native dependencies.) - Don't ship your build tools. Compilers, dev dependencies, and caches don't belong in the final image. The standard fix is a multi-stage build (build in one stage, copy only the finished artifact into a clean final stage) - worth looking up once your image matters.
A .dockerignore file (like .gitignore) keeps node_modules, .git, and local junk out of the build
context entirely, so they're never copied in.
Secrets in layers
⚠️ Gotcha - this one is genuinely dangerous. Anything you put into the image at build time becomes a
permanent layer, and layers are not erased by deleting the file in a later step. COPY a private key
in, or hardcode a password with ENV in the Dockerfile, and that secret is baked into the image's
history. Anyone who can pull the image can extract it from the layers - even if a later instruction
"removes" it, the earlier layer still holds it.
The rule that keeps you safe: secrets are run-time input, never build-time content. Pass them with
-e (or a secrets manager / Compose secrets) when the container starts - exactly like the config above -
so they live in the running container's environment, not frozen into a distributable image.
Recap
- Containers are ephemeral: the writable layer dies with the container. Use a volume (
-v) for any data that must survive. - A volume is host-managed storage mounted into the container; it outlives the container and shows up
in
docker volume ls. - Config and secrets go in at run time via
-eenvironment variables - keeps one image usable everywhere, and keeps secrets out of the image. - The traps: the unpublished port (forgot
-p, reversedHOST:CONTAINER, or app bound to127.0.0.1), the huge image (slim base + don't ship build tools), and secrets in layers (never bake them in).
You can now reason about a single container end to end - build it, run it, reach it, persist it, and avoid the traps. The next step up is orchestrating several containers together - a web app, its database, and a cache, defined in one file and started with one command: Docker Compose for Real Projects. When it's time to put a container on a real server, see Deploying to a VPS.
← Phase 2: The Dockerfile & Layers · Guide overview
Before the quiz: without looking back, say (or jot down) the core idea of this phase in your own words.
Check your understanding 2 questions
1. Where does data a container writes go by default, and what survives removal?
2. Why pass secrets at run time (-e) instead of baking them into the image?