Skip to content

Why Your Uploaded Files Vanish After Every Deploy: Docker Volumes and Networks

Your uploads are not lost by your application — they are lost by the container. What the writable layer, volumes, and bind mounts actually do across a redeploy, why the same logic breaks hardcoded container IPs, and a ten-minute experiment you can run yourself.

The one-line idea: containers are disposable — a deploy replaces the container, taking the files written inside it and the IP address it was handed along with it. Data belongs in a volume; addresses belong in a service name.

Who it's for: anyone who has run docker run but cannot quite say how volumes and container networking work, and anyone who has deployed and then found the uploaded files gone.

What you can do after reading: work out which data in your project will be lost and which addresses will break, then reproduce the whole thing with one container in ten minutes.

Your uploads worked on Friday. You deploy again on Monday, and every image uploaded over the weekend is a broken link. The database still has the rows. The files are gone.

Nothing in your code changed, and your application did not delete anything. The container that wrote those files does not exist anymore. That is the whole bug.

A deploy does not update your container. It builds or pulls an image and starts a new container from it. Anything you addressed by identity instead of by name goes with the old one: the filesystem it wrote to, and the IP address it was handed. Volumes fix the first half. Service names fix the second. Both halves are the same lesson, and both are documented, which is why they are so easy to walk into.

1. A deploy replaces the container, it does not update it

Start with the sentence that explains the missing uploads.

"By default all files created inside a container are stored on a writable container layer that sits on top of the read-only, immutable image layers. Data written to the container layer doesn't persist when the container is destroyed."

Your image is read-only. Everything your app writes at runtime — uploads, generated thumbnails, logs, a SQLite file, a cache — lands in a thin writable layer that belongs to that one container. The Docker documentation is blunt about what that layer is worth:

"The writable layer is unique per container. You can't easily extract the data from the writeable layer to the host, or to another container."

So when does it get destroyed? On deploy. Compose documents the exact behaviour:

"If there are existing containers for a service, and the service's configuration or image was changed after the container's creation, docker compose up picks up the changes by stopping and recreating the containers (preserving mounted volumes)."

Read that sentence twice, because it contains both halves of this article. It says recreating the containers — and it says preserving mounted volumes. A self-hosted PaaS such as Coolify does the same thing on its own schedule: it replaces the container. Whichever path your deploy takes, the outcome for the writable layer is identical, because there is a new container and its writable layer started empty.

The distinction that makes this diagnosable is restart versus replace:

What you didContainerFiles written inside it
docker restart appThe same containerStill there
docker compose restartThe same containerStill there
docker compose up -d after a changeA new containerGone
docker compose down then up -dA new containerGone
docker rm appDestroyedGone

That table is worth keeping. If your users only lose files after a deploy, and never after a restart, you are looking at the writable layer, and you can stop reading application logs for the cause.

2. Four pieces, and only one of them survives

The whole model fits in four lines. Learn it once and the rest of the article is detail.

Image      read-only template, built once, shared by every container that uses it
Container  a running instance of the image + its own writable layer   ← disposable
Volume     storage that lives outside the container's filesystem       ← survives
Network    a virtual network containers are attached to, with an IP     ← recreated too

One image can start ten containers, and each gets its own writable layer — which is exactly why the files exist on one container and not on its neighbour. Volumes are the only one of the four that is designed to outlive the container it is attached to:

"A volume's contents exist outside the lifecycle of a given container. When a container is destroyed, the writable layer is destroyed with it. Using a volume ensures that the data is persisted even if the container using it is removed."

Network sits in the same column as the container for a different reason, and we will come back to it in section 5.

3. Three places your files can go

Now the practical decision. For every path your app writes to, you have exactly three options.

Where the file goesWhat it isSurvives a redeploySurvives down -v
Nothing mountedThe container's writable layerNoNo
Named volume (uploads:/app/media)A Docker-managed directory on the hostYesNo — this is the one people delete by accident
Bind mount (./uploads:/app/media)A directory you chose on the hostYesYes — it is just a host directory

A named volume in Compose looks like this. The name on the left of the colon is a key, not a path:

services:
  app:
    image: ghcr.io/your-org/your-app:sha-1a2b3c4
    volumes:
      - uploads:/app/media        # named volume
      - ./config:/app/config:ro   # bind mount, read-only

volumes:
  uploads:                        # declares the volume Compose should manage

Mounted volumes are preserved across a redeploy. That is the fix, and for most apps it is the entire fix: find every directory your application writes to, and mount something there.

Bind mounts are the other half of the answer, and the difference between the two is smaller than the arguments about them suggest. Both put the data outside the container. A volume is managed by Docker and lives in Docker's storage directory; a bind mount points at a path you picked, so you can read it with ls and back it up with tar. Use a volume for data your app owns and you want Docker to keep track of. Use a bind mount when you want to reach the files from the host — config files, or data you want in your existing backup job.

4. The trap: a mount can hide files instead of saving them

Everything above is the happy path. Here are the three ways people do the right thing and still lose files.

Mounting over a directory that already has content hides it. Both volumes and bind mounts behave this way:

"If you mount a non-empty volume into a directory in the container in which files or directories exist, the pre-existing files are obscured by the mount."

This is the classic "my app's built assets vanished" bug. Your image ships files at /app/static, someone mounts a volume or an empty host directory at /app/static to "make it persistent", and the build output is now hidden behind the mount. The documentation's advice is uncomfortable but correct: there is no straightforward way to unmount and reveal them, so your best option is to recreate the container without the mount.

An empty volume is populated once, not on every deploy. This one is subtle and it bites later:

"If you mount an empty volume into a directory in the container in which files or directories exist, these files or directories are propagated (copied) into the volume by default."

Note the condition: empty volume. The copy happens when the volume is first created. If a later image ships new or changed files at that path, the volume is no longer empty, so nothing is copied, and the container keeps serving the old content from the volume. The image and the volume have quietly swapped roles. If you want to opt out of the copy entirely, --mount ... ,volume-nocopy does that.

A bind mount does not copy anything at all. Volumes auto-populate; bind mounts do not. What is on the host is what the container sees, even if the host directory is empty. There is also a footgun on the way in — -v creates a missing host directory for you, but --mount refuses:

$ docker run --mount type=bind,src=/dev/noexist,dst=/data alpine
docker: Error response from daemon: invalid mount config for type "bind": bind source path does not exist: /dev/noexist.

If you need --mount to create the source directory, the option is bind-create-src. The safer habit is to create the directory yourself, so that a typo in a path can never silently become an empty directory that your app happily writes to.

5. The same deploy also invalidates your container addresses

Here is the part of the title people skip, and it is the same mistake in a different costume: relying on something that was assigned to a container instead of addressing it by name.

Every container gets an IP address when it joins a network, and that address is not an identity:

"By default, the container gets an IP address for every Docker network it attaches to. A container receives an IP address out of the IP subnet of the network. The Docker daemon performs dynamic subnetting and IP address allocation for containers."

Now put that next to what a deploy does. docker compose down removes the containers and the default network; the next up creates a new network and new containers, and the addresses are allocated again. A config value like this is therefore not a durable fact about your stack:

services:
  app:
    environment:
      DB_HOST: 172.18.0.5        # an address that was true one deploy ago

It may still work. That is what makes it dangerous: on a quiet host the daemon often hands back the same address, so the mistake survives testing and then breaks the day a second container, a second network, or a second server shows up. I have not run this in a lab that forces the address to change, and I will not pretend I have tested the failure. What I am relying on is the documented behaviour above: allocation is dynamic, so nothing guarantees the number stays.

The fix is a name, and Docker gives you one for free on any user-defined network:

"Once connected to a user-defined network, containers can communicate with each other using container IP addresses or container names."

That resolution is served by Docker's embedded DNS server at 127.0.0.11, which is injected into containers on custom networks. Compose gives every stack its own network by default, so DB_HOST: db works because db is the service name, and it keeps working across redeploys.

Two supporting details worth knowing:

  • The default bridge network does not do this. "With the default configuration, containers attached to the default bridge network have unrestricted network access to each other using container IP addresses. They cannot refer to each other by name." Name resolution there needs the legacy --link flag, which is a good reason to never rely on the default bridge for a multi-container app.
  • Ports stop being your problem inside a network. "Containers connected to the same user-defined bridge network effectively expose all ports to each other." You publish with -p when something outside the network — the host, the reverse proxy, the internet — needs to reach the container. Your database does not need a published port to be reachable by your app, and publishing one is a separate decision with its own risk.

If you genuinely need a stable alias rather than the service name, you can add one without renaming anything:

$ docker network connect --alias db-primary my-net mydb

6. The experiment

Everything above is documented behaviour, and you should still watch it happen, because the memory of the second command's output is what stops you from debugging your application the next time this occurs.

# 1. Write a file inside a container.
$ docker run -d --name writer busybox sh -c 'echo hello > /data/note.txt; sleep 3600'
$ docker exec writer cat /data/note.txt

# 2. Replace the container, the way a deploy does.
$ docker rm -f writer
$ docker run -d --name writer busybox sh -c 'ls -la /data 2>&1; sleep 3600'
$ docker logs writer

# 3. Do it again with a volume in the way.
$ docker volume create demo-data
$ docker run -d --name writer -v demo-data:/data busybox sh -c 'echo hello > /data/note.txt; sleep 3600'
$ docker rm -f writer
$ docker run -d --name writer2 -v demo-data:/data busybox sh -c 'cat /data/note.txt; sleep 3600'
$ docker logs writer2

# 4. Watch an address get handed out again.
$ docker network create demo-net
$ docker run -d --name target --network demo-net busybox sleep 3600
$ docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' target
$ docker rm -f target
$ docker run -d --name target --network demo-net busybox sleep 3600
$ docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' target
$ docker run --rm --network demo-net busybox nslookup target

# 5. Clean up.
$ docker rm -f writer2 target
$ docker volume rm demo-data
$ docker network rm demo-net

The script is the whole point of the article in five steps: the file is real, the container that holds it is not; the same file in a volume outlives the container; the address does not outlive the network; and the name does.

7. When a volume is the wrong answer

A volume makes data survive a deploy. It does not make data safe, and it is not always the right home.

A volume is a directory on one host. It is not a backup, and it does not replicate. If the machine dies, or you rebuild it, the volume goes with it. It also has two documented deletion paths that surprise people:

  • docker compose down -v removes "named volumes declared in the volumes section of the Compose file and anonymous volumes attached to containers". Without -v, down leaves named volumes alone. Note that networks and volumes marked external are never removed by Compose.
  • docker volume prune removes all unused volumes. Never run it on a machine whose data you have not backed up.

Anonymous volumes fail quietly after a redeploy. They are not deleted by docker compose down, which feels reassuring until you read the next sentence: because they have no stable name, they are not automatically mounted by a subsequent up. The old volume is still occupying disk, unconnected to anything.

Uploads are usually the wrong fit for a volume. This is the honest conclusion from our own version of this bug: a media app of ours wrote uploads to local disk, and we moved them to object storage. That write-up is How to Go from Syntax to Shipping, and it is this article's practical half. The files stopped being something the container had to hold, which is also the version of this problem that survives moving to a second server. A volume keeps uploads on one machine; object storage is addressed by URL, not by where the container happens to be running. Your database files, by contrast, are exactly what a named volume is for — with real backups, separately tested, because a volume is not one.

The short version: database data in a named volume, files you serve over HTTP in object storage, build output in the image, and source code in a bind mount during development. This is the first article in the DevOps Automation branch that is pure fundamentals; everything else in that branch assumes you can tell a container from the data it is holding.

8. FAQ

Why can't my container reach the database at localhost? Because every container has its own network namespace, and localhost inside it means that container. There is no shared loopback. Use the service name on a Compose network.

I changed an environment variable and nothing happened. Environment variables are applied when the container is created. A running container keeps the values it started with. Recreate it — docker compose up -d does that when the configuration changed, and --force-recreate does it regardless.

docker restart — do I lose the files? No. Restarting keeps the same container and its writable layer. Only destroying the container takes the writable layer with it.

How do I check whether a path in a running container is actually mounted?

$ docker inspect -f '{{json .Mounts}}' <container>

Anything your application writes to that is not in that list is going away at the next deploy. That one command answers this article's question for your own stack.

Will docker compose down delete my data? Not by default. It removes containers and the networks it created. Named volumes survive unless you pass -v.

How much disk are my volumes using?

$ docker system df -v

Unused volumes are a common cause of a full disk on a small VPS, which is its own outage — the disk fills, the container cannot write, and it looks like an application bug.

Is a volume faster than writing inside the container? Yes, and the documentation gives the reason: writing into the writable layer requires a storage driver to manage a union filesystem, while volumes write directly to the host filesystem. It matters little for a few uploads and a lot for anything I/O-heavy.

9. What to do next

Run this against your own production containers:

$ docker inspect -f '{{range .Mounts}}{{.Type}} {{.Source}} -> {{.Destination}}{{"\n"}}{{end}}' <container>

Then list the paths your application writes to, and put the two side by side. Anything in the second list that is not in the first will disappear at your next deploy — and now you know whether the fix belongs in a volume, in a bind mount, or in object storage. The next time uploads go missing, you will look at the container instead of at the application, and you will be right in about a minute.

Related Reading