Two website backups can contain the same payload bytes and still take materially different times to extract. Archive size measures bytes; restore work also includes opening entries, creating directory records, writing metadata, validating a database and proving the application state is usable. A credible restore estimate therefore needs the real file count and a timed rehearsal, not only a gigabyte figure.
On this host, the reproduced lab split the same deterministic 16 MiB byte stream into either 16 large files or 4,096 small files, paired both layouts with the same SQLite business state, archived them, and extracted each archive five times. It is a controlled local comparison, not a hosting benchmark. Its purpose is to show which evidence a site owner or agency should request before treating “your backup is only X GB” as a recovery promise.
Intended reader: a small-business website owner, agency lead or hosting buyer who can run a shell command or ask a technical provider to run the receipt. The workflow was reproduced on Debian 13 with GNU tar 1.35, Python 3.13 and SQLite 3.46.1. It uses no production files, accounts, network listeners or credentials and removes one marker-owned directory at the end.
AWS Backup restore testing treats an isolated restore and its measured completion time as recovery evidence. Google Cloud’s recovery-testing guidance adds two other acceptance criteria: data integrity and the recovery point objective. Neither source says that stored bytes alone predict the complete result.
Byte volume still matters. Transfer bandwidth, decompression throughput and destination write speed can dominate a large restore. Yet a website is rarely one contiguous object. WordPress media, generated thumbnails, caches, mailboxes, package trees and session data can create hundreds of thousands of entries. Each entry adds filesystem and archive work that a plan’s storage number does not describe. Voxfor’s website storage planning method separates file count, databases, retained backups and growth for the same reason.
A public pgBackRest issue documents an extreme version of this boundary: more than six million PostgreSQL files made per-file S3 processing a major part of backup duration even though the dataset was about 77 GB. That case is not a website restore measurement, but it is useful demand evidence that object count can change an otherwise byte-led forecast.
Keep the decision narrow:
| Evidence | Buyer question | What it can reveal | What it cannot prove | Useful acceptance |
|---|---|---|---|---|
| Payload bytes | How much content must move? | Transfer and capacity floor | Entry overhead or application readiness | Exact source and restored byte counts |
| Regular-file count | How much per-entry work exists? | Metadata-heavy restore risk | Storage or network speed by itself | Count captured before and after restore |
| Repeated extraction time | How long did this archive materialize here? | A measured local phase distribution | End-to-end outage time on another platform | Several runs with host and tool context |
| Database query | Is business state readable? | Structural and semantic usefulness | Every plugin or external dependency | Named invariants, not only quick_check=ok |
| Complete rehearsal | Can users finish the important action? | Achieved recovery time on the tested path | Future results after major change | A declared start, finish and retained receipt |
The controlled receipt below is the answer this host produced. Both layouts carried the same 16 MiB payload and the same database, yet the 4,096-file layout created 4,099 tar entries and had a 94 ms median extraction versus 22 ms for the 16-file layout. Both still fit the tiny exercise budget, so the result is a file-cardinality warning and evidence model—not a universal four-times-slower promise.
tar_version=tar (GNU tar) 1.35
python_version=Python 3.13.5
sqlite_version=3.46.1
lab_root=/tmp/voxfor-website-restore-161 scope=marker-owned
payload_equal=yes payload_bytes=16777216 few_files=16 many_files=4096 database_equal=yes
few archive_bytes=16803840 tar_entries=19 regular_files=17
many archive_bytes=18892800 tar_entries=4099 regular_files=4097
content_sha256_match=yes database_quick_check=ok orders=3 total_cents=23750 schema_version=7
extraction_receipt few_median_ms=22 few_range_ms=21-28 many_median_ms=94 many_range_ms=82-102 many_to_few_ratio=4.27 budget_ms=1000 few_inside_budget=true many_inside_budget=true
corruption_control_rc=26 hash_match=no structural_acceptance=rejected
verification=pass payload_equal=yes repeated_runs=5+5 semantic_state=accepted corruption_control=rejected
cleanup=complete
Start with the exact tool versions and a private path. Every later block checks the ownership marker before changing anything.
set -euo pipefail
LAB=/tmp/voxfor-website-restore-161
MARKER="$LAB/.voxfor-website-restore-lab"
[[ ! -e "$LAB" ]] || { printf 'Refusing existing path: %s\n' "$LAB" >&2; exit 9; }
install -d -m 0700 "$LAB"
printf '%s\n' voxfor-website-restore-lab-v1 > "$MARKER"
printf 'tar_version=%s\n' "$(tar --version | head -n1)"
printf 'python_version=%s\n' "$(python3 --version)"
printf 'sqlite_version=%s\n' "$(sqlite3 --version | awk '{print $1}')"
printf 'lab_root=%s scope=marker-owned\n' "$LAB"
A fixed 16 MiB deterministic byte stream is written in two layouts. Concatenating the sorted content files recreates the same stream in both cases. Each layout also receives a byte-identical SQLite database with three order rows.
set -euo pipefail
LAB=/tmp/voxfor-website-restore-161
grep -qx 'voxfor-website-restore-lab-v1' "$LAB/.voxfor-website-restore-lab"
install -d "$LAB/source/few/content" "$LAB/source/many/content"
python3 - "$LAB/source/few/content" "$LAB/source/many/content" <<'PY'
import hashlib
import pathlib
import sys
few = pathlib.Path(sys.argv[1])
many = pathlib.Path(sys.argv[2])
data = b''.join(hashlib.sha256(b'voxfor-restore-' + i.to_bytes(8, 'big')).digest()
for i in range(524288))
assert len(data) == 16 * 1024 * 1024
for index in range(16):
(few / f'asset-{index:04d}.bin').write_bytes(data[index*1048576:(index+1)*1048576])
for index in range(4096):
(many / f'asset-{index:04d}.bin').write_bytes(data[index*4096:(index+1)*4096])
PY
sqlite3 "$LAB/business.sqlite" <<'SQL'
PRAGMA journal_mode=DELETE;
CREATE TABLE orders(id INTEGER PRIMARY KEY, reference TEXT UNIQUE, total_cents INTEGER NOT NULL);
INSERT INTO orders(reference,total_cents) VALUES ('ORDER-101',12900),('ORDER-102',8700),('ORDER-103',2150);
PRAGMA user_version=7;
VACUUM;
SQL
install -m 0600 "$LAB/business.sqlite" "$LAB/source/few/database.sqlite"
install -m 0600 "$LAB/business.sqlite" "$LAB/source/many/database.sqlite"
few_bytes=$(find "$LAB/source/few/content" -type f -printf '%s\n' | awk '{sum+=$1} END{print sum+0}')
many_bytes=$(find "$LAB/source/many/content" -type f -printf '%s\n' | awk '{sum+=$1} END{print sum+0}')
few_files=$(find "$LAB/source/few/content" -type f | wc -l)
many_files=$(find "$LAB/source/many/content" -type f | wc -l)
[[ "$few_bytes" -eq "$many_bytes" && "$few_files" -eq 16 && "$many_files" -eq 4096 ]]
[[ "$(sha256sum "$LAB/source/few/database.sqlite" | awk '{print $1}')" == \
"$(sha256sum "$LAB/source/many/database.sqlite" | awk '{print $1}')" ]]
printf 'payload_equal=yes payload_bytes=%s few_files=%s many_files=%s database_equal=yes\n' \
"$few_bytes" "$few_files" "$many_files"
Sixteen MiB is intentionally small enough for a safe demonstration. A production rehearsal should preserve the real path depth, permissions, symlinks, ownership, sparse files, database engine and application dependencies. Do not copy customer data into an unprotected lab merely to make the sample realistic.
Create uncompressed tar archives so compression does not obscure the entry-cost comparison. Fixed ownership, ordering and modification time make the receipt repeatable; those flags are for this disposable fixture, not instructions to erase meaningful production ownership.
set -euo pipefail
LAB=/tmp/voxfor-website-restore-161
grep -qx 'voxfor-website-restore-lab-v1' "$LAB/.voxfor-website-restore-lab"
install -d "$LAB/archives"
for shape in few many; do
tar --sort=name --mtime='UTC 2026-08-13' --owner=0 --group=0 --numeric-owner \
-C "$LAB/source" -cf "$LAB/archives/$shape.tar" "$shape"
entries=$(tar -tf "$LAB/archives/$shape.tar" | wc -l)
archive_bytes=$(stat -c %s "$LAB/archives/$shape.tar")
regular_files=$(find "$LAB/source/$shape" -type f | wc -l)
printf '%s archive_bytes=%s tar_entries=%s regular_files=%s sha256=%s\n' \
"$shape" "$archive_bytes" "$entries" "$regular_files" \
"$(sha256sum "$LAB/archives/$shape.tar" | awk '{print $1}')"
done
Because tar stores a header for each entry, the many-file archive is somewhat larger. That difference is part of the result, not a defect to normalize away. The central control is equal content payload plus an identical database; the changed variable is how that content is divided into filesystem objects.
One timing can be distorted by cache state, scheduler activity or neighboring workloads. The next block alternates archive order across five rounds, extracts into a new directory each time, records elapsed milliseconds and refuses a result with the wrong file count or byte total.
set -euo pipefail
LAB=/tmp/voxfor-website-restore-161
grep -qx 'voxfor-website-restore-lab-v1' "$LAB/.voxfor-website-restore-lab"
install -d "$LAB/restores"
printf 'shape\tround\telapsed_ms\tregular_files\tpayload_bytes\n' > "$LAB/timings.tsv"
measure_extract() {
shape=$1 round=$2 destination="$LAB/restores/$shape-$round"
start_ns=$(date +%s%N)
install -d "$destination"
tar -C "$destination" -xf "$LAB/archives/$shape.tar"
end_ns=$(date +%s%N)
files=$(find "$destination/$shape/content" -type f | wc -l)
bytes=$(find "$destination/$shape/content" -type f -printf '%s\n' | awk '{sum+=$1} END{print sum+0}')
[[ "$bytes" -eq 16777216 ]]
[[ "$shape" == few && "$files" -eq 16 || "$shape" == many && "$files" -eq 4096 ]]
printf '%s\t%s\t%s\t%s\t%s\n' "$shape" "$round" "$(((end_ns-start_ns)/1000000))" "$files" "$bytes" >> "$LAB/timings.tsv"
}
for round in 1 2 3 4 5; do
if (( round % 2 )); then measure_extract few "$round"; measure_extract many "$round"
else measure_extract many "$round"; measure_extract few "$round"; fi
done
cat "$LAB/timings.tsv"
Warm-cache local extraction is deliberately narrower than end-to-end recovery. It excludes locating the backup, authorization, network transfer, decompression, provisioning, DNS, cache warm-up and human approval. Rewind’s RTO guidance correctly recommends measuring the actual recovery path rather than setting a target that the backup cannot meet.
Speed does not make a restore correct. Rebuild the concatenated payload hash for both layouts, compare it with the source, then ask SQLite for both structural health and the named business invariant: three orders totaling 23,750 cents at schema version 7.
set -euo pipefail
LAB=/tmp/voxfor-website-restore-161
grep -qx 'voxfor-website-restore-lab-v1' "$LAB/.voxfor-website-restore-lab"
stream_hash() { find "$1" -type f -print0 | sort -z | xargs -0 cat | sha256sum | awk '{print $1}'; }
few_source_hash=$(stream_hash "$LAB/source/few/content")
many_source_hash=$(stream_hash "$LAB/source/many/content")
few_restore_hash=$(stream_hash "$LAB/restores/few-5/few/content")
many_restore_hash=$(stream_hash "$LAB/restores/many-5/many/content")
[[ "$few_source_hash" == "$many_source_hash" && "$few_source_hash" == "$few_restore_hash" && "$few_source_hash" == "$many_restore_hash" ]]
for shape in few many; do
db="$LAB/restores/$shape-5/$shape/database.sqlite"
[[ "$(sqlite3 "$db" 'PRAGMA quick_check;')" == ok ]]
[[ "$(sqlite3 "$db" 'SELECT COUNT(*)||":"||SUM(total_cents)||":"||(SELECT user_version FROM pragma_user_version) FROM orders;')" == '3:23750:7' ]]
done
printf 'content_sha256_match=yes database_quick_check=ok orders=3 total_cents=23750 schema_version=7\n'
For a live website, replace the sample query with acceptance that represents customer value: log in, load a known article, locate a recent order, complete a safe checkout simulation, confirm media references and verify scheduled work. Voxfor’s website uptime monitoring guide shows why a generic HTTP 200 is weaker than a user-good transaction.
Convert the five timings into one median per layout, retain the range, and compare both with a declared extraction-phase budget. A median reduces the influence of one noisy sample, but it does not make different hosts directly comparable.
set -euo pipefail
LAB=/tmp/voxfor-website-restore-161
grep -qx 'voxfor-website-restore-lab-v1' "$LAB/.voxfor-website-restore-lab"
python3 - "$LAB/timings.tsv" "$LAB/receipt.txt" <<'PY'
import csv
import pathlib
import statistics
import sys
rows = list(csv.DictReader(open(sys.argv[1]), delimiter='\t'))
groups = {name: [int(row['elapsed_ms']) for row in rows if row['shape'] == name]
for name in ('few', 'many')}
assert all(len(values) == 5 for values in groups.values())
few = statistics.median(groups['few'])
many = statistics.median(groups['many'])
assert many > few
ratio = many / max(few, 1)
line = (f'extraction_receipt few_median_ms={few} few_range_ms={min(groups["few"])}-{max(groups["few"])} '
f'many_median_ms={many} many_range_ms={min(groups["many"])}-{max(groups["many"])} '
f'many_to_few_ratio={ratio:.2f} budget_ms=1000 few_inside_budget={str(few <= 1000).lower()} '
f'many_inside_budget={str(many <= 1000).lower()}')
pathlib.Path(sys.argv[2]).write_text(line + '\n')
print(line)
PY
Both samples may fit the one-second demonstration budget even when one is many times slower. That does not erase the observed scaling signal. It tells the buyer that this tiny fixture still had headroom; a real acceptance test needs the production entry count, destination filesystem, control panel or restore tool, network path and full application finish line.
After extraction, the negative control changes the SQLite header. The directory still exists and its file count is unchanged, yet the database hash and structural check must reject it.
set -euo pipefail
LAB=/tmp/voxfor-website-restore-161
grep -qx 'voxfor-website-restore-lab-v1' "$LAB/.voxfor-website-restore-lab"
install -d "$LAB/negative"
cp "$LAB/restores/few-5/few/database.sqlite" "$LAB/negative/database.sqlite"
printf 'BROKEN!!' | dd of="$LAB/negative/database.sqlite" bs=1 seek=0 conv=notrunc status=none
set +e
sqlite3 "$LAB/negative/database.sqlite" 'PRAGMA quick_check;' >"$LAB/negative/quick-check.out" 2>"$LAB/negative/quick-check.err"
negative_rc=$?
set -e
[[ "$negative_rc" -ne 0 ]]
[[ "$(sha256sum "$LAB/negative/database.sqlite" | awk '{print $1}')" != \
"$(sha256sum "$LAB/business.sqlite" | awk '{print $1}')" ]]
printf 'corruption_control_rc=%s hash_match=no structural_acceptance=rejected\n' "$negative_rc"
Control-panel archives add more boundaries: account metadata, database dumps, mail, DNS zones and ownership can be present or absent independently. Before a scheduled restore window, use Voxfor’s cPanel archive preflight procedure to validate that archive’s actual component set; the synthetic tar lab here does not claim cPanel completeness.
Ask a provider or internal owner for evidence in five parts:
Set the business clock separately from this extraction phase. Voxfor’s testable website RTO and RPO method defines acceptable downtime and data loss; the current receipt helps determine whether one technical phase can fit inside that broader target.
Responsibility also changes the decision. A self-managed server gives the operator more control over archive layout, parallelism, staging capacity and rehearsal scheduling. Owners who want provider help with backup configuration and recovery can evaluate managed hosting’s advanced backup scope, then request a measured restore result for their own site rather than treating the service-page wording as a site-specific RTO. Management scope is a responsibility choice; recovery evidence remains workload-specific either way.
One lab cannot rank filesystems, backup products or hosting providers. Re-run after substantial changes to media count, cache behavior, plugins, database size, storage class, control panel, archive format or recovery location. Preserve the raw timings; do not publish only the fastest run.
Run the final assertions, retain the secret-free receipt, and remove only the marked lab.
set -euo pipefail
LAB=/tmp/voxfor-website-restore-161
MARKER="$LAB/.voxfor-website-restore-lab"
grep -qx 'voxfor-website-restore-lab-v1' "$MARKER"
grep -q '^extraction_receipt ' "$LAB/receipt.txt"
[[ "$(awk -F'\t' 'NR>1&&$1=="few"{n++} END{print n+0}' "$LAB/timings.tsv")" -eq 5 ]]
[[ "$(awk -F'\t' 'NR>1&&$1=="many"{n++} END{print n+0}' "$LAB/timings.tsv")" -eq 5 ]]
printf 'verification=pass payload_equal=yes repeated_runs=5+5 semantic_state=accepted corruption_control=rejected\n'
cat "$LAB/receipt.txt"
rm -rf --one-file-system "$LAB"
[[ ! -e "$LAB" ]]
printf 'cleanup=complete\n'
The comparison is accepted when both source layouts contain exactly 16,777,216 payload bytes, their databases are byte-identical, the few-file layout has 16 content files, the many-file layout has 4,096, all ten extractions recreate the declared byte and file counts, the concatenated content hashes match, both restored databases return quick_check=ok and 3:23750:7, the many-file median exceeds the few-file median on this host, the corrupted database fails closed, and the marker-owned path is absent after cleanup.
Lab rollback deletes only /tmp/voxfor-website-restore-161 after its marker equals voxfor-website-restore-lab-v1; no service, listener or production path is changed. For a real recovery rehearsal, keep production read-only, restore into a separate destination, record the original DNS and routing state, and reverse only the tested cutover after the old environment remains intact. Never use this lab’s recursive cleanup command on an unverified or variable production path.
No. Backup size influences transfer, decompression and write volume, but file count, directory depth, metadata, database replay, destination performance and application validation also consume time. Measure the real restore path and retain phase timings.
Each file can require an archive header, path lookup, inode allocation, metadata updates and separate filesystem operations. The exact cost depends on the backup format, batching, storage and filesystem, so file count is a risk signal that needs a local test rather than a universal multiplier.
Usually rebuildable caches should be excluded when the application has a documented regeneration path, because they add bytes and entries without preserving unique business state. Confirm the cache is truly disposable and test the cold-start impact before changing backup scope.
Not by itself. A usable website restore also needs readable database state, correct files and permissions, compatible software, configuration, secrets, DNS or routing and a representative user transaction. Extraction is one measurable phase inside the complete recovery.
Five repeated local samples can expose gross variability in a controlled comparison, but they are not a universal standard. Business acceptance should include enough runs to characterize normal and adverse conditions, plus a new rehearsal after material workload or platform changes.
Ask for backup scope, retention, isolation, restore authority, a workload-specific file and byte inventory, phase timings, the tested destination, database and application acceptance, and the achieved recovery result. A generic “daily backup” label proves frequency, not recoverability or completion time.