If you're running Garage on a cheap Hetzner VPS and serving product images, avatars, or media directly from that box, a traffic spike will expose disk IOPS limits, network egress caps, and TLS overhead all at once. This guide shows the fix: separate storage from delivery by putting a CDN (Cloudflare or Bunny) in front of your Garage origin, then walks through the exact Next.js and Payload CMS configuration needed to make CDN URLs stick on both server and client.
I run Garage on a €5/month VPS for several client projects, including the izlozbica storefront. Early on, public reads went straight to Garage. Once traffic picked up, image requests started queueing behind disk reads and the whole app slowed down on every page that rendered a product grid. Adding a CDN solved the load problem, and migrating the URL-generation logic safely was the part that actually took care.
Why a €5 VPS Needs a CDN in Front of It
Garage is a lightweight, Rust-based S3 engine built for low-resource nodes, and it handles small, low-traffic deployments well. The hardware underneath it still has fixed limits.
On a typical €5 VPS (2 cores, 2 GB RAM, shared SSD), three constraints show up under load:
Network egress: a 1 Gbps port tops out around 125 MB/s. A burst of concurrent requests for 1.5 MB product images saturates that port fast.
Disk IOPS: Garage stores file blocks and metadata separately, and listing or reading thousands of small objects drives disk queue depth up quickly on shared cloud SSDs.
CPU: TLS handshakes for many simultaneous connections consume cycles that a 2-core box doesn't have to spare.
The fix is architectural: keep Garage as the storage origin, and let a CDN handle every public read.
Uploads, deletes, and admin operations still hit Garage directly. Public GET traffic hits the CDN, which fetches from Garage once per object and serves every subsequent request from the edge.
Choosing a CDN for a European-Hosted VPS
For a Slovenia-hosted origin, edge density in Central Europe matters more than total global PoP count.
CDN
Best for
Pricing
Notes
Cloudflare
Starting point, free tier
Free for basic CDN/DNS/DDoS
47+ European edge locations, easiest setup via orange-clouded DNS
Bunny CDN
Cost-conscious production use
$0.01/GB
Slovenia-based, 119+ global PoPs, ~$1 for 100 GB/month
Fastly / Akamai
Enterprise scale
$0.12/GB+
Complex contracts, better suited to traffic volumes far beyond a single-VPS setup
I went with Bunny CDN for izlozbica. The pricing is predictable, the PoP coverage across Europe is strong, and origin pull behavior with a custom S3 bucket path was straightforward to configure.
Cache and Storage Conventions That Make a CDN Effective
Before touching application code, three conventions need to be in place so the CDN can actually do its job:
Resize on upload, not on request. Generate thumbnail, card, and desktop variants once during upload, convert to WebP or AVIF, and store all variants. Dynamic resizing on every GET defeats CDN caching.
Cache-bust with a content hash in the filename, for example card-800-a83f91.webp. When a product image changes, upload a new file with a new hash rather than overwriting the old key. The CDN then serves the new image immediately without a manual purge.
With those conventions set, the application changes are about getting Payload, Next.js, and any custom URL helpers to emit and trust CDN URLs consistently.
Wiring Garage, Bunny, and a Payload App Together
This walkthrough uses the izlozbica Payload app in apps/web as the concrete example. Infrastructure setup for Garage, Traefik, and Bunny lives in the project's README.md; this section covers the application-layer wiring once that infrastructure exists.
Uploads and public reads travel through different paths:
mermaid
flowchart LR
subgraph app [Payload app]
Upload["Upload PUT"]
Storefront["Storefront GET"]
end
subgraph garage [Garage izlozbica-prod-s3]
S3API["S3 API :5900"]
S3Web["S3 web :5902"]
end
subgraph bunny [Bunny.net]
CDN["izlozbica.b-cdn.net"]
end
Upload -->|"S3_ENDPOINT"| S3API
Storefront --> CDN
CDN -->|"CDN_ORIGIN_URL"| S3Web
The bucket name belongs in the Bunny origin URL configuration, not in the CDN-facing paths your app generates. Keep the CDN hostname out of S3_ENDPOINT entirely; that variable is for uploads only.
Before changing any application code, confirm the infrastructure side is live:
Garage website mode is enabled for izlozbica-media
Traefik routes anonymous GET/HEAD requests on /izlozbica-media/* to Garage's web mode on :5902
The Bunny Pull Zone origin points to https://s3.izlozbica.si/izlozbica-media with no trailing slash
A direct origin request returns HTTP 200 for a known key
https://izlozbica.b-cdn.net/images/logo.png loads in a browser
Step 1: Environment Variables
The source of truth is apps/web/.env.production, which CI copies into the production deployment. Upload variables stay pointed at Garage, and CDN variables get added for public media:
bash
# Uploads only — do not change
S3_BUCKET=izlozbica-media
S3_REGION=garage
S3_ENDPOINT=https://s3.izlozbica.si
S3_ACCESS_KEY_ID=...
S3_SECRET_ACCESS_KEY=...
# Public media URLs (server-side Payload)
CDN_PUBLIC_URL=https://izlozbica.b-cdn.net
# Public media URLs (client-side Next.js — inlined at build time)
NEXT_PUBLIC_CDN_URL=https://izlozbica.b-cdn.net
# Optional ops reference (not read by app code)
CDN_ORIGIN_URL=https://s3.izlozbica.si/izlozbica-media
Moving to a custom hostname later just means updating both CDN variables:
Any change to a NEXT_PUBLIC_* value gets inlined at build time, so the Docker image needs a rebuild for the change to take effect in the browser bundle.
Step 2: Payload Storage Plugin
File: apps/web/payload.config.ts
The storage plugin needs to generate CDN URLs directly, rather than letting Payload proxy media through /api/media/file/...:
disablePayloadAccessControl: true works here because the media collection already grants public read access, so there's no need for Payload to sit in the request path. generateFileURL writes https://izlozbica.b-cdn.net/{key} into media.url and every size variant URL. S3_ENDPOINT stays untouched because uploads continue to target the Garage S3 API directly.
Step 3: Next.js Image Config
File: apps/web/next.config.ts
Once generateFileURL is live, product and media images resolve to Bunny URLs. Next.js needs those hosts allowlisted in images.remotePatterns:
This is the step most likely to break silently. If a path-normalization helper strips an absolute CDN host back down to a relative path like /foo.webp, the browser requests that path from the Next.js app itself, which never served it, and the image 404s. The bug is easy to miss locally because process.env.NEXT_PUBLIC_CDN_URL is present during next dev but can come back undefined in a browser bundle if the build pipeline didn't inline it correctly.
The fix adds an explicit client-side hostname check alongside the server-side env comparison:
isCdnUrl checks origin equality against the configured CDN base, so any URL that already points at the CDN passes through untouched. Everything else still goes through the normal relative-path normalization. This helper feeds ImageMedia, client-side Payload image rendering, cart line images, and every other storefront consumer of media URLs, so a fix here propagates everywhere images render.
Bulk catalog imports build media URLs independently of generateFileURL, so a separate code path can keep emitting /api/media/file/... even after the storage plugin change. Extracting a shared helper keeps both paths in sync:
Update import callers that already know the storage prefix to use this helper, then update the corresponding assertions in apps/web/src/tests/unit/bulk-media-import.logic.test.ts.
Step 6: Deploy
Apply the code changes across apps/web.
Add the CDN variables to apps/web/.env.production.
Build and push the Docker image through CI.
Update the production deployment's environment and image tag.
Redeploy the stack.
Upload a test image through the Payload admin and confirm the stored url uses the CDN hostname.
Verification Checklist
Test
Expected result
New upload in Payload admin
media.url starts with CDN_PUBLIC_URL
GET /api/media/:id
url and sizes.*.url are CDN URLs
Storefront product image
Network requests go to izlozbica.b-cdn.net
Origin fetch for the same object key
HTTP 200
Admin upload
File appears in the Garage bucket
Synthetic test image
https://izlozbica.b-cdn.net/images/logo.png loads
Existing Media Records
Media uploaded before this change may still carry /api/media/file/... URLs in Postgres.
Approach
When it fits
Leave them as-is
Old proxy URLs keep working during migration; revisit later
Re-save individual records
Fixes records you touch, leaves the rest unchanged
Run a backfill script
Rewrites url and sizes.*.url for every existing media row in one pass
New uploads get CDN URLs as soon as the payload.config.ts change ships, regardless of which approach you pick for old records.
Troubleshooting
Symptom
Likely cause
Fix
Bunny 404, origin 200
Wrong object key in the CDN URL
Check prefix and filename logic in generateFileURL
Bunny 404, origin 404
File missing from the bucket
Verify the object exists at the origin first
Bunny 403 or pull failed
Origin not publicly readable
Re-check Garage website mode and the Traefik web-read route
INVALID_IMAGE_OPTIMIZE_REQUEST
getMediaUrl stripped the CDN host
Fix getMediaUrl and add the Bunny host to remotePatterns
URL still uses /api/media/file/...
Missing disablePayloadAccessControl
Update the storage plugin config
CDN_PUBLIC_URL is required error
Env var missing in the container
Add it to production env and redeploy
CDN works, upload fails
Wrong S3_ENDPOINT or credentials
Keep S3_ENDPOINT=https://s3.izlozbica.si and verify keys
NEXT_PUBLIC_CDN_URL appears ignored
Image built before the env change
Rebuild the Docker image
S3 Uploads Work but ListObjectsV2 Fails on s3.izlozbica.si
This one cost the most debugging time, so it's worth its own walkthrough. Two symptoms point here: app uploads succeed via PutObject, but onboarding or imports fail on ListObjectsV2 with a 404 or an XML parse error; or Bunny and direct public reads return AccessDenied even though the object exists in Garage.
Two causes produce these symptoms, and both trace back to routing:
Stale Traefik Docker labels still attached to the running izlozbica-prod-s3 container can create an old izlozbica-s3-web-read@docker router. That router intercepts GET/HEAD bucket requests and sends them to Garage's web mode on :5902, when bucket-root S3 API calls need to land on :5900.
The file-provider web-read route can be missing or scoped too broadly. Public object reads need a dedicated route to :5902, while bucket-root calls like /izlozbica-media?list-type=2... need to stay on :5900.
The correct file-provider shape keeps the authenticated S3 API router on izlozbica-prod-s3:5900, with a higher-priority public-read router for Host(\s3.izlozbica.si`) && PathRegexp(`^/izlozbica-media/.+`)routed toizlozbica-prod-s3:5902, using strip-prefix and a Host: izlozbica-media.localhost` header.
To confirm this is the issue, test Garage directly first:
bash
# Signed ListObjectsV2 against the local Garage S3 API
curl ... 127.0.0.1:5900/izlozbica-media?list-type=2
# Expected: 200
Then check the container's current Traefik labels:
For izlozbica, the expected label state is traefik.enable=false on the Garage container itself, with all routing defined in /root/farmica-proxy/dynamic/izlozbica.yml. If old labels are still present, recreate the container to drop them:
bash
cd /root/payload-garage-storage/local/izlozbica-prod-s3
docker compose up -d --force-recreate garage
Media 404 for /api/media/file/... on a Tenant Subdomain
When a media row exists in the API but the file route returns 404, the staging S3_* settings likely point at a different bucket than the one files were uploaded to. A common version of this: a seed script ran against izlozbica-dev at https://s3.izlozbica.top, while staging configuration still referenced an older bucket or endpoint. Aligning S3_* values in apps/web/.env.staging and redeploying resolves it.
Media 404 for /api/media/file/... on the Primary Domain
DNS for s3.izlozbica.si can be correctly configured while Traefik still routes that hostname to the Next.js storefront instead of Garage. The giveaway: curl -sI https://s3.izlozbica.si/ returns content-type: text/html rather than S3 or XML, and app logs show S3 read failed or XML parse failures on ListObjects.
The cause is a routing-rule collision: the izlozbica-tenants router rule HostRegexp(\^.+.izlozbica.si$`)matchess3.izlozbica.siand wins unless reserved hosts are excluded or the S3 router is given higher priority. The fix is to syncapps/web/deployment-templates/farmica-proxy/dynamic/izlozbica.ymlto/root/farmica-proxy/dynamic/izlozbica.ymlon the host, withizlozbica-s3andizlozbica-logsrunning atpriority: 100and the tenant rule excluding reserved hosts likes3andlogs`.
Do I need to change my S3 upload code when adding a CDN?
No. S3_ENDPOINT and your upload credentials stay pointed at Garage. The CDN only handles the read path, configured through CDN_PUBLIC_URL and generateFileURL.
Why use Bunny instead of just Cloudflare for everything?
Cloudflare's free tier is a solid starting point for DNS, basic caching, and DDoS protection. Bunny's per-GB pricing and PoP density made more sense once traffic and image volume grew past what felt comfortable on a free tier alone. Either works as a first step; the migration path between them is mostly an environment variable change.
What happens to media uploaded before the CDN was added?
Old records keep their original /api/media/file/... URLs until you re-save them or run a backfill script. New uploads get CDN URLs immediately once generateFileURL ships, so you can migrate old records on whatever timeline suits the project.
How do I know if the CDN is actually serving traffic instead of falling back to my VPS?
Open browser dev tools on a storefront page and check the network tab for image requests. They should resolve to your CDN hostname (*.b-cdn.net or your custom domain), not your app's own origin.
Does resizing images on upload instead of on request cost more storage?
Yes, modestly — you're storing multiple variants instead of one. The tradeoff is worth it: dynamic resize-on-GET defeats CDN caching entirely, since every request with different query parameters becomes a fresh cache miss.
Wrapping Up
A €5 Garage VPS handles low-traffic self-hosted storage well, and it needs help once public reads scale. Separating storage (Garage) from delivery (a CDN) solves the load problem at the infrastructure level. Getting the application layer to cooperate — Payload's generateFileURL, Next.js remotePatterns, the client-side URL helper, and catalog import paths — is where most of the actual debugging happens, and where this guide should save you the hours I spent tracing 404s back to a stripped environment variable and a stale Traefik label.
Let me know in the comments if you have questions, and subscribe for more practical development guides.