DevOps

Advanced Docker Volumes and Networking Patterns: Persistent Storage and Isolation for Complex Microservice Stacks

Ruslan Ismailov Published 18 min read
A

Introduction: Why Proper Volumes and Networking Are the Foundation of a Reliable Docker Environment

Containerization is no longer an experimental technology. Docker in production is the norm for most teams building microservice architectures. Yet this is precisely where the divide emerges between those who simply "run containers" and those who build reliable, scalable systems.

Two key aspects that define the quality of a Docker production environment are data management (Docker Volumes) and network isolation. Improper volume handling leads to data loss on container restarts, permission issues, and inability to scale. Networking misconfigurations open unwanted communication channels between services, introduce vulnerabilities, and complicate debugging.

This article is written for DevOps engineers and backend developers who already work with Docker in production and want to level up. We will cover advanced patterns for persistent storage, Docker's networking model from bridge to overlay, zero-trust isolation, and assemble a real-world case study with PostgreSQL, Redis, and Go microservices.

Docker Volumes: Types, Comparison, and Use Cases

Docker provides three primary mechanisms for working with data: named volumes, bind mounts, and tmpfs. The choice between them directly affects performance, portability, and security.

Named Volumes

Named volumes are the recommended way to store persistent data in Docker. They are managed by the Docker daemon, stored in the /var/lib/docker/volumes/ directory on the host, and fully isolated from the host filesystem.

Advantages: portability across environments, straightforward backup, support for volume plugins with third-party storage backends, and correct permission handling. Use named volumes for databases (PostgreSQL, Redis), message queues, and any data that must survive container restarts.

Bind Mounts

Bind mounts mount a specific directory or file from the host into the container. This is a powerful tool for development — changes to code on the host are instantly reflected inside the container. However, in production, bind mounts create a hard dependency on the host filesystem, which reduces portability and can cause permission issues (especially in rootless mode).

Use bind mounts for: mounting configuration files, sockets (e.g., /var/run/docker.sock), and in CI/CD pipelines for source code access.

tmpfs Mounts

tmpfs stores data exclusively in host RAM — it is never written to disk and disappears when the container stops. This is ideal for temporary data, sessions, caches, and anything containing sensitive information that does not require persistent storage.

Performance comparison: tmpfs provides the highest read/write speed since it operates in RAM. Named volumes on a local host are comparable in performance to direct disk access (with minor overlay filesystem overhead). Bind mounts can be slower on macOS and Windows due to the synchronization mechanism between the host and the virtual machine.

# Examples of all three types declared in docker-compose.yml
services:
  app:
    image: myapp:latest
    volumes:
      # Named volume for application data
      - app_data:/var/lib/app
      # Bind mount for configuration
      - ./config/app.yaml:/etc/app/config.yaml:ro
      # tmpfs for temporary files
      - type: tmpfs
        target: /tmp
        tmpfs:
          size: 134217728  # 128MB

  postgres:
    image: postgres:16
    volumes:
      - postgres_data:/var/lib/postgresql/data

volumes:
  app_data:
    driver: local
  postgres_data:
    driver: local
    driver_opts:
      type: none
      o: bind
      device: /data/postgres  # explicit path on the host

Advanced Volume Scenarios

Volume Plugins

Docker supports a plugin system for volumes, enabling integration with external storage systems. The most popular plugins in production are:

  • docker-volume-netshare — mounting NFS, EFS, and Samba shares
  • rexray — integration with AWS EBS, GCE Persistent Disk, Azure Disk
  • local-persist — persisting data in a specified host directory with naming support
  • convoy — snapshots and backup to S3

NFS Volumes for Distributed Environments

In multi-node environments (Docker Swarm, clusters without Kubernetes), shared data access across multiple hosts is often required. NFS is a proven solution for such scenarios.

# Creating an NFS volume via CLI
docker volume create \
  --driver local \
  --opt type=nfs \
  --opt o=addr=192.168.1.100,rw,nfsvers=4 \
  --opt device=:/exports/postgres_data \
  postgres_nfs_volume

# Declaring an NFS volume in docker-compose.yml
volumes:
  shared_data:
    driver: local
    driver_opts:
      type: nfs
      o: "addr=192.168.1.100,rw,nfsvers=4,hard,intr"
      device: ":/exports/shared_data"

Shared Volumes Between Services

Sometimes multiple containers need access to the same data — for example, an application and a sidecar container for log processing or static asset generation. This is achieved using the volumes-from pattern or a shared named volume.

services:
  app:
    image: goapp:latest
    volumes:
      - shared_uploads:/app/uploads

  nginx:
    image: nginx:alpine
    volumes:
      - shared_uploads:/usr/share/nginx/html/uploads:ro
    depends_on:
      - app

volumes:
  shared_uploads:

Important: when sharing volumes, ensure that the UID/GID of users inside the containers are compatible. Mismatched permissions are one of the most common causes of production errors.

Backup and Restore for Docker Volumes

Basic Pattern with a Temporary Container

The standard approach to backing up named volumes is to spin up a temporary container that mounts the target volume and creates an archive.

# Backing up the postgres_data volume to a tar archive
docker run --rm \
  -v postgres_data:/data:ro \
  -v $(pwd)/backups:/backups \
  alpine \
  tar czf /backups/postgres_data_$(date +%Y%m%d_%H%M%S).tar.gz -C /data .

# Restoring from an archive
docker run --rm \
  -v postgres_data:/data \
  -v $(pwd)/backups:/backups \
  alpine \
  tar xzf /backups/postgres_data_20260101_120000.tar.gz -C /data

PostgreSQL Backup with pg_dump

For databases, it is better to use specialized tools rather than copying files directly. Copying PostgreSQL data files while the database is running can lead to corruption.

# Logical PostgreSQL dump
docker exec postgres_container \
  pg_dump -U postgres -d mydb --format=custom \
  > backups/mydb_$(date +%Y%m%d).dump

# Restore
docker exec -i postgres_container \
  pg_restore -U postgres -d mydb --format=custom \
  < backups/mydb_20260101.dump

# Automation via cron in a dedicated container
services:
  postgres_backup:
    image: postgres:16
    environment:
      PGPASSWORD: ${POSTGRES_PASSWORD}
    volumes:
      - ./backups:/backups
    entrypoint: |
      sh -c 'while true; do
        pg_dump -h postgres -U postgres -d mydb --format=custom \
          > /backups/mydb_$$(date +%Y%m%d_%H%M%S).dump;
        find /backups -name "*.dump" -mtime +7 -delete;
        sleep 86400;
      done'
    depends_on:
      - postgres
    networks:
      - backend

Integration with Object Storage

In production, backups should be stored off-host. Use rclone or the aws cli to sync with S3-compatible storage (AWS S3, MinIO, Yandex Object Storage).

# Backup with subsequent upload to S3
docker run --rm \
  -v postgres_data:/data:ro \
  -e AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID} \
  -e AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY} \
  amazon/aws-cli \
  s3 cp - s3://my-backups/postgres/data_$(date +%Y%m%d).tar.gz \
  < <(tar czf - -C /data .)

Docker Networking Model: A Detailed Comparison

Docker supports several network types, each optimized for specific scenarios.

Bridge Network

By default, Docker creates a bridge network (docker0). Each container receives an IP address from this bridge's subnet. Containers on the same bridge network can communicate by IP, but not by name — name resolution requires user-defined bridge networks.

A user-defined bridge network provides automatic DNS resolution between containers by service name — this is its key advantage over the default network. Use user-defined bridge networks for all local multi-container applications.

Host Network

In host mode, the container uses the host's network stack directly. There is no isolation — container ports are immediately accessible on the host without port mapping. This provides maximum performance (no NAT overhead) but eliminates network isolation entirely. Use the host network only for high-throughput network applications (e.g., monitoring agents) or when performance is critical.

Overlay Network

Overlay networks enable communication between containers on different hosts — they are the foundation of Docker Swarm. They encapsulate traffic in VXLAN tunnels, providing transparent container-to-container communication regardless of the physical topology.

Macvlan

Macvlan assigns a container a MAC address of a physical network interface, making it "visible" on the physical network as a separate device. This is needed for legacy applications that expect direct connectivity to the physical network, or for network traffic monitoring. It requires promiscuous mode support on the network adapter.

# Creating an overlay network (requires an initialized Swarm)
docker network create \
  --driver overlay \
  --attachable \
  --subnet 10.10.0.0/16 \
  --opt encrypted \
  production_overlay

# Creating a macvlan network
docker network create \
  --driver macvlan \
  --subnet 192.168.1.0/24 \
  --gateway 192.168.1.1 \
  -o parent=eth0 \
  macvlan_net

Overlay Networks in Docker Swarm and Differences from Kubernetes Networking

Docker Swarm uses overlay networks as the primary communication mechanism between services. When a Swarm is initialized, an ingress network for load balancing and a docker_gwbridge for external connectivity are automatically created.

Key characteristics of overlay in Swarm:

  • Built-in service discovery via DNS — addressing by service name automatically load-balances across replicas (VIP mode)
  • Traffic encryption via the --opt encrypted flag (AES-GCM 128-bit)
  • Automatic encryption key management between nodes
  • VXLAN encapsulation on UDP port 4789

Unlike Kubernetes, where the networking model is implemented through CNI plugins (Calico, Flannel, Cilium), Docker Swarm provides a built-in solution with no external dependencies. Kubernetes offers significantly more flexibility: NetworkPolicy for fine-grained L3/L4 traffic control, eBPF support in Cilium for L7 visibility, and service mesh integration (Istio, Linkerd). Docker Swarm is easier to configure but less flexible for complex isolation policies.

# Declaring services in Swarm with an overlay network
version: '3.8'
services:
  api:
    image: goapi:latest
    networks:
      - backend_overlay
    deploy:
      replicas: 3
      update_config:
        parallelism: 1
        delay: 10s

  postgres:
    image: postgres:16
    networks:
      - backend_overlay
    deploy:
      replicas: 1
      placement:
        constraints:
          - node.role == manager
    volumes:
      - postgres_data:/var/lib/postgresql/data

networks:
  backend_overlay:
    driver: overlay
    driver_opts:
      encrypted: "true"
    attachable: false

volumes:
  postgres_data:
    driver: local

Network Isolation: A Zero-Trust Approach in Docker

Zero-trust in the Docker context means: each service communicates only with the services it needs to. By default, all containers on the same bridge network can communicate with each other — this violates the principle of least privilege.

Network Segmentation

The primary isolation tool in Docker Compose is splitting services across multiple networks. Each network is a separate L2 domain. A service can belong to multiple networks, enabling DMZ-like configurations.

services:
  nginx:
    image: nginx:alpine
    networks:
      - frontend
      - dmz
    ports:
      - "80:80"

  api:
    image: goapi:latest
    networks:
      - dmz
      - backend
    # api is NOT connected to frontend — nginx cannot reach api directly
    # api sees nginx via dmz and postgres via backend

  postgres:
    image: postgres:16
    networks:
      - backend
    # postgres is visible ONLY on the backend network
    # nginx has no access to it

  redis:
    image: redis:7-alpine
    networks:
      - backend
    # redis is also isolated in backend

networks:
  frontend:
    driver: bridge
    internal: false  # internet access allowed
  dmz:
    driver: bridge
    internal: true   # internal traffic only
  backend:
    driver: bridge
    internal: true   # internal traffic only
    driver_opts:
      com.docker.network.bridge.enable_icc: "false"  # disable inter-container communication

Additional Isolation Measures

  • --icc=false at the Docker daemon level: disables direct communication between containers on the default network, requiring explicit --link (deprecated) or user-defined networks
  • Read-only filesystems: read_only: true in Compose to prevent writes into the container
  • seccomp and AppArmor profiles: restricting system calls available to the container
  • User namespace remapping: isolating container UID/GID from the host
  • Secrets management: use Docker Secrets or Vault for passing sensitive data instead of environment variables
# Disabling inter-container communication at the daemon level
# /etc/docker/daemon.json
{
  "icc": false,
  "userns-remap": "default",
  "log-driver": "json-file",
  "log-opts": {
    "max-size": "10m",
    "max-file": "3"
  }
}

Real-World Case Study: PostgreSQL, Redis, and Go Microservices

Let's walk through a complete example of storage and network organization for a typical production stack: a Go API, PostgreSQL as the primary database, Redis for caching and sessions, and Nginx as a reverse proxy.

version: '3.8'

x-logging: &default-logging
  driver: json-file
  options:
    max-size: "10m"
    max-file: "3"

services:
  nginx:
    image: nginx:1.25-alpine
    restart: unless-stopped
    ports:
      - "443:443"
      - "80:80"
    volumes:
      - ./nginx/conf.d:/etc/nginx/conf.d:ro
      - ./nginx/ssl:/etc/nginx/ssl:ro
      - nginx_cache:/var/cache/nginx
    networks:
      - frontend
      - dmz
    logging: *default-logging
    depends_on:
      - api

  api:
    image: mycompany/goapi:${API_VERSION:-latest}
    restart: unless-stopped
    environment:
      DATABASE_URL: postgresql://appuser:${POSTGRES_PASSWORD}@postgres:5432/appdb?sslmode=disable
      REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379/0
      APP_ENV: production
    volumes:
      - api_uploads:/app/uploads
      - type: tmpfs
        target: /tmp
        tmpfs:
          size: 67108864  # 64MB
    networks:
      - dmz
      - backend
    logging: *default-logging
    healthcheck:
      test: ["CMD", "wget", "-qO-", "http://localhost:8080/health"]
      interval: 30s
      timeout: 10s
      retries: 3
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy
    read_only: true
    tmpfs:
      - /tmp

  postgres:
    image: postgres:16-alpine
    restart: unless-stopped
    environment:
      POSTGRES_DB: appdb
      POSTGRES_USER: appuser
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
      PGDATA: /var/lib/postgresql/data/pgdata
    volumes:
      - postgres_data:/var/lib/postgresql/data
      - postgres_wal:/var/lib/postgresql/wal
      - ./postgres/postgresql.conf:/etc/postgresql/postgresql.conf:ro
      - ./postgres/pg_hba.conf:/etc/postgresql/pg_hba.conf:ro
    networks:
      - backend
    logging: *default-logging
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U appuser -d appdb"]
      interval: 10s
      timeout: 5s
      retries: 5
    command: postgres -c config_file=/etc/postgresql/postgresql.conf

  redis:
    image: redis:7-alpine
    restart: unless-stopped
    command: redis-server --requirepass ${REDIS_PASSWORD} --maxmemory 256mb --maxmemory-policy allkeys-lru --appendonly yes
    volumes:
      - redis_data:/data
    networks:
      - backend
    logging: *default-logging
    healthcheck:
      test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"]
      interval: 10s
      timeout: 5s
      retries: 3

  postgres_backup:
    image: postgres:16-alpine
    restart: unless-stopped
    environment:
      PGPASSWORD: ${POSTGRES_PASSWORD}
      POSTGRES_USER: appuser
      POSTGRES_DB: appdb
    volumes:
      - postgres_backups:/backups
    networks:
      - backend
    entrypoint: |
      sh -c 'while true; do
        pg_dump -h postgres -U appuser -d appdb --format=custom \
          -f /backups/appdb_$$(date +%Y%m%d_%H%M%S).dump;
        find /backups -name "*.dump" -mtime +7 -delete;
        echo "Backup completed at $$(date)";
        sleep 86400;
      done'
    depends_on:
      postgres:
        condition: service_healthy

networks:
  frontend:
    driver: bridge
    ipam:
      config:
        - subnet: 172.20.0.0/24
  dmz:
    driver: bridge
    internal: true
    ipam:
      config:
        - subnet: 172.20.1.0/24
  backend:
    driver: bridge
    internal: true
    ipam:
      config:
        - subnet: 172.20.2.0/24

volumes:
  postgres_data:
    driver: local
    driver_opts:
      type: none
      o: bind
      device: /data/postgres
  postgres_wal:
    driver: local
  postgres_backups:
    driver: local
    driver_opts:
      type: none
      o: bind
      device: /data/backups
  redis_data:
    driver: local
  api_uploads:
    driver: local
  nginx_cache:
    driver: local

Note the key decisions in this configuration:

  • PostgreSQL and Redis are isolated in the backend network — nginx physically cannot reach them
  • The Go API has access to dmz (for communication with nginx) and backend (for databases)
  • PostgreSQL data is stored in a bind-mounted directory on the host with an explicit path to simplify backups
  • Redis uses AOF for persistence with an allkeys-lru eviction policy
  • The API container runs with read_only: true, writing only to tmpfs and a named volume
  • The backup container is isolated in the backend network with no access to the frontend

Common Mistakes and How to Avoid Them

1. Storing Data in the Container's Writable Layer

Any data written to a container without a mounted volume lives only in the writable layer and is lost when the container is removed. Always declare VOLUME in the Dockerfile or mount volumes explicitly.

2. Using the Default Bridge Network

The default bridge network does not support DNS resolution by container name. Always create user-defined networks in Compose or via docker network create.

3. Exposing Database Ports to the Host

The directive ports: - "5432:5432" for PostgreSQL makes the database accessible on all host interfaces. In production, use expose instead of ports for services that should not be reachable from outside.

4. Missing Healthcheck-Based Dependencies

The depends_on directive without condition: service_healthy only guarantees container startup order, not service readiness. The Go application may start before PostgreSQL is ready to accept connections. Always use healthchecks.

5. Ignoring Volume Permission Issues

If an application inside a container runs as an unprivileged user (the correct practice) but the host directory is owned by root, Permission denied errors will occur. Solution: set the user in the Dockerfile and ensure UID/GID compatibility on the host.

6. Passing Secrets via Environment Variables

Environment variables are visible via docker inspect and may end up in logs. Use Docker Secrets (in Swarm) or mount secrets as files via bind mount from a secured directory.

7. No Limits on Volumes and Resources

Without limits, a single container can exhaust the host's disk space. Use driver_opts to set quotas, or monitor usage with docker system df.

# Monitoring volume and container usage
docker system df -v

# Inspecting a specific volume
docker volume inspect postgres_data

# Removing unused volumes (USE WITH CAUTION in production!)
docker volume prune --filter "label!=keep"

# Inspecting networks
docker network inspect backend

# Listing all containers connected to a network
docker network inspect backend --format '{{range .Containers}}{{.Name}} {{.IPv4Address}}{{"\\n"}}{{end}}'

Conclusion: A Checklist for a Production-Ready Docker Environment

Let's summarize all the practices covered in this article as a checklist to help you assess the production readiness of your Docker environment.

Volumes and Storage

  • All persistent data is stored in named volumes or bind mounts — not in the writable layer
  • Database data (PostgreSQL, Redis) is on named volumes with explicit paths
  • Temporary data is placed in tmpfs
  • Automated backups are configured with rotation and offsite storage
  • UID/GID compatibility between containers and the host has been verified
  • NFS volumes or volume plugins are configured for multi-host environments

Network Isolation

  • User-defined bridge networks are used instead of the default bridge
  • Services are separated into functional networks (frontend/dmz/backend)
  • Databases and internal services reside on internal networks
  • Database ports are not mapped to the host (expose instead of ports)
  • Overlay networks in Swarm are configured with encryption enabled
  • icc=false is configured at the daemon level or via driver_opts

Security and Reliability

  • Containers run as unprivileged users
  • Configuration files are mounted as read-only
  • API containers run with read_only: true
  • Secrets are not passed as plaintext environment variables
  • Healthchecks are configured for all services along with condition: service_healthy dependencies
  • Resource limits (CPU, memory) and log rotation are configured
  • docker system df is run regularly and unused resources are cleaned up

Proper handling of Docker Volumes and networking is not just a technical detail — it is the foundation on which the reliability, security, and scalability of your entire microservice stack are built. The time invested in configuring these aspects pays off many times over when production incidents occur.

Technologies

Tags

Ruslan Ismailov

Senior Web / Backend Developer. Senior web/backend developer with 9 years of experience. Stack: PHP, Laravel, PostgreSQL, Redis, Docker, Kubernetes, REST, microservices, CI/CD. More about me →