$osscostcalc.comSelf-hosting cost intelligence

Object Storage + CDN: The Self-Hoster's Bandwidth Cheat Code

9 min read·1,720 words·Architecture

TL;DR

Self-hosted apps bleed money and CPU when the app server also ships PNGs, MP4s and release tarballs. Offloading those blobs to S3-compatible object storage behind a CDN typically drops origin egress by 90% or more, lets you shrink the VM, and in the Cloudflare R2 case eliminates egress fees entirely. This guide covers the real per-GB math for B2, R2 and DO Spaces, then shows the exact knobs to flip in WordPress, Mattermost and GitLab.

TL;DR

If your self-hosted app is also serving static files, you are paying twice: once in CPU and once in egress. Putting blobs behind S3-compatible storage plus a CDN routinely cuts origin bandwidth by 90 percent and lets you run the whole stack on a Hetzner CX22 (~EUR 4.5/mo) or a $6 DigitalOcean droplet instead of a beefier box. The cheat code is real, the wiring is mostly config, and Cloudflare R2 makes the egress math trivial because there is no egress fee.

Why the origin should never serve static assets

A typical WordPress install with a media-heavy blog generates 80 to 95 percent of its bytes from /wp-content/uploads. A Mattermost team channel doing screenshot-driven standups pushes most of its traffic through /api/v4/files. A GitLab instance hosting CI artifacts and container images can move tens of gigabytes of release tarballs per push. In every case, those bytes are immutable, cacheable, and have nothing to do with your application logic. Yet they go out the same NIC as your PHP, your WebSocket fanout, your Rails workers.

Three things go wrong when the origin ships blobs directly:

  • NIC saturation under burst. A 1 Gbps droplet capped at, say, 2 TB/mo of included transfer will choke long before it hits the monthly cap if a single asset goes viral. You are CPU-idle and bandwidth-broke.
  • Egress overage pricing is punitive. DigitalOcean charges around $0.01/GB over the included pool. Linode is similar. Hetzner is the outlier in Europe with EUR 1.00/TB outside the 20 TB included on most plans, but even Hetzner will rate-limit if you abuse it.
  • Backups and snapshots balloon. Every uploaded MP4 that lives on the root volume gets snapshotted, replicated, and rsynced forever.

The fix is the same pattern Netflix, GitHub, and every other large shop uses: put the immutable bytes on object storage, slap a CDN in front, point the app at the CDN URL, and let the origin do nothing but application work.

The 2026 per-GB landscape

Here is the math on the three S3-compatible providers that actually matter for small to mid self-hosting. Prices are list as of mid-2026.

Provider Storage / GB-month Egress to internet Class A (writes) per 1k Class B (reads) per 1k Free egress allowance
Backblaze B2 $0.006 $0.01/GB (free up to 3x stored) $0.004 $0.0004 3x your stored data per month
Cloudflare R2 $0.015 $0.00 ~$4.50 per million ~$0.36 per million Unlimited (no egress fee)
DigitalOcean Spaces $5/mo flat (250 GB + 1 TB egress) $0.01/GB over included included 1 TB/mo bundled
Hetzner Object Storage ~EUR 5.99/mo per TB included with generous fair use included included bundled
Linode Object Storage $5/mo (250 GB + 1 TB egress) $0.005/GB over included included 1 TB/mo bundled

Two takeaways. First, R2 wins the moment your egress-to-storage ratio goes above roughly 3:1, which is the case for any public-facing site with viral potential. Second, B2 wins for cold-ish data where reads are bounded, because storage at $6/TB-month is the cheapest in the industry and the 3x free egress is generous as long as you front it with Cloudflare via the Bandwidth Alliance (still free, still works).

A worked example. Suppose you self-host a podcast on a $6 droplet and serve 5 TB of MP3s per month from 200 GB stored:

  • Origin only: 5 TB egress. On DO, after the 1 TB included you owe $40/mo in overage. Plus the droplet.
  • B2 + Cloudflare: Storage 200 GB x $0.006 = $1.20. Egress is free through Cloudflare. Cloudflare free plan handles it. Total storage cost: $1.20.
  • R2 direct: 200 GB x $0.015 = $3.00. Egress: $0. Total: $3.

You just turned a $46/mo bill into $3 and freed the droplet to do podcast app work. This is the cheat code.

When the CDN matters more than the storage

People obsess over the per-GB storage number. For most self-hosters, the CDN in front is doing the heavy lifting. A Cloudflare free plan in front of any origin (object storage or not) will absorb 90 to 99 percent of repeat requests once the cache warms. The hit ratio depends on three things you control:

  1. Cache-Control headers. Anything immutable should be public, max-age=31536000, immutable. WordPress media, GitLab artifacts, Mattermost file IDs are all immutable by ID.
  2. Cache key normalization. Strip query strings on assets, ignore the Authorization header for public files, do not vary on cookies for /static/*.
  3. Tiered cache or Argo. For globally distributed audiences, Cloudflare's tiered cache (free) reduces origin pulls further by chaining edge POPs.

Once the CDN is doing its job, the choice between R2, B2, and Spaces is mostly about storage price and operational ergonomics, because the CDN is serving 95 percent of the bytes anyway.

Wiring it into WordPress

The canonical plugin is WP Offload Media (Delicious Brains, now part of WP Engine). The free tier handles the rewrite; the paid tier adds automatic uploads to existing media. Pattern:

  • Create an R2 bucket, generate an API token scoped to that bucket.
  • Install the plugin, set the provider to "Cloudflare R2", paste the access key and account ID.
  • Set the custom domain to cdn.yoursite.com and CNAME that to your R2 public bucket (or to a Cloudflare Worker that signs URLs).
  • Enable "Remove Files From Server" only after you have verified uploads round-trip correctly.

For pure DIY without a plugin, you can do this with two filters in functions.php that rewrite wp_get_attachment_url and the the_content image src. But honestly, the plugin is worth the hour you save.

The CX22 at EUR 4.5/mo running nginx + PHP-FPM + MariaDB will handle a startling amount of traffic once it is no longer pushing JPEGs. I have seen this exact setup take a HackerNews front-page hit while the origin sat at 8 percent CPU.

Wiring it into Mattermost

Mattermost ships with native S3 support. In config.json under FileSettings:

"DriverName": "amazons3",
"AmazonS3Bucket": "mm-files",
"AmazonS3Endpoint": "<account>.r2.cloudflarestorage.com",
"AmazonS3AccessKeyId": "...",
"AmazonS3SecretAccessKey": "...",
"AmazonS3SSL": true,
"AmazonS3SignV2": false,
"AmazonS3Region": "auto"

The gotcha: Mattermost streams files through the app server even when they live in S3, because it enforces channel ACLs per request. So the CDN in front of Mattermost itself only helps for the static web bundle, not for file downloads. If you have a 200-user team uploading screenshots all day, the upside is mostly storage offload and backup simplicity, not bandwidth offload. Still worth doing: a Hetzner CX22 with 40 GB SSD fills up fast otherwise.

For public file sharing (Mattermost's "Public Link" feature), files do get a presigned URL that hits S3 directly. That path is fully CDN-cacheable if you front R2 with Cloudflare.

Wiring it into GitLab

GitLab is where this pattern pays off most because the blob types are huge: LFS objects, CI artifacts, container registry layers, Terraform state, package registry tarballs. Each has its own [object_store] block in gitlab.rb:

gitlab_rails['object_store']['enabled'] = true
gitlab_rails['object_store']['connection'] = {
  'provider' => 'AWS',
  'aws_access_key_id' => '...',
  'aws_secret_access_key' => '...',
  'endpoint' => 'https://<account>.r2.cloudflarestorage.com',
  'region' => 'auto',
  'path_style' => true
}
gitlab_rails['object_store']['objects']['artifacts']['bucket'] = 'gitlab-artifacts'
gitlab_rails['object_store']['objects']['lfs']['bucket'] = 'gitlab-lfs'
gitlab_rails['object_store']['objects']['packages']['bucket'] = 'gitlab-packages'

After enabling, run gitlab-rake gitlab:artifacts:migrate and the equivalents for LFS, uploads, and packages to push existing data out.

GitLab issues presigned URLs and clients pull directly from object storage, so the origin really does drop out of the bandwidth path. A self-hosted GitLab on a 4 vCPU / 8 GB box (around $48/mo on DO, EUR 16/mo on Hetzner with a CX32) that used to need 200 GB of block storage for artifacts now needs 40 GB for the database and repos. Block storage on Hetzner runs about EUR 0.044/GB-month, so you save another EUR 7/mo on volume size alone.

The hidden costs nobody mentions

Three things to plan for:

  • Class A (write) operations on R2. They are $4.50 per million. A misconfigured CI pipeline that re-uploads the same artifact every 30 seconds can rack up real money. Set lifecycle rules.
  • Egress on B2 when you bypass Cloudflare. The 3x free egress is conditional on going through a Bandwidth Alliance partner. Direct pulls cost $0.01/GB. Always front B2 with Cloudflare or Bunny.
  • DO Spaces bandwidth pooling. Spaces transfer pools with droplet transfer on the same account. Useful, but easy to misread on the invoice.

If you want to model the all-in cost for your specific stack (droplet + volume + object storage + backups), the osscostcalc calculator on this site lets you stack those line items across DigitalOcean, Hetzner, and Linode side by side, which is how I usually settle the "should I move to Hetzner" debate in 30 seconds.

Decision checklist

Do this week, in order:

  • Run du -sh on your media or uploads directory. If it is over 5 GB or your monthly egress is over 500 GB, this pattern pays for itself immediately.
  • Pick the provider: R2 if you have any public viral risk, B2 + Cloudflare if you are cost-optimizing cold storage, DO Spaces if you are already deep in the DO ecosystem and value one invoice.
  • Put Cloudflare (free plan is fine) in front of the bucket with a custom hostname.
  • Set Cache-Control: public, max-age=31536000, immutable on all uploaded assets.
  • Flip the app config (WP Offload Media, Mattermost FileSettings, GitLab object_store).
  • Migrate existing files with the app's built-in migrator, then verify a sample of URLs.
  • Only after a week of clean logs, delete local copies and shrink the volume.
  • Re-run your sizing in the comparison tool on this site to see if you can now drop to a smaller VM.

Most self-hosters can complete this in an afternoon and cut their next invoice by 30 to 70 percent. It is the highest-leverage single change you can make to a self-hosted stack.

object-storagecdnbandwidthr2backblazeself-hosting