Optimizing Container Volume Performance On Hetzner VPS

Optimizing Container Volume Performance On Hetzner VPS

What You’ll Need

Table of Contents


Understanding Hetzner Storage Architectures and Container Overhead

Running database-heavy containers on cloud infrastructure often leads to unexpected IO bottlenecks. When hosting stateful containers on a Hetzner VPS, you generally choose between local NVMe drives and network-attached block storage (Hetzner Cloud Volumes). While Cloud Volumes offer easy resizing and snapshot portability, they rely on network storage networks based on Ceph. This architecture adds latency to every write operation. Local NVMe drives on CX and CPX server lines offer direct attached performance, but improper Linux kernel settings or bad Docker mount strategies can reduce write throughput by up to 60 percent.

Docker containers run on top of standard host filesystems using storage drivers like overlay2. The overlay filesystem introduces a Layered Copy-on-Write (CoW) driver architecture. When a process inside a container modifies an existing file located within the container image layer, Docker copies the entire file up to the container read-write layer before applying the modification. For high-frequency read and write operations, such as database logs, queue managers, or high volume trace logs, this driver overhead causes elevated CPU utilization and disk IO wait times (iowait).

To eliminate this performance cost, persistent data must be mapped using Docker volumes or bind mounts directly attached to an optimized host filesystem. However, simple volume binding is only half the battle. Default Linux kernel page cache parameters are configured for desktop or general-purpose server environments, not for container instances running high concurrency database engines. Tuning the page cache writeback triggers, mount flags, and container filesystem layer options creates a massive improvement in system IOPS and throughput.


Benchmarking Baseline Volume IO with FIO

Before applying kernel tweaks or filesystem adjustments, you must measure your current storage performance inside the container environment. Relying on simple tools like dd is misleading because dd uses sequential writes that primarily test system RAM caching rather than realistic random access IOPS.

We use Flexible I/O Tester (fio) inside a clean container environment to measure random read and write performance, IOPS, and latency across directly mounted host volumes.

Save the following benchmarking script as benchmark-storage.sh and execute it on your server to establish your performance baseline:

#!/bin/bash
set -euo pipefail

TEST_DIR="/var/lib/docker/volumes/test_bench_vol/_data"
mkdir -p "$TEST_DIR"

echo "=== STAGE 1: Host Directory Direct FIO Benchmark ==="
fio --name=randwrite_direct \
  --filename="$TEST_DIR/fio_test_file" \
  --ioengine=libaio \
  --direct=1 \
  --rw=randwrite \
  --bs=4k \
  --size=1G \
  --numjobs=4 \
  --runtime=30 \
  --group_reporting \
  --time_based

rm -f "$TEST_DIR/fio_test_file"

echo "=== STAGE 2: Container Volume FIO Benchmark ==="
docker run --rm \
  -v test_bench_vol:/benchdata \
  alpine:3.19 \
  sh -c "apk add --no-cache fio && fio --name=randwrite_container --filename=/benchdata/fio_test_file --ioengine=libaio --direct=1 --rw=randwrite --bs=4k --size=1G --numjobs=4 --runtime=30 --group_reporting --time_based"

docker volume rm test_bench_vol

Make the script executable and run the test:

chmod +x benchmark-storage.sh
./benchmark-storage.sh

Pay close attention to the IOPS and clat (completion latency) values in the output. On default Hetzner NVMe deployments, raw host direct disk access should achieve between 40,000 to 80,000 IOPS for 4k random writes. If your container score drops below 15,000 IOPS, your host kernel dirty memory writeback settings and Docker storage layers are throttling your storage system.

💡 Fast-Track Your Project: Don’t want to configure this yourself? I build custom n8n pipelines and bots. Message me with code SYS3-HUGO.


Kernel and Filesystem Tuning for Storage Throughput

The Linux kernel delays writing dirty pages (data changed in RAM but not yet written to physical disk) to prevent disk thrashing. By default, the system allows dirty memory to accumulate up to 20 percent of total system RAM before forcing synchronous blocking writes. On a system with 16GB or 32GB of RAM, this means several gigabytes of data can build up before flushing, causing the storage subsystem to freeze for several seconds.

When routing high volumes of web traffic through edge services like Configuring Traefik Reverse Proxy for Container Deployments, long access logging operations can exhaust disk write buffers and stall proxy execution. To enforce continuous background flushes and eliminate IO spikes, we adjust system kernel sysctl variables.

Create a dedicated sysctl configuration file at /etc/sysctl.d/99-hetzner-storage.conf:

# Enforce background dirty page flush at 5% of total RAM
vm.dirty_background_ratio = 5

# Force synchronous dirty page flush at 10% of total RAM
vm.dirty_ratio = 10

# Frequency of wake-up for kernel flusher threads (in hundredths of a second)
vm.dirty_writeback_centisecs = 150

# Maximum age of dirty data before forcing writeback (in hundredths of a second)
vm.dirty_expire_centisecs = 500

# Aggressiveness of memory reclaim against directory/inode cache (default 100)
vm.vfs_cache_pressure = 50

Apply these settings immediately without rebooting:

sysctl --system

Next, optimize the host file system. If your server uses ext4, default mount options write access timestamps (atime) and use strict journaling barriers that slow down container write operations. Modify your /etc/fstab file to use high performance mount options for your dedicated container storage drives or primary NVMe partitions.

Open /etc/fstab and append or edit your volume parameters:

UUID=4f3310a0-e2cb-4581-a67b-111122223333 /var/lib/docker ext4 defaults,noatime,nodiratime,data=ordered,barrier=0,commit=60 0 2

Here is a breakdown of what these options do:

  • noatime,nodiratime: Disables reading timestamp updates when files or directories are accessed, reducing disk write operations by up to 30 percent.
  • barrier=0: Disables write barriers on dirty page flushes, which improves write throughput on enterprise NVMe drives equipped with hardware power loss protection.
  • commit=60: Flushes file system metadata every 60 seconds instead of the default 5 seconds, lowering disk controller wear and increasing batch write performance.

Remount the target mount point to apply the new options instantly:

mount -o remount /var/lib/docker

Optimizing Docker Storage Daemon and Container Mount Strategy

By default, Docker uses default driver options that are sub-optimal for production container operations. We must configure /etc/docker/daemon.json to instruct the Docker engine to use optimal storage drivers, limit container log volume usage, and manage storage allocations cleanly.

Edit or create /etc/docker/daemon.json:

{
  "storage-driver": "overlay2",
  "storage-opts": [
    "overlay2.override_kernel_check=true"
  ],
  "log-driver": "json-file",
  "log-opts": {
    "max-size": "10m",
    "max-file": "3",
    "compress": "true"
  },
  "default-shm-size": "256M"
}

Restart the Docker daemon to apply these structural daemon adjustments:

systemctl restart docker

Strategic Container Storage Allocation

Different types of container data require different storage strategies. Application containers write to three main categories of storage:

  1. Ephemeral Cache Data: Directories where processes store temporary scratch files, session storage, or fast state caches.
  2. Persistent Structured Databases: High IOPS locations such as PostgreSQL, MySQL, or Redis datasets.
  3. Log Streams: Continuous text file writes.

When managing state components like rate limiters built with Implementing Redis Sliding Window Rate Limiting, writing transient key tracking structures to physical storage wastes disk wear and adds latency. These operations belong entirely in memory via tmpfs mounts.

Here is a full docker-compose.yml file demonstrating optimized volume mounts across all three storage patterns:

version: '3.8'

services:
  redis:
    image: redis:7.2-alpine
    container_name: production_redis
    command: redis-server --appendonly yes --appendfsync everysec
    volumes:
      - redis_data:/data
    tmpfs:
      - /tmp:rw,noexec,nosuid,size=128m
    restart: always

  api_app:
    image: node:20-alpine
    container_name: production_api
    environment:
      - NODE_ENV=production
      - REDIS_HOST=redis
    volumes:
      - app_logs:/var/log/app
    tmpfs:
      - /tmp:rw,noexec,nosuid,size=256m
      - /run:rw,noexec,nosuid,size=64m
    depends_on:
      - redis
    restart: always

volumes:
  redis_data:
    driver: local
    driver_opts:
      type: 'none'
      o: 'bind'
      device: '/var/lib/docker/volumes/production_redis_data'
  app_logs:
    driver: local
    driver_opts:
      type: 'none'
      o: 'bind'
      device: '/var/lib/docker/volumes/production_app_logs'

Before running this Docker Compose configuration, build the explicit bind path directories on your fast storage volume:

mkdir -p /var/lib/docker/volumes/production_redis_data
mkdir -p /var/lib/docker/volumes/production_app_logs
chmod -R 777 /var/lib/docker/volumes/production_redis_data
chmod -R 777 /var/lib/docker/volumes/production_app_logs

Production Deployment with Automated Optimization Script

For mission-critical production components such as relational store drivers supporting messaging architectures like Building a Self Hosted WhatsApp Bot, sustained storage performance is mandatory to avoid losing inbound webhooks during message spikes.

The following shell script automates the complete optimization process. It verifies root permissions, updates sysctl parameters, creates direct directory targets, configures Docker settings, and verifies storage performance improvements using fio.

Save this script as deploy-optimized-node.sh:

#!/bin/bash
set -euo pipefail

if [ "$EUID" -ne 0 ]; then
  echo "Error: Please run this deployment script as root."
  exit 1
fi

echo "=================================================="
echo " Starting Hetzner Storage Optimization Deployment "
echo "=================================================="

echo "[1/5] Writing Kernel Sysctl Optimization Rules..."
cat << 'EOF' > /etc/sysctl.d/99-hetzner-storage.conf
vm.dirty_background_ratio = 5
vm.dirty_ratio = 10
vm.dirty_writeback_centisecs = 150
vm.dirty_expire_centisecs = 500
vm.vfs_cache_pressure = 50
EOF

sysctl --system > /dev/null 2>&1

echo "[2/5] Optimizing Mount Flags on /var/lib/docker..."
DOCKER_MOUNT_POINT=$(df -P /var/lib/docker | tail -1 | awk '{print $6}')
DOCKER_DEV=$(df -P /var/lib/docker | tail -1 | awk '{print $1}')

if [ "$DOCKER_DEV" != "tmpfs" ] && [ "$DOCKER_DEV" != "overlay" ]; then
  mount -o remount,noatime,nodiratime,commit=60 "$DOCKER_MOUNT_POINT" || true
  echo "Mount point $DOCKER_MOUNT_POINT remounted with noatime, commit=60."
fi

echo "[3/5] Configuring Docker Storage Engine and Log Limits..."
mkdir -p /etc/docker

cat << 'EOF' > /etc/docker/daemon.json
{
  "storage-driver": "overlay2",
  "storage-opts": [
    "overlay2.override_kernel_check=true"
  ],
  "log-driver": "json-file",
  "log-opts": {
    "max-size": "10m",
    "max-file": "3",
    "compress": "true"
  },
  "default-shm-size": "256M"
}
EOF

echo "[4/5] Restarting Docker Service..."
systemctl restart docker

echo "[5/5] Running Verification Benchmark..."
fio --name=post_optimization_test \
  --filename=/var/lib/docker/opt_test.tmp \
  --ioengine=libaio \
  --direct=1 \
  --rw=randwrite \
  --bs=4k \
  --size=512M \
  --numjobs=2 \
  --runtime=15 \
  --group_reporting \
  --time_based

rm -f /var/lib/docker/opt_test.tmp

echo "=================================================="
echo " Optimization Complete! Storage Pipeline Ready.   "
echo "=================================================="

Run the deployment script:

chmod +x deploy-optimized-node.sh
./deploy-optimized-node.sh

Getting Started

To implement these optimizations on your own server infrastructure, launch a fresh instance on Hetzner VPS or an equivalent provider like DigitalOcean. Point your domain records using Namecheap, connect via SSH, run the automated optimization script, and deploy your workload using optimized Docker volumes and tmpfs mounts. If you rely on external automation, you can run self-hosted n8n on your optimized setup or use n8n Cloud to orchestrate state updates across your server stack.

Outsource Your Automation

Don’t have time? I build production n8n workflows, WhatsApp bots, and fully automated YouTube Shorts pipelines. Hire me on Fiverr, mention SYS3-HUGO for priority. Or DM at chasebot.online.

Want to automate this yourself?

Start with n8n Cloud (free tier available) or self-host on a Hetzner VPS for full control.

Want this engine running on your own VPS?

This blog publishes itself — daily, unattended, on free API tiers. The full engine, Hugo theme, and setup guide are available as System 3.

Get System 3
system online