You can now build an image and run a container from it. The next question is what happens to anything that container writes, and how it talks to anything outside itself. Both answers start from the same default: nothing, and nothing.

Containers are ephemeral by default

Every container gets a thin writable layer on top of its image. Anything the process writes while running—a log file, an uploaded image, a row in an embedded database—lives only in that layer. Remove the container, and the writable layer goes with it. Run a brand-new container from the same image, and it starts from the image's original state, with no memory of what the previous container wrote.

That is not a bug. It is the same property that makes containers reliable: a fresh container is always identical to the image, never a slowly-drifting copy of whatever happened to accumulate in a long-running one. But it means anything you actually want to keep has to live somewhere other than the container's own writable layer.

Volumes: giving data a life outside the container

Docker gives you two practical ways to do that.

A bind mount maps a specific path on your host machine directly into the container:

docker run -v "$(pwd)/data:/app/data" my-app:latest

Whatever is at ./data on your host now appears at /app/data inside the container, and changes flow both ways. This is exactly what you want for local development—edit a file on your host, see the change reflected instantly inside the running container.

A named volume is different: Docker manages the storage itself, and you refer to it by name instead of a host path.

docker volume create pgdata
docker run -v pgdata:/var/lib/postgresql/data postgres:16

Named volumes are the right choice for data you want to persist reliably—a database's actual data directory—without tying it to a specific folder on a specific machine. Remove and recreate the container as often as you like; the named volume, and the data in it, survives independently.

Networking: how containers talk to the world—and each other

By default, a container gets its own isolated network namespace: its own IP address, invisible to your host and to other containers unless you deliberately connect them.

Reaching a container from your host is the -p flag from the last article:

docker run -d -p 5432:5432 postgres:16

That publishes the container's port 5432 to your host's port 5432. Without it, the container's network is real but unreachable from outside.

Letting two containers talk to each other takes a shared network:

docker network create my-app-net
docker run -d --name db --network my-app-net postgres:16
docker run -d --name web --network my-app-net -p 3000:3000 my-app:latest

Containers on the same user-defined network can reach each other by container name—the web container can connect to db:5432 directly, no IP address required. Docker's built-in DNS resolves the name for you. That is the mechanism behind almost every multi-container local setup, including the Compose files the next article builds on.

Environment variables and .env files

Configuration—database URLs, API keys, feature flags—should not be baked into an image. The same image needs to behave differently in development, staging, and production, and that means configuration has to be supplied at run time, not build time.

docker run -e DATABASE_URL=postgres://localhost/mydb -e NODE_ENV=production my-app:latest

For more than a couple of variables, an env file is more manageable than a wall of -e flags:

docker run --env-file .env.production my-app:latest

Keep a checked-in .env.example documenting every variable a project needs, with placeholder values—and keep the real .env files out of version control and out of your build context (this is exactly what the .dockerignore from the last article protects against).

Putting it together: a small stateful example

docker network create demo-net
docker volume create demo-pgdata

docker run -d --name demo-db \
  --network demo-net \
  -v demo-pgdata:/var/lib/postgresql/data \
  -e POSTGRES_PASSWORD=devpassword \
  postgres:16

docker run -d --name demo-web \
  --network demo-net \
  -p 3000:3000 \
  -e DATABASE_URL=postgres://postgres:devpassword@demo-db:5432/postgres \
  my-app:latest

Restart demo-db as many times as you like—docker stop demo-db && docker start demo-db—and the data in demo-pgdata is still there afterward. Remove the container entirely and recreate it against the same named volume, and the data survives that too. That combination—a network for communication, a volume for persistence, environment variables for configuration—is the pattern almost every real multi-container application runs on.

Common mistakes

  • Storing real data in the container's writable layer. It disappears the moment the container is removed. Anything worth keeping belongs in a named volume.
  • Forgetting a shared network between containers that need to talk. Two containers with no shared --network cannot reach each other by name, even on the same host.
  • Hardcoding configuration into the image. An image with a baked-in database URL cannot move between environments without being rebuilt.
  • Committing .env files with real secrets. Keep a .env.example with placeholders in version control; keep real values out of it and out of the build context.

Practical checklist

  • I understand why a container's writable layer disappears when the container is removed.
  • I can explain the difference between a bind mount and a named volume, and when to use each.
  • I have run two containers on a shared user-defined network and had one reach the other by container name.
  • I configure containers with environment variables or an env file, not values baked into the image.
  • My project has a .env.example with placeholder values, and real .env files are excluded from version control and the build context.

Next in the series: Docker Compose for Multi-Container Apps. We will turn the network, volume, and environment-variable commands above into one repeatable, versioned file.