Mert Özen Further With Every Line
DevOps Docker — #11

Volumes, Bind Mounts and tmpfs: Keeping Data in Containers

Mert Özen Nov 14, 2026 15 min 14 views
Volumes, Bind Mounts and tmpfs: Keeping Data in Containers

A container's writable layer goes away when the container is deleted; data that must persist belongs on a named volume, files from the host on a bind mount and temporary data on tmpfs. With real output we run BookstoreDb on a SQL Server 2025 container with a volume, keep the data across container deletion, back it up with BACKUP DATABASE and tar, fix the non-root SQL Server's Permission denied on a bind mount, use tmpfs for tests and avoid anonymous volumes.

Version note: The examples in this post were run with Docker Engine 29.9.3, SQL Server 2025 (RTM-CU11, mcr.microsoft.com/mssql/server:2025-latest), alpine:3 and nginxinc/nginx-unprivileged:1.30-alpine.

Guess First

You started a SQL Server container without any -v flag, created BookstoreDb in it and added six books. Then you deleted the container with docker rm -f and ran the same docker run command again. Is the database still there? Second question: if you run docker run --rm -v /data alpine:3 ... a hundred times, how many volumes pile up on disk?

The Container's Writable Layer Is Temporary

In post 4 we saw that an image is made of read-only layers and that a container adds a thin writable layer of its own on top. Every file written inside the container goes to that layer, and the layer lives as long as the container: it survives docker stop and docker start, and it is deleted by docker rm. That is exactly what we want for application code; for a database it is a disaster.

In this post we run BookstoreDb, the database of Bookstore, the online bookstore used as the shared example across this site's series, in a SQL Server 2025 container. First we checked whether the image itself sets aside a place for data:

docker image inspect mcr.microsoft.com/mssql/server:2025-latest \
    --format 'User={{.Config.User}} Volumes={{json .Config.Volumes}}'
User=mssql Volumes=null

The image has no VOLUME definition. So without -v, all of SQL Server's data and log files under /var/opt/mssql are written to the container's writable layer. The commands in the examples are for bash in WSL 2; we defined the SA password in the shell first with export MSSQL_SA_PASSWORD=... and passed it with -e MSSQL_SA_PASSWORD without a value. Docker takes the value from the shell, and the password does not end up in the command history. The db/01-bookstore.sql script creates the schema and the sample books; we give the script to the container through a read-only bind mount (more on that shortly):

docker run -d --name dockerseries-db -e ACCEPT_EULA=Y -e MSSQL_SA_PASSWORD \
    -v "$PWD/db:/scripts:ro" mcr.microsoft.com/mssql/server:2025-latest

docker exec -e SQLCMDPASSWORD="$MSSQL_SA_PASSWORD" dockerseries-db \
    /opt/mssql-tools18/bin/sqlcmd -C -S localhost -U sa -i /scripts/01-bookstore.sql
Changed database context to 'BookstoreDb'.

(6 rows affected)

sqlcmd reads the password from the SQLCMDPASSWORD environment variable, so there is no need to put it on the command line with -P. We deleted the container, started it again with the same command and listed the user databases:

docker rm -f dockerseries-db
docker run -d --name dockerseries-db -e ACCEPT_EULA=Y -e MSSQL_SA_PASSWORD \
    -v "$PWD/db:/scripts:ro" mcr.microsoft.com/mssql/server:2025-latest
docker exec -e SQLCMDPASSWORD="$MSSQL_SA_PASSWORD" dockerseries-db \
    /opt/mssql-tools18/bin/sqlcmd -C -S localhost -U sa -h -1 \
    -Q "SET NOCOUNT ON; SELECT name FROM sys.databases WHERE database_id > 4"

The output is empty. The answer to the first question: the database is gone, because the new container started with a new writable layer. The log also shows SQL Server setting up its system databases from scratch. Any data that has to outlive the container must live outside it, in a mount.

BookstoreDb on a Named Volume

Docker's answer to this is the named volume: a directory that Docker creates itself, that has a name and that is not deleted when the container is. We attached /var/opt/mssql, which holds all of SQL Server's files, to a volume:

docker volume create dockerseries-sqldata
docker run -d --name dockerseries-db -e ACCEPT_EULA=Y -e MSSQL_SA_PASSWORD \
    -p 127.0.0.1:1433:1433 \
    -v dockerseries-sqldata:/var/opt/mssql \
    -v "$PWD/db:/scripts:ro" \
    mcr.microsoft.com/mssql/server:2025-latest

If the left-hand side of -v does not start with / or ., Docker treats it as a volume name; if it does, it is a path on the host, that is, a bind mount. docker volume create is optional, since docker run creates a missing volume by itself, but the separate command makes it clear that the volume is a deliberate resource. We published the port on 127.0.0.1: SQL Server should be reachable from tools on the host (for example the SQL Server extension for Visual Studio Code) only from this machine, and other machines on the network should not see 1433. We will see why this matters in post 12.

We ran the script, added one more book at run time, then deleted the container and created it again with the same command:

docker exec -e SQLCMDPASSWORD="$MSSQL_SA_PASSWORD" dockerseries-db \
    /opt/mssql-tools18/bin/sqlcmd -C -S localhost -U sa -d BookstoreDb \
    -Q "INSERT INTO dbo.Books VALUES ('9780547928227', N'The Hobbit', N'J. R. R. Tolkien', 260.00)"
docker rm -f dockerseries-db
# ... the same docker run command ...
docker exec -e SQLCMDPASSWORD="$MSSQL_SA_PASSWORD" dockerseries-db \
    /opt/mssql-tools18/bin/sqlcmd -C -S localhost -U sa -d BookstoreDb \
    -Q "SET NOCOUNT ON; SELECT COUNT(*) AS Books FROM dbo.Books"
Books
-----------
          7

Seven books: the six from the script and The Hobbit, added later. The container is gone, the data stayed. Upgrading SQL Server to a new CU works the same way: a new container from the new image, the same volume. That is why we wrote the script so that it can run again; it creates the database and the table only if they are missing, and adds the books with MERGE only if they are not there:

IF DB_ID(N'BookstoreDb') IS NULL
    CREATE DATABASE BookstoreDb;
GO

USE BookstoreDb;
GO

IF OBJECT_ID(N'dbo.Books', N'U') IS NULL
    CREATE TABLE dbo.Books
    (
        Isbn     char(13)      NOT NULL CONSTRAINT PK_Books PRIMARY KEY,
        Title    nvarchar(200) NOT NULL,
        Author   nvarchar(200) NOT NULL,
        PriceTry decimal(10,2) NOT NULL
    );
GO

MERGE dbo.Books AS target
USING (VALUES
    ('9780441172719', N'Dune', N'Frank Herbert', 420.00),
    ('9789750719387', N'İnce Memed', N'Yaşar Kemal', 310.00),
    ('9789750738609', N'Çalıkuşu', N'Reşat Nuri Güntekin', 240.00),
    ('9789754700114', N'Saatleri Ayarlama Enstitüsü', N'Ahmet Hamdi Tanpınar', 280.00),
    ('9780141439587', N'Emma', N'Jane Austen', 180.00),
    ('9789750722752', N'Kürk Mantolu Madonna', N'Sabahattin Ali', 150.00)
) AS source (Isbn, Title, Author, PriceTry)
ON target.Isbn = source.Isbn
WHEN NOT MATCHED THEN
    INSERT (Isbn, Title, Author, PriceTry) VALUES (source.Isbn, source.Title, source.Author, source.PriceTry);
GO

One of the log lines tells us something else about the image: when MSSQL_PID is not set, the SQL Server 2025 container starts as "Enterprise Developer Edition". The Developer editions are for development and test only; production licensing is a separate topic.

Three Mount Types: Volumes, Bind Mounts and tmpfs

There are three ways to attach something from outside to a container's file system, and the command above uses two of them together:

TypeWhere does the data live?Who manages it?Typical use
Named volumeThe engine's own area (/var/lib/docker/volumes/)Docker (docker volume commands)Database files, persistent application data
Bind mountA path you choose on the hostYou (the host's file system and permissions)Giving source code, config and scripts to a container
tmpfsThe host's memoryNobody; deleted when the container stopsTemporary files, throwaway test data

The db/ folder is a bind mount, because the file's real home is the repository and we want the container to see it as soon as you change it in the editor. The :ro at the end keeps the container from changing these files; always mount config and scripts you hand to a container read-only. The database files go on a volume, because we do not need them in a particular place on the host and letting Docker manage their permissions makes life easier. docker inspect tells the two apart:

docker inspect dockerseries-db \
    --format '{{range .Mounts}}{{.Type}} {{.Name}}{{.Source}} -> {{.Destination}} rw={{.RW}}{{println}}{{end}}'
bind /home/user/bookstore/db -> /scripts rw=false
volume dockerseries-sqldata/var/lib/docker/volumes/dockerseries-sqldata/_data -> /var/opt/mssql rw=true

You can write the same thing with --mount: --mount type=volume,source=dockerseries-sqldata,target=/var/opt/mssql. It is longer, but it is easier to read because every field is named, and there is one difference: in a bind mount, if the host path does not exist, -v silently creates it as an empty folder owned by root, while --mount fails:

docker run --rm --mount type=bind,source=/srv/missing,target=/data alpine:3 true
docker: Error response from daemon: invalid mount config for type "bind": bind source path does not exist: /srv/missing

When you mistype a path, an immediate error is usually better than a container that runs with an empty folder; that is the reason to prefer --mount in scripts and documentation.

Managing Volumes

docker volume ls --filter name=dockerseries
docker volume inspect dockerseries-sqldata
docker system df -v | grep -A3 "^VOLUME NAME"
DRIVER    VOLUME NAME
local     dockerseries-sqldata
[
    {
        "CreatedAt": "2026-11-14T12:15:19Z",
        "Driver": "local",
        "Labels": null,
        "Mountpoint": "/var/lib/docker/volumes/dockerseries-sqldata/_data",
        "Name": "dockerseries-sqldata",
        "Options": null,
        "Scope": "local"
    }
]
VOLUME NAME            LINKS     SIZE
dockerseries-sqldata   1         152.2MB

Mountpoint is the path on the machine the engine runs on. On a Linux server it is that folder itself; on Docker Desktop the engine runs in a Linux VM in WSL 2, so the path is inside that VM. Rather than hunting for this path, mounting the volume into a temporary container to look inside works everywhere. LINKS is the number of containers using the volume. Even an empty SQL Server installation with a small database takes 152 MB; the system databases and log files reserve space up front.

The cleanup command is docker volume prune. Since Engine 23.0 it removes by default only anonymous volumes (volumes without a name, which Docker names with a hash) that no container uses; to also remove unused named volumes it needs --all. So a docker volume prune command from an old blog post behaves differently today. In this series we do not run prune on shared machines; we delete volumes by name, with docker volume rm dockerseries-sqldata. Knowing what you delete before you delete it is the only safe way when a database is involved.

Backup and Restore

A volume survives the container being deleted, but not a failing disk, a wrong rm or a broken migration. We tried two ways. The first is the database's own backup; SQL Server takes a consistent backup while it is running:

docker exec -e SQLCMDPASSWORD="$MSSQL_SA_PASSWORD" dockerseries-db \
    /opt/mssql-tools18/bin/sqlcmd -C -S localhost -U sa \
    -Q "BACKUP DATABASE BookstoreDb TO DISK = N'/var/opt/mssql/backup/BookstoreDb.bak' WITH INIT, COMPRESSION"
mkdir -p backup
docker cp dockerseries-db:/var/opt/mssql/backup/BookstoreDb.bak backup/
Processed 416 pages for database 'BookstoreDb', file 'BookstoreDb' on file 1.
Processed 2 pages for database 'BookstoreDb', file 'BookstoreDb_log' on file 1.
BACKUP DATABASE successfully processed 418 pages in 0.027 seconds (120.804 MB/sec).

The second is taking the whole volume as a file. The docker CLI has no separate command for volume backups; the method is to mount the volume and a folder on the host into a small container and run tar. Copying database files while they are in use can produce an inconsistent backup, so we stopped the container first:

docker stop dockerseries-db
docker run --rm -v dockerseries-sqldata:/data:ro -v "$PWD/backup:/backup" alpine:3 \
    tar czf /backup/sqldata.tar.gz -C /data .
ls -lh backup
-rw-rw----    1 root     root      516.0K Nov 14 12:15 BookstoreDb.bak
-rw-r--r--    1 root     root        7.4M Nov 14 12:15 sqldata.tar.gz

Restoring is the reverse: a new volume, tar xzf into it, then SQL Server started on that volume:

docker volume create dockerseries-sqldata-restored
docker run --rm -v dockerseries-sqldata-restored:/data -v "$PWD/backup:/backup:ro" alpine:3 \
    sh -c "tar xzf /backup/sqldata.tar.gz -C /data && ls -ln /data"
docker rm dockerseries-db
docker run -d --name dockerseries-db -e ACCEPT_EULA=Y -e MSSQL_SA_PASSWORD \
    -v dockerseries-sqldata-restored:/var/opt/mssql mcr.microsoft.com/mssql/server:2025-latest
# ... SELECT COUNT(*) FROM dbo.Books ...
drwxr-xr-x    2 10001    10001         4096 Nov 14 12:15 backup
drwxr-xr-x    2 10001    10001         4096 Nov 14 12:15 data
drwxr-xr-x    2 10001    10001         4096 Nov 14 12:15 log
drwxr-xr-x    2 10001    10001         4096 Nov 14 12:15 secrets
Books
-----------
          7

The seven books are back. Since tar runs as root, it keeps uid 10001 as the owner of the files; this detail matters in the next section. The two methods differ in scope: a .bak is the backup of a single database, it can be RESTOREd on another SQL Server (in a container or not) and it does not require stopping the running system. The volume tar carries the whole state of the server (system databases, logins, settings) but needs the container to be stopped. In production the main backup path is the database's own backup mechanism; backup strategy and RESTORE details belong to the SQL Server Deep Dive series.

Permissions: a Non-root SQL Server and Bind Mounts

Since SQL Server 2019 the container images start with the non-root mssql user; the id command in the container shows uid=10001(mssql). On the named volume this user caused no trouble, because when an empty volume is mounted for the first time Docker copies the content, owner and permissions of the image's /var/opt/mssql folder into the volume. In the image that folder belongs to root, but its group is mssql (gid 10001) and the group can write (drwxrwx---). A bind mount copies nothing: the container sees the host folder as it is, with the host's owner and permissions. Say we want to keep the database files under /srv on a Linux server:

sudo mkdir -p /srv/bookstore/mssql
docker run -d --name dockerseries-db -e ACCEPT_EULA=Y -e MSSQL_SA_PASSWORD \
    -v /srv/bookstore/mssql:/var/opt/mssql mcr.microsoft.com/mssql/server:2025-latest
docker ps -a --filter name=dockerseries-db --format "{{.Names}}: {{.Status}}"
docker logs dockerseries-db 2>&1 | tail -1
dockerseries-db: Exited (1) 14 seconds ago
/opt/mssql/bin/sqlservr: Error: The system directory [/.system] could not be created. File: /mnt/vss/_work/1/s/sqlpal/Hosts/Linux/LinuxDirectory.cpp:420 [Status: 0xC0000022 Access denied errno = 0xD(13) Permission denied]

We created the folder with sudo, so its owner is root and its mode is 755; uid 10001 cannot write there, and SQL Server exits while creating its first file. The path in the message (/.system) is an internal path of SQL Server's Linux layer, so do not let it confuse you; the important part is the Permission denied at the end. The fix in Microsoft's documentation is to give the folder to the user in the container:

sudo chown -R 10001:0 /srv/bookstore/mssql

Started with the same command, SQL Server came up and created the data, log and secrets folders owned by uid 10001. A uid may have no name on the host, which is why ls -l shows 10001 instead of mssql. The container and the host share the same kernel and the same uid numbers; numbers match, names do not.

A note for readers on Windows: on Docker Desktop the speed of a bind mount depends on where the file lives. Docker's WSL documentation recommends keeping files you bind mount in the WSL distribution's Linux file system (somewhere like ~/projects/...), not on the Windows drive (/mnt/c/...): every access to the Windows file system crosses the VM boundary and is slow, and file change notifications (the inotify events hot reload relies on) only work for files on the Linux side. We put the database files on a volume rather than a bind mount anyway; volumes live on the VM's own disk and are not affected by this.

tmpfs: a Throwaway Database for Tests

A tmpfs mount writes to memory instead of disk, and its content is deleted when the container stops; it exists only on engines running on Linux (the engine in Docker Desktop is Linux too). A scenario where persistence is unwanted: integration tests want a clean database on every run, and disk I/O slows them down. We made SQL Server's whole file area a tmpfs:

docker run -d --name dockerseries-db -e ACCEPT_EULA=Y -e MSSQL_SA_PASSWORD \
    --mount type=tmpfs,destination=/var/opt/mssql,tmpfs-size=2g,tmpfs-mode=1777 \
    -v "$PWD/db:/scripts:ro" mcr.microsoft.com/mssql/server:2025-latest
# ... run the script ...
docker exec dockerseries-db df -h /var/opt/mssql
docker restart dockerseries-db
# ... is BookstoreDb there? ...
Filesystem      Size  Used Avail Use% Mounted on
tmpfs           2.0G  145M  1.9G   8% /var/opt/mssql
          0

The script ran, and the database took 145 MB of memory. After docker restart, the count of BookstoreDb in sys.databases is 0: tmpfs content goes away every time the container stops, without even waiting for docker rm. If you do not set tmpfs-size there is no limit, and according to Docker's documentation it can in practice grow up to half of the host's memory; on a CI runner with a memory limit, always set a size. tmpfs-mode=1777 makes the folder writable for everyone, like /tmp; the non-root mssql user depends on that to write. The default is 1777 as well, but spelling it out in the command makes the dependency visible. The Integration and E2E Test series covers managing the container's lifecycle from the test itself with Testcontainers; tmpfs is used there with the same flag.

In Production

According to Microsoft's documentation, SQL Server container images are supported only on Linux hosts with x86-64 (Intel/AMD) processors; with Docker Desktop on an x64 Windows machine there is no problem. If you are thinking of moving to ARM-based servers, running the database in a container there is not an option. SQL Server needs at least 2 GB of memory; set the memory given to WSL 2 on Docker Desktop accordingly.

Do not decide to run the production database in a container out of habit. What a container brings (the same image, quick setup) is small for a database; the real work is backups, restore tests, disk performance, high availability and an upgrade plan. A managed database service (Azure SQL, Amazon RDS) does these for you. For development, test, demos and small internal applications, SQL Server in a container is a very good choice. The Kubernetes series post "Storage and Stateful Apps in Kubernetes: Volumes, PersistentVolumeClaims, StatefulSets and the Database Decision" discusses the same decision from the cluster's point of view.

Taking backups is not enough; try restoring them. Opening the backup into a new volume, starting the container and running one query as above takes a few minutes; far cheaper than trying it for the first time during a real outage.

Put ownership information on your volumes: labels such as docker volume create --label app=bookstore --label data=database dockerseries-sqldata let you find what must not be deleted a few months later with docker volume ls --filter label=data=database.

Common Pitfalls

Piling up anonymous volumes. To answer the second question we tried two commands:

docker run --name dockerseries-anon -v /data alpine:3 sh -c "echo order-42 > /data/note.txt"
docker rm dockerseries-anon
docker volume ls
docker run --rm -v /data alpine:3 sh -c "echo order-43 > /data/note.txt"
docker volume ls
DRIVER    VOLUME NAME
local     95f2ffb717796efde96b7ac5660261a40e34c53ef3d0a8a4cf9ea25f0bc61aed
DRIVER    VOLUME NAME
local     95f2ffb717796efde96b7ac5660261a40e34c53ef3d0a8a4cf9ea25f0bc61aed

(I removed the other volumes.) -v /data, with only a target path, creates an anonymous volume. docker rm does not delete it (docker rm -v would), and the hash-named volume stays on disk with data nobody remembers. A container run with --rm, on the other hand, deletes its anonymous volumes along with itself; the second command left no new volume behind. So the answer is zero, but if you run the same command without --rm and delete the containers with docker rm, it is a hundred. The same happens when you run a database whose image has a VOLUME definition (many official images do) without -v: the data is on an anonymous volume, and when you recreate the container, the new container gets a new, empty volume. Always give a database a named volume.

Mounting a volume over application files. An empty volume is filled with the image's content, and a non-empty volume hides the image's content. We tried putting bookstore-web's files on a volume, then mounted the same volume with the plain nginx image:

docker run -d --name dockerseries-web -e API_BASE_URL=http://localhost:8080 \
    -v dockerseries-webroot:/usr/share/nginx/html dockerseries-bookstore-web:1.0.0
docker run --rm -v dockerseries-webroot:/data alpine:3 ls /data
docker rm -f dockerseries-web
docker run -d --name dockerseries-web -p 8081:8080 \
    -v dockerseries-webroot:/usr/share/nginx/html nginxinc/nginx-unprivileged:1.30-alpine
curl -s localhost:8081/ | grep -o "<title>.*</title>"
50x.html
config.json
favicon.ico
index.html
main-BCJ2DRL6.js
styles-5INURTSO.css
<title>BookstoreWeb</title>

Even a different image kept serving bookstore-web. The same happens with a new release: if you start the 1.0.1 image with the same volume, users see the files of 1.0.0, and the image update changes nothing. Volumes are for data; the application itself belongs in the image. When you do not want the volume to be filled, add the volume-nocopy option to --mount.

Deleting data with a single command. docker volume prune --all deletes every unused named volume; if the database container happens to be stopped at that moment, its volume counts as "unused" too. If you use Docker Compose, docker compose down -v also deletes the volumes in the compose file; the details are in the Docker Compose series. Think twice before putting either into an automatic cleanup script.

Leaving backups in the build context. We created the backup/ folder at the repository root, and Bookstore.Api's Dockerfile uses the root as its build context; unless you add it to .dockerignore, every build sends the 8 MB archive (and, on a real system, customer data) to BuildKit. We added one line to .dockerignore:

backup/

A Small Experiment

★☆☆ Recreate the dockerseries-sqldata volume with --label app=bookstore, start SQL Server with it and look at the output of docker volume ls --filter label=app=bookstore. Does the volume stay in the list after you delete the container?

★★☆ Copy BookstoreDb.bak with docker cp into a second SQL Server container started with a new, empty volume, and restore it with RESTORE DATABASE BookstoreDb FROM DISK = .... Which user needs read permission on the file?

★★★ Try the bind mount example with a combination of docker run --user 10001:0 and chmod g+rwx instead of chown. Why does the "root group" advice in Microsoft's documentation work?

BookstoreDb now lives on a volume, independent of containers. But Bookstore.Api still cannot see it: the API runs in a separate container, and the two containers do not know about each other. In the next post we move on to Docker networks: the difference between the default bridge and a user-defined network, DNS resolution by container name, the API connecting to the database with Server=bookstore-db, and safe defaults for port publishing.