Docker Volumes and Networking: Where Data Lives, How Containers Talk

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.

TL;DR

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.

New to Docker? The jargon on this page, translated
Image
The frozen blueprint an app ships as: files, settings, everything. You never edit it, you start containers from it.
Container
A running copy of an image. Disposable by design: you delete and recreate containers all the time.
Container layer
The thin writable surface on top of the image where a running container's file changes land. Deleted with the container.
Named volume
A chunk of disk Docker creates and manages for keeping data. Lives on the host, survives the container.
Bind mount
A normal host folder you hand to a container, like /root/site-data. You manage it, Docker just connects it.
Bridge network
A virtual switch inside your server that containers plug into. Each container gets a private IP on it.
DNS
The phone book that turns a name like db into an IP address. Docker runs a tiny private one for your containers.
Publishing a port
The -p 8080:80 flag: connect a port on the server's public side to a port inside a container. This is the doorway to the internet.
What you'll understand at the end
  • The three places container data can live, and exactly what survives docker rm (tested, with output)
  • The real paths on disk: where a named volume's files actually sit, shown with ls
  • Why docker compose down -v is the flag that deletes data, and the anonymous-volume trap
  • Why a container "can't reach the database", reproduced and then fixed with one command
  • When to publish a port and when to keep it internal, including the firewall bypass most guides skip
Following along
  • You need: any Linux box with Docker. I used a fresh DigitalOcean droplet (2 vCPU / 4 GB). Docker not installed yet? The official Docker install docs for Ubuntu take about five minutes.
  • Everything is copy-paste safe. The demos use throwaway containers (alpine, nginx, Postgres) and clean up after themselves.
  • Tested on: Ubuntu 24.04, Docker 29.6.2, Compose v5.3.1, on 11 August 2026.

Containers are disposable on purpose

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.

The three places container data can live

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

Files written inside the containerno volume, no mount: changes land on the image's thin writable layer
Gonedeleted with the container, every time

2 · Named volume · Docker's territory

-v app_data:/dataDocker creates and manages the storage
/var/lib/docker/volumes/app_data/_datasurvives rm, recreate, updates

3 · Bind mount · your territory

-v /root/site-data:/datayou pick a host folder, Docker connects it
/root/site-datasurvives everything Docker does

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.

Where Docker volumes are actually stored on disk

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:

docker volume create app_data
docker volume inspect app_data

Every named volume tells you exactly where it lives

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

root@cf-docker-volumes-networking: ~
root@server:~# docker volume inspect app_data
[
    {
        "CreatedAt": "2026-08-11T17:40:08Z",
        "Driver": "local",
        "Labels": null,
        "Mountpoint": "/var/lib/docker/volumes/app_data/_data",
        "Name": "app_data",
        "Options": null,
        "Scope": "local"
    }
]

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.

root@cf-docker-volumes-networking: ~
root@server:~# docker run --rm -v app_data:/data alpine sh -c 'echo hello-from-inside-the-container > /data/hello.txt'

root@server:~# ls -la /var/lib/docker/volumes/app_data/_data
total 12
drwxr-xr-x 2 root root 4096 Aug 11 17:40 .
drwx-----x 3 root root 4096 Aug 11 17:40 ..
-rw-r--r-- 1 root root   32 Aug 11 17:40 hello.txt

root@server:~# cat /var/lib/docker/volumes/app_data/_data/hello.txt
hello-from-inside-the-container

root@server:~# echo written-on-the-host > /var/lib/docker/volumes/app_data/_data/from-host.txt
root@server:~# docker run --rm -v app_data:/data alpine cat /data/from-host.txt
written-on-the-host

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:

mkdir -p /root/site-data
docker run --rm -v /root/site-data:/data alpine sh -c 'echo hello-from-bind-mount > /data/hello.txt'
cat /root/site-data/hello.txt

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.

root@cf-docker-volumes-networking: ~
root@server:~# docker inspect mounts-demo --format '{{json .Mounts}}' | python3 -m json.tool
[
    {
        "Type": "volume",
        "Name": "app_data",
        "Source": "/var/lib/docker/volumes/app_data/_data",
        "Destination": "/named",
        "Driver": "local",
        "RW": true
    },
    {
        "Type": "bind",
        "Source": "/root/site-data",
        "Destination": "/bind",
        "RW": true
    }
]   # trimmed: Mode and Propagation fields

Named volumes vs bind mounts: which one?

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

Named volumeBind mount
Syntax-v app_data:/data-v /root/site-data:/data
Lives at/var/lib/docker/volumes/<name>/_dataany host path you choose
Managed byDocker (create, list, inspect, remove)you; Docker just connects it
Survives docker rmyes (tested below)yes (tested below)
Deleted by compose down -vyes, that is the trapno, never touched by Docker
First use on existing image datapre-filled with the image's files at that pathstarts with whatever your folder holds (an empty folder hides the image's files)
Best forapp state: databases, uploads, anything the app ownsthings you edit: configs, code, files you want visible in your file manager

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.

What survives docker rm? I destroyed three containers to check

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

docker volume create survive_vol
mkdir -p /root/survive-bind

docker run -d --name c-layer alpine sleep 3600
docker exec c-layer sh -c 'echo my-precious-data > /marker.txt'

docker run -d --name c-volume -v survive_vol:/data alpine sleep 3600
docker exec c-volume sh -c 'echo my-precious-data > /data/marker.txt'

docker run -d --name c-bind -v /root/survive-bind:/data alpine sleep 3600
docker exec c-bind sh -c 'echo my-precious-data > /data/marker.txt'

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.

root@cf-docker-volumes-networking: ~
root@server:~# docker rm -f c-layer c-volume c-bind
c-layer
c-volume
c-bind

# recreate all three identically, then:

root@server:~# docker exec c-layer cat /marker.txt
cat: can't open '/marker.txt': No such file or directory  (exit code: 1)

root@server:~# docker exec c-volume cat /data/marker.txt
my-precious-data

root@server:~# docker exec c-bind cat /data/marker.txt
my-precious-data

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.

The anonymous-volume trap

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.

root@cf-docker-volumes-networking: ~
root@server:~# docker run -d --name anon-demo -v /data alpine sleep 3600
root@server:~# docker volume ls
DRIVER    VOLUME NAME
local     7d0e85d43825a60599e7257211abf23bb3284d449b7d977afe5f31339a27953f
local     app_data
local     survive_vol

root@server:~# docker rm -f anon-demo
root@server:~# docker volume ls
DRIVER    VOLUME NAME
local     7d0e85d43825a60599e7257211abf23bb3284d449b7d977afe5f31339a27953f
local     app_data
local     survive_vol

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.

Compose: down keeps data, down -v deletes it

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

services:
  app:
    image: alpine
    command: sleep 3600
    volumes:
      - appdata:/data
volumes:
  appdata:

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.

root@cf-docker-volumes-networking: /root/e2-stack
root@server:~/e2-stack# docker compose down
root@server:~/e2-stack# docker volume ls --filter name=e2-stack
DRIVER    VOLUME NAME
local     e2-stack_appdata

root@server:~/e2-stack# docker compose up -d && docker compose exec app cat /data/marker.txt
compose-data

root@server:~/e2-stack# docker compose down -v
root@server:~/e2-stack# docker volume ls --filter name=e2-stack
DRIVER    VOLUME NAME
(empty: the volume and the data are gone)
The flag that means "delete my 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.

Does data survive an image update?

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:

docker run -d --name pg -e POSTGRES_PASSWORD=labpass \
  -v pgdata:/var/lib/postgresql/data postgres:16.3

Insert on 16.3, read it back on 16.14

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

root@cf-docker-volumes-networking: ~
root@server:~# docker exec pg psql -U postgres -tAc 'SELECT version();'
PostgreSQL 16.3 (Debian 16.3-1.pgdg120+1) on x86_64-pc-linux-gnu ...
root@server:~# docker exec pg psql -U postgres -c "CREATE TABLE customers(name text); INSERT INTO customers VALUES ('Hasan');"
CREATE TABLE
INSERT 0 1

# the update: remove the container, newer image, SAME volume
root@server:~# docker rm -f pg
root@server:~# docker run -d --name pg -e POSTGRES_PASSWORD=labpass -v pgdata:/var/lib/postgresql/data postgres:16

root@server:~# docker exec pg psql -U postgres -tAc 'SELECT version();'
PostgreSQL 16.14 (Debian 16.14-1.pgdg13+1) on x86_64-pc-linux-gnu ...
root@server:~# docker exec pg psql -U postgres -tAc 'SELECT * FROM customers;'
Hasan

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.

How containers talk: the model

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

webasks for "db" by name
No answerthe default bridge has no DNS between containers
db · 172.17.0.2reachable only by this IP, which changes on recreate

User-defined network · names just work

webasks for "db" by name
Docker's embedded DNSknows every container on this network by name
db · 172.18.0.2found by name, whatever its IP is today

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.

The default-bridge trap: "can't reach the database", reproduced

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

docker run -d --name db  alpine sleep 3600
docker run -d --name web alpine sleep 3600
docker exec web getent hosts db

On the default bridge, the name simply does not resolve

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

root@cf-docker-volumes-networking: ~
root@server:~# docker exec web getent hosts db
(exit code: 2, no output: the name does not exist)

root@server:~# docker exec web ping -c 1 -W 2 db
(exit code: 1: "bad address 'db'")

root@server:~# docker exec web ping -c 1 -W 2 172.17.0.2
PING 172.17.0.2 (172.17.0.2): 56 data bytes
64 bytes from 172.17.0.2: seq=0 ttl=64 time=0.152 ms

root@server:~# docker exec web cat /etc/resolv.conf
nameserver 10.135.255.254        # the HOST's DNS, copied in. No container names here.
(duplicate nameserver + comment lines trimmed)

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:

docker network create appnet
docker run -d --name db  --network appnet alpine sleep 3600
docker run -d --name web --network appnet alpine sleep 3600
docker exec web getent hosts db

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

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

root@cf-docker-volumes-networking: ~
root@server:~# docker exec web getent hosts db
172.18.0.2        db  db

root@server:~# docker exec web ping -c 1 -W 2 db
PING db (172.18.0.2): 56 data bytes
64 bytes from 172.18.0.2: seq=0 ttl=64 time=0.085 ms

root@server:~# docker exec web cat /etc/resolv.conf
nameserver 127.0.0.11            # Docker's embedded DNS, inside the container
options edns0 trust-ad ndots:0
(comment lines trimmed)

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.

Compose does the right thing for you

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:

services:
  web:
    image: nginx:alpine
  app:
    image: alpine
    command: sleep 3600

Compose: a network appears, and service names resolve

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

root@cf-docker-volumes-networking: /root/mystack
root@server:~/mystack# docker compose up -d && docker network ls
NETWORK ID     NAME              DRIVER    SCOPE
7922323d5042   bridge            bridge    local
f7f41825a961   host              host      local
89a051d5d87c   mystack_default   bridge    local
33c6ed89b058   none              null      local

root@server:~/mystack# docker compose exec app getent hosts web
172.18.0.3        web  web

root@server:~/mystack# docker compose exec app wget -qO- http://web | head -4
<!DOCTYPE html>
<html>
<head>
<title>Welcome to nginx!</title>

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.

Publish a port, or keep it internal?

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:

docker network create stacknet
docker run -d --name db  --network stacknet -e POSTGRES_PASSWORD=labpass postgres:16
docker run -d --name web --network stacknet -p 8080:80 nginx:alpine

The db needs no ports to serve the app

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

root@cf-docker-volumes-networking: ~
root@server:~# docker run --rm --network stacknet alpine sh -c 'nc -z -w 2 db 5432 && echo db reachable from inside the network'
db reachable from inside the network

root@server:~# ss -tlnp | grep LISTEN | grep -E ':8080|:5432'
LISTEN 0  4096  0.0.0.0:8080  0.0.0.0:*  users:(("docker-proxy",pid=9390,fd=8))
LISTEN 0  4096     [::]:8080     [::]:*  users:(("docker-proxy",pid=9395,fd=8))

root@server:~# docker port db
(no output: nothing is published)

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.

The trap: a published port walks straight past your firewall

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

root@cf-docker-volumes-networking: ~
root@server:~# ufw status
Status: active

To             Action    From
--             ------    ----
OpenSSH        ALLOW     Anywhere
8080/tcp       ALLOW     Anywhere
OpenSSH (v6)   ALLOW     Anywhere (v6)
8080/tcp (v6)  ALLOW     Anywhere (v6)

From my laptop · outside the droplet

you@laptop: ~
PS> probe 157.230.105.56
 8080/tcp : OPEN            # published + allowed
 5432/tcp : OPEN            # published, NOT allowed by ufw
 9999/tcp : closed/filtered  # never published (baseline)

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.

What about host networking?

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.

root@cf-docker-volumes-networking: ~
root@server:~# docker run -d --name hostweb --network host nginx:alpine
root@server:~# ss -tlnp | grep ':80 '
LISTEN 0  511  0.0.0.0:80  0.0.0.0:*  users:(("nginx",pid=10072,fd=6))

root@server:~# docker port hostweb
(no output: nothing is mapped, the container IS on the host's network)

root@server:~# docker run -d --name hostweb2 --network host nginx:alpine
root@server:~# docker logs hostweb2 | tail -2
nginx: [emerg] bind() to [::]:80 failed (98: Address in use)

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.

The whole page in three rules

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

  1. Named volumes for anything the app owns. Databases, uploads, state. You now know where the files live (/var/lib/docker/volumes/), what survives (rm, recreates, updates), and what kills them (down -v, anonymous volumes).
  2. One user-defined network per stack. Compose does it for you. Containers find each other by name through DNS at 127.0.0.11, and never through hardcoded IPs.
  3. Publish only what the internet needs. Usually that is one web port, and eventually just one reverse proxy. Everything else stays internal, where ufw cannot be bypassed because there is no doorway to bypass it through.

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

Questions people actually ask

Where are Docker volumes stored on disk?

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.

What is the difference between a Docker volume and a bind mount?

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.

Does docker rm delete my data?

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.

Will updating a container image delete my data?

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.

What does docker compose down -v do?

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.

Why can't my container reach my database by name?

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.

Should I publish my database port with -p 5432:5432?

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.

When should I use host networking?

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.

What is an anonymous volume?

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.


  • Updated August 2026
  • Ubuntu 24.04
  • Docker 29.6.2
  • Compose v5.3.1
  • Reading ~20 minutes
  • Difficulty Beginner-friendly
Last verified: August 11, 2026. Every command and output on this page captured on a fresh Ubuntu 24.04 DigitalOcean droplet: Docker 29.6.2, Compose v5.3.1, Postgres 16.3 → 16.14, with the port probes run from outside the server.

Want the whole self-hosting path, in order?

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.

Hasan Aboul Hasan giving a thumbs up

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.

Vibe Engineering Blocks — free guide
Free guide

Get the free Vibe Engineering Blocks guide

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

Free PDF · double opt-in · unsubscribe anytime.

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.

Loading questions…