Migrating ReadonlyREST's build catalog off AWS S3

For eight years, every ReadonlyREST release uploaded another set of Kibana plugin bundles to AWS S3. By the time I looked, the catalog had grown to 145 versions — about 103,000 objects, 1.71 TB — and we were paying to keep every byte of it, month after month. This is the story of moving all of it to Hetzner Object Storage through DeltaGlider: why, how, what it cost, and the pitfalls worth knowing if you try the same.

Why we finally moved it

A build catalog is a strange kind of data. Nobody touches it for months, and then a customer on an old Elasticsearch version needs one exact plugin build — and it has to be there, byte-perfect. So it sits in a bucket, grows with every release, and the bill grows with it.

The wasteful part is what those releases contain. Each version ships nearly the same set of plugin bundles as the one before it, with a thin layer of changed code inside. Across the 145 releases from 1.12.x to 1.69.0 we uploaded near-identical files again and again — and paid full price to store every copy, because S3 neither knows nor cares that the bytes repeat.

I picked Hetzner Object Storage as the destination because it is the cheapest S3-compatible option that fit: cold-tier-friendly pricing, EU jurisdiction, and no enterprise sales process. Beshu Tech has no partnership with Hetzner; it's a vendor choice, not a sponsored one.

The setup

The whole rig was one small EC2 instance in the same region as the source bucket, so reading from S3 was free:

  • Source: s3://readonlyrest-data/build/ (AWS S3, eu-west-1)
  • Destination: s3://beshu/ror/builds/ (Hetzner, hel1)
  • Runner: single t4g.medium EC2 in eu-west-1 (intra-region read = no AWS egress charge)
  • Pipeline per version: aws s3 sync → merge enterprise/free/pro/* into legacy/deltaglider cp -r to Hetzner
  • Cold storage: the older 99 versions were asleep on Glacier Deep Archive

The run itself was pleasantly boring. The Glacier versions had to be woken up first — a Bulk-tier restore, about $2 for all ninety-nine. From there, each version followed the same loop: sync it down, merge the enterprise, free, and pro variants into one layout, and push it to Hetzner through deltaglider cp. The entire migration cost roughly $9 of EC2 compute.

The results

Every number below is verified: each migrated version was hashed at the source, round-tripped back through DeltaGlider, and hashed again. Three versions tell the story, and the last row is the whole catalog:

Version Size (Encrypted) Verdict
1.69.0
warm-path
2,866 MB 32.4 MB -98.9%
Excellent
1.38.0
warm-path, multi-shape
13.7 GB 3.3 GB -75.6%
Fair cross-major ES range
1.17.1
cold from Glacier
26 MB 6.8 MB -74.0%
Small dataset, dominated by reference
Full migration
174 versions
1.71 TB 134 GB
Complete

The outlier is the interesting one. 1.38.0 compressed worse than the rest because its legacy/ folder mixes Kibana plugins for Elasticsearch 6, 7, and 8 — three families of files that are not similar to each other, so the delta encoder finds less repetition to exploit. The root folder stored 1,520% smaller; the legacy folder only 270%. The admin UI's Delta Efficiency Panel flags exactly this pattern ("Fair, near Poor"), so you can spot it in your own buckets before it costs you anything.

Pitfalls worth knowing

Four things tripped me up along the way. None of them was serious, and every one has a fix you can copy:

The CLI wrote its temp files to /tmp

The Python deltaglider 6.1.1 CLI hardcoded /tmp for its working files, and /tmp on my EC2 was a 1.9 GB tmpfs — a mid-size version ran it out of space. I patched it to honor TMPDIR and sent the fix upstream.

A 30 GB disk wasn't enough

Staging 13 GB of source next to xdelta3's temporary files burst the 30 GB volume at the first multi-gigabyte version. Growing it to 100 GB cost about $8 per month for the migration window — cheap insurance.

pipefail and head don't mix

My script had set -o pipefail and piped deltaglider's output through grep -E ... | head -200. After the 200th matching line, head closed the pipe, pipefail turned that into an exit, and the migration stopped silently mid-upload. Dropping the | head made the logs noisier and the script honest.

Reverse proxies default to 60-second timeouts

The production deployment sits behind Traefik, whose default respondingTimeouts.readTimeout is 60 seconds. Multi-gigabyte uploads over the internet died at exactly the 60-second mark with a 502.

I raised it to 30 minutes and wrote the gotcha into the troubleshooting guide, because every reverse proxy's default is wrong for large S3 uploads.

Checking the bytes came back right

Compression is worthless if the data comes back wrong, so every migrated version went through the same three checks:

  1. SHA-256 of the source object (computed by aws s3api head-object)
  2. SHA-256 of the round-tripped object via the DeltaGlider proxy (GET, hash, compare)
  3. Manifest check: same object count, same total source size

No exceptions logged. Bit-perfect round-trip on every sampled object, across all three folders (root, legacy, universal).

Reproducibility

Available on request (contact@beshu.tech):

  • The migration script
  • The per-version manifest (CSV)
  • The SHA-256 verification script and raw results

Try the same migration on your data