Game Servers on ZFS — never lose a world again.
Every game server admin has a horror story. The Minecraft world that corrupted during a power outage. The Valheim save that vanished after a bad update. The griefer who joined the Rust server and leveled everything while you were asleep. The Palworld save that just... stopped loading one day.
The problem is always the same: game servers write important data to disk, and nothing protects it. ext4 doesn't snapshot. NTFS doesn't snapshot. Your backup script that runs at midnight doesn't help when the grief happened at 12:01 AM. You're always one crash away from losing everything.
ZFS changes that. Automatic snapshots every 15 minutes. Rollback to any point in time in under 2 seconds. Zero-cost clones for testing. Replication to a backup server. Your worlds become indestructible.
1. The problem
Game server data is uniquely fragile. Save files are written constantly, often by janky serialization code that was never designed for reliability. A crash mid-write means corruption. A bad mod update means your world loads into a void. A griefer with TNT means hours of building gone in seconds. And the "solutions" the game server community uses — manual backups, rsync cron jobs, copying the world folder to Dropbox — are all point-in-time copies that miss everything in between.
What goes wrong without ZFS
Save corruption: Server crashes mid-write. The world file is half-written. Game won't load it. Gone.
Griefing: Someone joins your server and destroys everything. Your last backup was 6 hours ago.
Bad updates: You update the server or a mod. The save format changed. No rollback path.
Disk failure: The SSD dies. Your backups are on the same disk. Everything's gone.
Operator error: You accidentally delete the world folder. rm -rf doesn't ask twice.
2. Quick start — Minecraft in 5 minutes
Let's start with the game everyone knows. You'll have a Minecraft server running on ZFS with auto-snapshots in about five minutes. Both Java Edition and Bedrock — pick your flavor.
Create the ZFS dataset for Minecraft
# Create a dedicated ZFS dataset for Minecraft world data
kdir /srv/minecraft
# Verify it — you'll see compression enabled, mountpoint set
zfs list -o name,mountpoint,compression,compressratio rpool/srv/minecraft
Minecraft Java Edition
# Run Minecraft Java server in Docker
docker run -d --name minecraft-java \
--restart unless-stopped \
-p 25565:25565 \
-e EULA=TRUE \
-e MEMORY=4G \
-e TYPE=PAPER \
-e DIFFICULTY=normal \
-e MAX_PLAYERS=20 \
-e MOTD="ZFS-backed server — your builds are immortal" \
-v /srv/minecraft/java:/data \
itzg/minecraft-server
# Check the logs — wait for "Done" message
docker logs -f minecraft-java
Minecraft Bedrock Edition
# Run Minecraft Bedrock server in Docker
docker run -d --name minecraft-bedrock \
--restart unless-stopped \
-p 19132:19132/udp \
-e EULA=TRUE \
-e DIFFICULTY=normal \
-e SERVER_NAME="ZFS Bedrock" \
-e MAX_PLAYERS=10 \
-v /srv/minecraft/bedrock:/data \
itzg/minecraft-bedrock-server
# Both editions, one ZFS dataset, one snapshot schedule
Auto-snapshots with Sanoid — every 15 minutes
# Add to /etc/sanoid/sanoid.conf
cat >> /etc/sanoid/sanoid.conf <<'EOF'
[rpool/srv/minecraft]
use_template = gameserver
recursive = yes
[template_gameserver]
frequently = 8
hourly = 48
daily = 30
monthly = 3
yearly = 0
autosnap = yes
autoprune = yes
frequent_period = 15
EOF
# Restart sanoid timer
systemctl restart sanoid.timer
# Verify — snapshots start appearing every 15 minutes
ksnap list /srv/minecraft
Grief happened? Roll it back.
# Someone blew up the spawn? Roll back to before it happened.
docker stop minecraft-java
# See every snapshot with timestamps
ksnap list /srv/minecraft
# NAME USED REFER CREATION
# rpool/srv/minecraft@autosnap_2026-03-23_14:00 128K 2.1G Mon Mar 23 14:00
# rpool/srv/minecraft@autosnap_2026-03-23_14:15 256K 2.1G Mon Mar 23 14:15
# rpool/srv/minecraft@autosnap_2026-03-23_14:30 384K 2.1G Mon Mar 23 14:30 <-- grief at 14:35
# rpool/srv/minecraft@autosnap_2026-03-23_14:45 512K 2.1G Mon Mar 23 14:45
# Roll back to the 14:30 snapshot — before the grief
ksnap rollback /srv/minecraft
# Start the server again — world restored, 2 seconds flat
docker start minecraft-java
3. Valheim
Valheim worlds are precious. Hundreds of hours building a Viking fortress, sailing to every biome, defeating every boss. One corrupted save and it's all gone. Let's make that impossible.
Valheim dedicated server on ZFS
# Create ZFS dataset for Valheim
kdir /srv/valheim
# Run Valheim server — lloesche's image handles updates automatically
docker run -d --name valheim \
--restart unless-stopped \
--cap-add=sys_nice \
-p 2456-2458:2456-2458/udp \
-e SERVER_NAME="Valhalla ZFS" \
-e WORLD_NAME="Midgard" \
-e SERVER_PASS="your-secret-password" \
-e BACKUPS_MAX_AGE=7 \
-e UPDATE_CRON="0 5 * * *" \
-v /srv/valheim/config:/config \
-v /srv/valheim/data:/opt/valheim \
lloesche/valheim-server
# Add to Sanoid — same gameserver template
cat >> /etc/sanoid/sanoid.conf <<'EOF'
[rpool/srv/valheim]
use_template = gameserver
recursive = yes
EOF
systemctl restart sanoid.timer
Clone your world for testing
# Want to test a mod without risking your main world?
# Clone it — instant, takes zero extra disk space until you change things
kclone /srv/valheim /srv/valheim-test
# Run a second server on the clone with different ports
docker run -d --name valheim-test \
--restart unless-stopped \
-p 2459-2461:2456-2458/udp \
-e SERVER_NAME="Valhalla TEST" \
-e WORLD_NAME="Midgard" \
-e SERVER_PASS="test-password" \
-v /srv/valheim-test/config:/config \
-v /srv/valheim-test/data:/opt/valheim \
lloesche/valheim-server
# Test your mods. If it works, apply to production.
# If it doesn't, just destroy the clone — your real world never saw it.
docker rm -f valheim-test
zfs destroy -r rpool/srv/valheim-test
4. Palworld
Palworld dedicated servers are notoriously fragile. The save format has changed multiple times across updates, and corruption reports are common. ZFS snapshots before every server restart give you a safety net that no amount of manual backups can match.
Palworld dedicated server
# Create ZFS dataset
kdir /srv/palworld
# Run Palworld dedicated server
docker run -d --name palworld \
--restart unless-stopped \
-p 8211:8211/udp \
-p 27015:27015/udp \
-e PUID=1000 \
-e PGID=1000 \
-e MULTITHREADING=true \
-e COMMUNITY=false \
-e SERVER_NAME="Palworld ZFS" \
-e SERVER_PASSWORD="your-secret" \
-e MAX_PLAYERS=16 \
-v /srv/palworld:/palworld \
thijsvanloef/palworld-server-docker:latest
# Sanoid config
cat >> /etc/sanoid/sanoid.conf <<'EOF'
[rpool/srv/palworld]
use_template = gameserver
recursive = yes
EOF
systemctl restart sanoid.timer
Snapshot before every restart
# Create a wrapper script that always snapshots before restarting
cat > /usr/local/bin/palworld-restart <<'SCRIPT'
#!/bin/bash
echo "Snapshotting Palworld save data..."
ksnap /srv/palworld
echo "Stopping server..."
docker restart palworld
echo "Done. Snapshot taken, server restarting."
echo "If anything goes wrong: ksnap rollback /srv/palworld"
SCRIPT
chmod +x /usr/local/bin/palworld-restart
# Now use palworld-restart instead of docker restart palworld
# Every restart has a matching snapshot — you can always go back
palworld-restart
5. Rust
Rust has forced wipes. It's part of the game. But with ZFS, wipe day becomes trivial — roll back to a "clean slate" snapshot instead of rebuilding the server from scratch. And between wipes, your players' progress is protected by the same 15-minute snapshots.
Rust dedicated server with wipe-day snapshots
# Create ZFS dataset
kdir /srv/rust
# Run Rust server
docker run -d --name rust-server \
--restart unless-stopped \
-p 28015:28015/udp \
-p 28016:28016 \
-p 28082:28082 \
-e RUST_SERVER_NAME="ZFS Rust | Wipe Thursdays" \
-e RUST_SERVER_SEED=12345 \
-e RUST_SERVER_MAXPLAYERS=50 \
-e RUST_SERVER_WORLDSIZE=3000 \
-e RUST_RCON_PASSWORD="your-rcon-pass" \
-v /srv/rust:/steamcmd/rust \
didstopia/rust-server
# After first boot and map generation, create the "day 1" snapshot
ksnap /srv/rust
# Rename it for clarity
zfs rename rpool/srv/rust@$(zfs list -t snapshot -o name -H rpool/srv/rust | tail -1 | cut -d@ -f2) rpool/srv/rust@wipe-day-clean
# On wipe day — one command, fresh server
docker stop rust-server
ksnap rollback /srv/rust
docker start rust-server
# That's it. World reset to day 1. No reinstall. No config fiddling. 2 seconds.
Monthly wipe automation
# Automate wipe day — first Thursday of every month at 3 AM
cat > /etc/cron.d/rust-wipe <<'EOF'
SHELL=/bin/bash
PATH=/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin
0 3 1-7 * 4 root /usr/local/bin/rust-wipe
EOF
# The wipe script
cat > /usr/local/bin/rust-wipe <<'SCRIPT'
#!/bin/bash
echo "[$(date)] Rust wipe day — rolling back to clean state"
docker stop rust-server
ksnap rollback /srv/rust
docker start rust-server
echo "[$(date)] Wipe complete. Server is live."
SCRIPT
chmod +x /usr/local/bin/rust-wipe
6. Multi-server setup
One box. Five games. Each game gets its own ZFS dataset with its own snapshot schedule, its own disk quota, and its own resource limits. Docker Compose ties it all together. This is the setup for running a proper game server community.
ZFS datasets with quotas
# Each game gets its own dataset — isolated snapshots, isolated space
kdir /srv/minecraft
kdir /srv/valheim
kdir /srv/palworld
kdir /srv/rust
kdir /srv/terraria
# Set quotas so one game can't eat all the disk
zfs set quota=50G rpool/srv/minecraft
zfs set quota=20G rpool/srv/valheim
zfs set quota=30G rpool/srv/palworld
zfs set quota=40G rpool/srv/rust
zfs set quota=10G rpool/srv/terraria
# Verify
kdf
Docker Compose — all servers in one file
# /srv/docker-compose.yml
services:
minecraft:
image: itzg/minecraft-server
restart: unless-stopped
ports:
- "25565:25565"
environment:
EULA: "TRUE"
MEMORY: "4G"
TYPE: "PAPER"
volumes:
- /srv/minecraft/java:/data
deploy:
resources:
limits:
cpus: "2.0"
memory: 6G
valheim:
image: lloesche/valheim-server
restart: unless-stopped
ports:
- "2456-2458:2456-2458/udp"
environment:
SERVER_NAME: "Valhalla"
WORLD_NAME: "Midgard"
SERVER_PASS: "your-secret"
volumes:
- /srv/valheim/config:/config
- /srv/valheim/data:/opt/valheim
deploy:
resources:
limits:
cpus: "2.0"
memory: 4G
palworld:
image: thijsvanloef/palworld-server-docker:latest
restart: unless-stopped
ports:
- "8211:8211/udp"
environment:
SERVER_NAME: "Palworld ZFS"
MAX_PLAYERS: "16"
volumes:
- /srv/palworld:/palworld
deploy:
resources:
limits:
cpus: "2.0"
memory: 8G
rust:
image: didstopia/rust-server
restart: unless-stopped
ports:
- "28015:28015/udp"
- "28016:28016"
environment:
RUST_SERVER_NAME: "ZFS Rust"
RUST_SERVER_MAXPLAYERS: "50"
volumes:
- /srv/rust:/steamcmd/rust
deploy:
resources:
limits:
cpus: "2.0"
memory: 8G
terraria:
image: ryshe/terraria:latest
restart: unless-stopped
ports:
- "7777:7777"
volumes:
- /srv/terraria:/root/.local/share/Terraria/Worlds
deploy:
resources:
limits:
cpus: "1.0"
memory: 2G
stdin_open: true
tty: true
# Start everything
cd /srv && docker compose up -d
# Check status
docker compose ps
7. Anti-grief toolkit
This is the part that makes ZFS magic for game servers. You don't just have backups — you have a time machine. Every 15 minutes, automatically, forever. Here's how to use it when bad things happen.
See every snapshot
# List all snapshots for your Minecraft world
ksnap list /srv/minecraft
# Output — every 15 minutes, like clockwork
# NAME USED REFER CREATION
# rpool/srv/minecraft@autosnap_2026-03-23_12:00_frequent 64K 2.1G Mon Mar 23 12:00
# rpool/srv/minecraft@autosnap_2026-03-23_12:15_frequent 128K 2.1G Mon Mar 23 12:15
# rpool/srv/minecraft@autosnap_2026-03-23_12:30_frequent 192K 2.1G Mon Mar 23 12:30
# rpool/srv/minecraft@autosnap_2026-03-23_12:45_frequent 256K 2.1G Mon Mar 23 12:45
# rpool/srv/minecraft@autosnap_2026-03-23_13:00_hourly 384K 2.1G Mon Mar 23 13:00
# ...
# Notice the USED column — snapshots are tiny.
# They only store what changed since the last snapshot.
# A 2 GB Minecraft world generates maybe 1-5 MB of snapshot data per hour.
Roll back to any point in time
# Grief discovered at 3:20 PM. Griefer joined at ~3:05 PM.
# Roll back to the 3:00 PM snapshot — 20 minutes of grief, undone in 2 seconds.
docker stop minecraft-java
ksnap rollback /srv/minecraft
# Interactive: pick the 3:00 PM snapshot from the list
docker start minecraft-java
# Or target a specific snapshot directly
docker stop minecraft-java
zfs rollback rpool/srv/minecraft@autosnap_2026-03-23_15:00_frequent
docker start minecraft-java
Clone for forensics
# Don't want to rollback yet? Clone the pre-grief state and investigate.
kclone /srv/minecraft /srv/minecraft-forensics
# Now you have two copies:
# /srv/minecraft — current state (with grief)
# /srv/minecraft-forensics — clean state (before grief)
# Run a second server instance on the clone to compare
docker run -d --name mc-forensics \
-p 25566:25565 \
-e EULA=TRUE \
-v /srv/minecraft-forensics/java:/data \
itzg/minecraft-server
# Join both servers. Compare. See exactly what was destroyed.
# Document it. Ban the griefer. Then rollback the real server.
8. WireGuard for players
Public game servers get DDoS'd, port-scanned, and griefed by strangers. A private game server behind WireGuard is invisible to the internet. No port forwarding. No public IP exposure. No DDoS. Give each player a WireGuard config and they connect directly to your server over an encrypted tunnel.
Set up WireGuard for your game server
# Generate server keys
wg genkey | tee /etc/wireguard/server.key | wg pubkey > /etc/wireguard/server.pub
# Server config
cat > /etc/wireguard/wg-games.conf <<EOF
[Interface]
Address = 10.100.0.1/24
ListenPort = 51820
PrivateKey = $(cat /etc/wireguard/server.key)
PostUp = iptables -A FORWARD -i wg-games -j ACCEPT
PostDown = iptables -D FORWARD -i wg-games -j ACCEPT
EOF
# Generate a config for each player
for player in alice bob charlie; do
wg genkey | tee /etc/wireguard/peers/${player}.key | wg pubkey > /etc/wireguard/peers/${player}.pub
# Get next available IP
NEXT_IP=$(($(wg show wg-games peers 2>/dev/null | wc -l) + 2))
# Add peer to server config
cat >> /etc/wireguard/wg-games.conf <<PEER
[Peer]
# ${player}
PublicKey = $(cat /etc/wireguard/peers/${player}.pub)
AllowedIPs = 10.100.0.${NEXT_IP}/32
PEER
# Generate player config file — send this to them
cat > /etc/wireguard/peers/${player}.conf <<CLIENT
[Interface]
PrivateKey = $(cat /etc/wireguard/peers/${player}.key)
Address = 10.100.0.${NEXT_IP}/32
DNS = 10.100.0.1
[Peer]
PublicKey = $(cat /etc/wireguard/server.pub)
Endpoint = YOUR_PUBLIC_IP:51820
AllowedIPs = 10.100.0.0/24
PersistentKeepalive = 25
CLIENT
echo "Created config for ${player}: /etc/wireguard/peers/${player}.conf"
done
# Start WireGuard
systemctl enable --now wg-quick@wg-games
How players connect
# Send each player their .conf file
# They import it into the WireGuard app (Windows, Mac, Linux, iOS, Android)
# In-game server address becomes the WireGuard IP:
# Minecraft: 10.100.0.1:25565
# Valheim: 10.100.0.1:2456
# Palworld: 10.100.0.1:8211
# Rust: 10.100.0.1:28015
# No port forwarding on your router. No public IP exposure.
# The game server only listens on the WireGuard interface.
# To the internet, your server doesn't exist.
9. Performance tuning
Game servers have specific I/O patterns. Worlds are written frequently in big chunks. ZFS can be tuned for this. The goal: fast writes for game saves, enough RAM for both the game and ZFS, minimal overhead.
ZFS recordsize for game saves
Most game saves: recordsize=128K (the default). Minecraft worlds, Valheim saves, Palworld data —
these are medium-sized files written in bursts. 128K is the sweet spot.
Large world files: recordsize=1M. If a game stores its entire world in one massive file
(some games do), bump to 1M to reduce metadata overhead on sequential writes.
Many small files: recordsize=32K. If the game scatters data across hundreds of tiny files
(mods, configs, chunk files), smaller recordsize reduces wasted space.
# Set recordsize per dataset
zfs set recordsize=128K rpool/srv/minecraft
zfs set recordsize=128K rpool/srv/valheim
zfs set recordsize=128K rpool/srv/palworld
zfs set recordsize=1M rpool/srv/rust
ARC tuning for game servers
Game servers are write-heavy. The ZFS ARC (read cache) matters less than having enough RAM for the game processes themselves. On a dedicated game server box, reduce the ARC to leave more RAM for Docker containers.
# 32 GB RAM box running game servers
# Give ARC 4 GB, leave 28 GB for games + OS
echo "options zfs zfs_arc_max=4294967296" > /etc/modprobe.d/zfs.conf
# Apply without reboot
echo 4294967296 > /sys/module/zfs/parameters/zfs_arc_max
# Verify
arc_summary | head -20
RAM allocation guide
# For a 32 GB game server box:
#
# OS + Docker overhead: 2 GB
# ZFS ARC: 4 GB
# Minecraft (Paper): 4-6 GB
# Valheim: 4 GB
# Palworld: 8 GB (this game is hungry)
# Rust: 6-8 GB
# ---
# Total: ~28-32 GB
#
# If you're running all 4 games: 32 GB minimum.
# If you're running 2-3 games: 16 GB works.
# If you're running just Minecraft: 8 GB is plenty.
#
# Rule of thumb: give ZFS ARC 10-15% of total RAM on game servers.
# On a storage server you'd give it 50-75%. Games are different.
10. Replication — your world survives even if the server burns down
Snapshots protect you from software problems. Replication protects you from hardware problems.
syncoid sends your game worlds to a second machine over the network. If your server
dies — disk failure, power surge, coffee spill — your worlds are safe on the backup box.
Replicate game worlds to a backup server
# One command — replicate Minecraft to the backup server
syncoid rpool/srv/minecraft backup-server:tank/minecraft
# Replicate everything under /srv
syncoid -r rpool/srv backup-server:tank/gameservers
# Automate it — replicate every hour
cat > /etc/cron.d/game-replication <<'EOF'
SHELL=/bin/bash
PATH=/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin
0 * * * * root syncoid -r --no-sync-snap rpool/srv root@backup-server:tank/gameservers 2>&1 | logger -t syncoid
EOF
# First run sends the full dataset. Every run after that sends only changes.
# A Minecraft world that changes 50 MB/hour sends 50 MB — not the whole 2 GB world.
Disaster recovery — the backup server takes over
# Main server is dead. On the backup server:
# 1. The replicated datasets are already there
zfs list -r tank/gameservers
# 2. Set mountpoints
zfs set mountpoint=/srv/minecraft tank/gameservers/minecraft
zfs set mountpoint=/srv/valheim tank/gameservers/valheim
# 3. Start Docker containers (same compose file, same images)
cd /srv && docker compose up -d
# 4. Update WireGuard endpoints to point to backup server IP
# 5. Players reconnect — world is at most 1 hour behind
# Total recovery time: ~5 minutes of typing.
# Data loss: at most 1 hour (your replication interval).
# Without replication: total loss. Start over. Explain to 20 angry players.
Every game server admin learns the same lesson the hard way: your world is only as safe as your last backup, and your last backup is never recent enough. ZFS changes the math. Snapshots every 15 minutes cost almost nothing. Rollback takes 2 seconds. Clones are free. Replication is one command.
You don't need a game hosting company. You don't need a control panel with a monthly fee. You need a Linux box, ZFS, Docker, and 10 minutes of setup. Your friends connect over WireGuard. Your worlds are snapshotted, replicated, and indestructible.
Go build something worth protecting. ZFS will make sure you never lose it.