Docker Volumes & Networking Explained on a Real Server

Docker Volumes & Networking Explained on a Real Server

There are two moments that teach every self-hoster how Docker actually works. The morning an update wipes your data. And the night an app cannot reach its own database. Both come from the same two half-understood ideas: where container data lives , and how containers talk . So I booted a fresh DigitalOcean droplet and triggered both failures on purpose. Every command and every line of output on this page came off that server.

A container's own filesystem dies with the container. That is by design. Data you want to keep goes in a named volume (Docker manages it at /var/lib/docker/volumes/<name>/_data , I show you the real files) or a bind mount (a folder you choose). For networking: containers only find each other by name on a user-defined network . The default one has no DNS, which is why "can't reach the database" happens. And publish as few ports as possible, because a published port is public even when your firewall says otherwise. I prove each of these below with captured output.

I have run this stack in production for years, and these two concepts are the exact foundation my Self Hosting 2.0 course builds on before it ever touches a deploy tool. Tools change. The volume and the network underneath them do not.

Here is the mental shift that makes everything else on this page obvious: a container is not a small server . It is a running copy of a frozen image, and the normal way to change it is to throw it away and start a new one. Update an app? Remove the container, start a fresh one from the newer image. Change a setting? Same move. When I timed a Plausible upgrade , the whole remove-and-recreate took 16 seconds. This is routine, not surgery.

Which raises the obvious question. If the container gets thrown away on every update, and your database lives inside it... where exactly is your data?

That question has three possible answers, and one of them loses your data.

One host, three places a container's files can land

Everything is on the host's disk in the end. What differs is who manages it, and what happens on delete.

1 · Container layer · dies with the container

2 · Named volume · Docker's territory

3 · Bind mount · your territory

The flag syntax is nearly identical: a name before the colon means named volume , a path before the colon means bind mount . One character of difference ( / ) decides who manages your data.

Lane 1 is the dangerous one, and it is the default. Run a database container with no -v flag at all and it happily writes everything into its container layer. It works. It keeps working through restarts. Then the first real update deletes the container, and the data goes with it. Nothing warned you, because nothing was wrong until that moment.

Lanes 2 and 3 both survive. The rest of this page is about proving that, and choosing between them.

This is the question that sends people to Reddit: I made a volume, my data is in it, but where are the files ? The answer is one command away. Create a volume and ask Docker to describe it:

Every named volume tells you exactly where it lives

Output from the demo droplet, 11 August 2026. The field that matters is Mountpoint.

On Linux, every named volume is a plain directory at /var/lib/docker/volumes/<name>/_data . Not a disk image, not a hidden database. A folder. To prove the two worlds are the same folder, I wrote a file from inside a container, then read it from the host with no container running at all:

Written inside the container, found on the host's disk

The volume is mounted at /data inside the container. Same bytes, two doors.

It works in both directions: the container's /data and the host's /var/lib/docker/volumes/app_data/_data are the same directory . This is also why backups are simple: the files are right there. My Docker backups guide (coming to this hub) is built on exactly that fact.

A bind mount is even less mysterious. You pick the folder, so you already know where it is:

One habit worth stealing: when you are not sure how a running container's storage is wired, ask it. docker inspect lists every mount with its type and its host path:

How Docker records the two mount types

One container running with both: a named volume at /named, a bind mount at /bind.

Both survive the container. The differences are in who manages them, what pre-fills them, and what deletes them:

My rule, and the one Docker's own docs lean toward: named volumes for data the app owns, bind mounts for files you own . The database's storage directory is the app's business, so give it a named volume. Your nginx config or a folder of site files is your business, so bind mount it where you can edit it.

The pre-fill row in that table deserves one more sentence, because it bites. If an image ships files at a path (say a default config), mounting a named volume there copies those files into the volume on first use. Mounting an empty bind folder there hides them, and the app boots into a directory with nothing in it. If an app mysteriously starts blank only when you add your mount, this is usually why.

Time to actually run the failure. I started three alpine containers and wrote the same marker file into each, one per storage mode:

Then I killed all three, recreated them with identical commands, and asked each one for its marker back:

The survival matrix: destroy, recreate, ask for the data back

docker rm -f on all three, then an identical recreate. Real exit codes from the droplet.

There is the whole storage story in three lines. Container layer: gone. Named volume: survived. Bind mount: survived. The container was never the place to keep anything.

There is a fourth case, and it is sneaky. Mount a path with no name at all , like -v /data (many Dockerfiles do the equivalent with a bare VOLUME line), and Docker creates an anonymous volume with a 64-character hash for a name:

The volume nobody will ever find again

One container started with -v /data, then removed.

The container is gone. The hash volume stays, holding data nothing points to anymore. Do this for a year and docker volume ls becomes a graveyard where one of the tombstones might be data you need. Always name your volumes.

Most real stacks run under Docker Compose, so here is the same lifecycle there. A minimal stack with one named volume:

I wrote a marker into the volume, took the stack down, brought it back, and then did it again with one extra flag:

down vs down -v, on the same stack

The difference is one flag and all of your data.

Plain docker compose down is safe: containers and network go, named volumes stay. The -v flag is the destructive one, and people reach for it as a "clean restart" without knowing what it means. A clean restart of the app is down then up -d . down -v is "and erase its memory too". Type it only when that is what you want.

The update is the moment people actually lose data, so I tested the exact move every self-hosted upgrade makes: remove the container, start a newer image on the same volume . With a real database, and a real row in it:

Insert on 16.3, read it back on 16.14

The container changed. The volume did not. That is an update.

Eleven minor versions forward, container destroyed and rebuilt, and the row is still there. Updates only lose data when the data lived in the container layer. With a named volume, an update is just a container swap.

One honest caveat for databases specifically: this is safe for minor updates (16.3 to 16.14). A major jump (Postgres 16 to 17) changes the on-disk format and needs a real migration, not just a tag change. That is a database rule, not a Docker rule.

Storage half done. Now the other half of the page: how does the app container find the database container?

Docker networking is easier to hold in your head if you picture a virtual switch inside your server . Docker calls it a bridge. Containers plug into it, each gets a private IP address (mine got 172.17.0.2 and 172.17.0.3 ), and anything plugged into the same switch can talk to anything else on it, on any port, without a single port being published. Traffic between them never leaves the machine.

Out of the box there is one switch, called the default bridge , and every docker run plugs into it unless you say otherwise. You can also create your own switches, called user-defined networks , with one command. They look identical at first. They are not, and the difference is the single most common source of "my containers can't see each other":

The same two containers on the two kinds of network

Same apps, same commands. The only change is which network they plug into.

Default bridge · IPs work, names do not

User-defined network · names just work

Container IPs are assigned at start and change when containers are recreated . So hardcoding an IP works until the next update, the same way container-layer storage works until the next update. Names are the only stable handle, and only user-defined networks provide them.

Let me show you the failure itself, not just claim it. Two containers, plain docker run , which means the default bridge:

On the default bridge, the name simply does not resolve

getent hosts asks the container's own resolver, exactly like your app does.

This is the anatomy of "my app can't reach the database" when both containers are running fine. The network path exists (the ping by IP proves it). What is missing is name resolution : on the default bridge, a container's resolv.conf just points at the host's DNS, which has never heard of a container called db .

The fix costs one command. Create a network, run the same two containers on it:

On a user-defined network, the same question gets an answer

Identical containers. The only change: --network appnet.

Look at resolv.conf now: 127.0.0.11 . That is a tiny DNS server Docker runs for every user-defined network, and it knows every container on that network by name. This is the entire magic behind service names. Nothing more exotic than a private phone book.

So the practical rule writes itself: containers that belong together get their own network . One docker network create per stack. The default bridge is legacy behavior kept for compatibility, and Docker's own docs steer you off it.

Here is the good news that makes this whole section click into place: if you use Docker Compose, you have been getting user-defined networks for free all along. Compose never uses the default bridge. It creates a network per project and puts every service on it:

Compose: a network appears, and service names resolve

No networks: section anywhere in the file. This is default Compose behavior.

The app service fetched a web page from http://web , no IP, no published port, no config. When a compose file says DATABASE_HOST=db , this is the machinery making that work: service name → embedded DNS → container IP , all inside the project's private network.

This is also why the classic beginner bug is copy-pasting a database hostname like localhost into an app's config. Inside a container, localhost means that container , not the machine and not the database next door. On a Compose network, the database's name is its service name. Use it.

Everything so far happened on the private switch. The internet cannot see any of it. The -p flag is the deliberate act of opening a doorway, and the decision of which containers get one is a security decision.

Here is the shape of a typical stack done right. The web app gets a published port. The database gets nothing:

The db needs no ports to serve the app

Inside the network: full access. From the host's public side: only 8080 exists.

Read those three results together: the app can use the database on the private network , the server's public side is listening only on 8080, and the database has no doorway at all. A port scanner sees one service. Your app sees everything it needs.

Now the part I really wanted to show you, because most guides skip it and it is the one with consequences. Say you think "I'll publish 5432 but my firewall blocks it anyway". On this droplet, ufw was active with a default-deny policy, allowing only SSH and 8080. Then I published the database port and probed the server from my laptop, outside the box :

ufw says no. The port is open anyway.

Left: the firewall rules on the server. Right: what my laptop could actually reach.

On the server · 5432 is not allowed

From my laptop · outside the droplet

The database port answered from the public internet with the firewall actively denying it . This is not a bug in your setup. Docker programs the kernel's packet rules (iptables) directly for published ports, and its rules run before ufw's. The honest summary: on a default Ubuntu setup, -p overrides ufw , and the only reliable ways out are to not publish the port, or to bind it to localhost ( -p 127.0.0.1:5432:5432 ).

I tore that container down right after the probe, but on a real server this is exactly how databases end up in breach reports: the owner published the port for a quick debugging session, trusted the firewall, and moved on. The scanning background noise finds it within hours. I measured that noise separately in the VPS hardening guide : a fresh server got its first uninvited SSH attempt 2 minutes 35 seconds after boot.

So the port decision tree is short. Does the internet need it? Publish it. Does only another container need it? Same network, no -p , done. Do you, sometimes, need it for debugging? Bind it to localhost and reach it over SSH. This "publish almost nothing" pattern has a natural endpoint: one reverse proxy as the only published container, with every app internal behind it. That is its own guide, and it is the architecture every serious self-hosted box converges on.

One more mode you will meet in the wild: --network host . It skips the switch entirely. The container shares the host's network stack, so "the container's port 80" is the server's port 80:

Host networking: no mapping, no isolation

nginx with --network host, then a second one trying the same thing.

Notice ss shows nginx itself on port 80, not docker-proxy. And the second container hits "Address in use", a collision the bridge model would have made impossible. Host networking trades isolation for direct access.

When is that trade right? Monitoring agents that need to see the host's real interfaces, VPN servers, apps that open hundreds of dynamic ports. In my experience, for ordinary web apps and databases, the answer is: it isn't. Use the bridge model and publish deliberately.

Everything above compresses into a setup you can apply to any stack today:

Volumes are also the honest answer to "what do I back up?" You back up the volumes. That guide is next in this hub.

On Linux: /var/lib/docker/volumes/<volume-name>/_data . Confirm any volume's exact path with docker volume inspect <name> and read the files with normal tools, no container needed. Bind mounts live wherever you pointed them.

Both survive the container. A named volume is managed by Docker under /var/lib/docker/volumes/ , gets pre-filled with the image's files on first use, and is deleted by compose down -v . A bind mount is a host folder you chose: Docker never manages or deletes it. Volumes for app data, bind mounts for files you edit.

It deletes the container layer, so anything written inside the container with no mount is gone. Named volumes and bind mounts survive. I tested all three on one server and only the container-layer file was lost.

Not if the data is in a volume. I inserted a row on postgres:16.3, replaced the container with postgres:16.14 on the same volume, and the row was still there. For databases, that covers minor updates; major version jumps need a real migration.

down removes containers and the network but keeps named volumes. down -v also deletes the volumes, permanently. It is the difference between restarting an app and wiping it.

Both containers are probably on the default bridge, which has no DNS between containers. Create a network ( docker network create appnet ) and start both with --network appnet . Names then resolve through Docker's embedded DNS at 127.0.0.11.

No. Containers reach it over the shared network without any published port. And on Ubuntu, publishing it bypasses ufw: I proved from outside the server that a published 5432 answered while the firewall was actively denying it. If you need occasional access yourself, bind to localhost: -p 127.0.0.1:5432:5432 , then tunnel over SSH.

Rarely. --network host gives the container the host's network stack directly: no isolation, real port conflicts (I demonstrated two nginx containers fighting over port 80). It fits monitoring agents and VPN servers, not ordinary apps.

A volume created without a name ( -v /data , or a Dockerfile VOLUME line), identified only by a 64-character hash. It survives its container but nothing points to it anymore, so it becomes orphaned storage. Name your volumes and this never happens.

Volumes and networks are the foundation layer. In Self Hosting 2.0 I build up from here to the full stack you own: Coolify, real apps, backups, email, and security, in the order it should actually happen. 34 lessons, nothing skipped between them.

Name your volumes. Name your networks.

Hasan Aboul Hasan builds open-source tools and teaches solo developers how to build, host, and sell AI-powered products. Founder of LearnWithHasan.com , creator of SimplerLLM and PyRunner .

The exact building blocks I use to ship real products with AI — yours as a free PDF.

Have a question? Ask it in the community — it's tagged #guide and linked back here. Reading is open to everyone; posting needs a free account.

Recommended articles