Optimizing Container Volume Performance On Hetzner VPS
What You’ll Need
- Hetzner VPS for cloud instance server deployment
- DigitalOcean as alternative cloud infrastructure
- n8n Cloud or self-hosted n8n engine
- Namecheap for custom domain configuration
Table of Contents
- Understanding Hetzner Storage Architectures and Container Overhead
- Benchmarking Baseline Volume IO with FIO
- Kernel and Filesystem Tuning for Storage Throughput
- Optimizing Docker Storage Daemon and Container Mount Strategy
- Production Deployment with Automated Optimization Script
- Getting Started
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:
- Ephemeral Cache Data: Directories where processes store temporary scratch files, session storage, or fast state caches.
- Persistent Structured Databases: High IOPS locations such as PostgreSQL, MySQL, or Redis datasets.
- 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