BuildWithMatija
  1. Home
  2. Blog
  3. Cloudflare
  4. Ultimate CDN Setup for Garage S3 on a €5 VPS — Step-by-Step

Ultimate CDN Setup for Garage S3 on a €5 VPS — Step-by-Step

Configure Bunny or Cloudflare with Next.js and Payload CMS to offload media from Garage S3, stop 404s, and reduce VPS…

21st June 2026·Updated on:24th June 2026··
Cloudflare
Ultimate CDN Setup for Garage S3 on a €5 VPS — Step-by-Step

☁️ Cloudflare Edge Development Guides

Complete Cloudflare guides with practical examples, deployment strategies, and developer prompts to help you build and ship edge applications faster.

No spam. Unsubscribe anytime.

📄View markdown version
0

Frequently Asked Questions

About the author

Matija Žiberna

Matija Žiberna

Full-stack developer, co-founder

AboutResume

Self-taught full-stack developer sharing lessons from building software and startups.

I'm Matija Žiberna, a self-taught full-stack developer and co-founder passionate about building products, writing clean code, and figuring out how to turn ideas into businesses. I write about web development with Next.js, lessons from entrepreneurship, and the journey of learning by doing. My goal is to provide value through code—whether it's through tools, content, or real-world software.

Contents

  • Why a €5 VPS Needs a CDN in Front of It
  • Choosing a CDN for a European-Hosted VPS
  • Cache and Storage Conventions That Make a CDN Effective
  • Wiring Garage, Bunny, and a Payload App Together
  • Step 1: Environment Variables
  • Step 2: Payload Storage Plugin
  • Step 3: Next.js Image Config
  • Step 4: Frontend URL Helper
  • Step 5: Catalog Import URLs
  • Step 6: Deploy
  • Verification Checklist
  • Existing Media Records
  • Troubleshooting
  • S3 Uploads Work but `ListObjectsV2` Fails on `s3.izlozbica.si`
  • `DNS_PROBE_FINISHED_NXDOMAIN`
  • Media `404` for `/api/media/file/...` on a Tenant Subdomain
  • Media `404` for `/api/media/file/...` on the Primary Domain
  • File Reference
  • FAQ
  • Wrapping Up
On this page:
  • Why a €5 VPS Needs a CDN in Front of It
  • Choosing a CDN for a European-Hosted VPS
  • Cache and Storage Conventions That Make a CDN Effective
  • Wiring Garage, Bunny, and a Payload App Together
  • Troubleshooting
Build with Matija logo

Build with Matija

Modern websites, content systems, and AI workflows built for long-term growth.

Services

  • Headless CMS Websites
  • Next.js & Headless CMS Advisory
  • AI Systems & Automation
  • Website & Content Audit

Resources

  • Case Studies
  • How I Work
  • Blog
  • Topics
  • CMS Hub
  • E-commerce Hub
  • B2B Website Strategy
  • Dashboard

Headless CMS

  • Payload CMS Developer
  • CMS Migration
  • Multi-Tenant CMS
  • Payload vs Sanity
  • Payload vs WordPress
  • Payload vs Contentful

Get in Touch

Ready to modernize your stack? Let's talk about what you're building.

Book a discovery callContact me →
© 2026Build with Matija•All rights reserved•Privacy Policy•Terms of Service
BuildWithMatija
Get In Touch

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.

code
[ App Uploads / Admin ]
          │
          ▼ (PUT requests)
  ┌─────────────────────────────────┐
  │  Storage Origin (Garage S3)     │  <── Your €5 VPS
  └─────────────────────────────────┘
          ▲
          │ (Pull once on cache miss)
  ┌─────────────────────────────────┐
  │  CDN Cache (Cloudflare / Bunny) │  <── Global Edge Network
  └─────────────────────────────────┘
          ▲
          │ (GET requests)
[ Public Visitors / Browsers ]

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.

CDNBest forPricingNotes
CloudflareStarting point, free tierFree for basic CDN/DNS/DDoS47+ European edge locations, easiest setup via orange-clouded DNS
Bunny CDNCost-conscious production use$0.01/GBSlovenia-based, 119+ global PoPs, ~$1 for 100 GB/month
Fastly / AkamaiEnterprise 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:

  1. 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.
  2. Set immutable cache headers on stored objects:
    http
    Cache-Control: public, max-age=31536000, immutable
    
  3. 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
RoleExample
S3 object keyimages/logo.png, tenants/t_123/products/p_456/card-800-a83f91.webp
Upload endpoint (S3_ENDPOINT)https://s3.izlozbica.si
Origin fetch (Bunny → Garage)https://s3.izlozbica.si/izlozbica-media/{key}
CDN public URL (CDN_PUBLIC_URL)https://izlozbica.b-cdn.net/{key}

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:

bash
CDN_PUBLIC_URL=https://cdn.izlozbica.si
NEXT_PUBLIC_CDN_URL=https://cdn.izlozbica.si

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/...:

ts
s3Storage({
  collections: {
    [Media.slug]: {
      disablePayloadAccessControl: true,
      generateFileURL: ({ filename, prefix }) => {
        const cdnBase = process.env.CDN_PUBLIC_URL?.replace(/\/$/, '');
        if (!cdnBase) {
          throw new Error('CDN_PUBLIC_URL is required for public media URLs');
        }

        const key = prefix ? `${prefix}/${filename}` : filename;
        return `${cdnBase}/${key}`;
      },
    },
  },
  bucket: process.env.S3_BUCKET || '',
  config: {
    endpoint: process.env.S3_ENDPOINT || '',
    region: process.env.S3_REGION || 'garage',
    forcePathStyle: true,
    credentials: {
      accessKeyId: process.env.S3_ACCESS_KEY_ID || '',
      secretAccessKey: process.env.S3_SECRET_ACCESS_KEY || process.env.S3_SECRET || '',
    },
  },
}),

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:

ts
images: {
  remotePatterns: [
    // ...existing patterns...
    {
      protocol: 'https',
      hostname: 'izlozbica.b-cdn.net',
      pathname: '/**',
    },
    {
      protocol: 'https',
      hostname: 'cdn.izlozbica.si',
      pathname: '/**',
    },
  ],
},

Keep the existing localPatterns entry for /api/media/file/** during migration, since older records in the database may still reference the proxy path.

Step 4: Frontend URL Helper

File: apps/web/src/payload/utilities/images/getMediaUrl.ts

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:

ts
// File: src/payload/utilities/images/getMediaUrl.ts

function isCdnUrl(url: string): boolean {
  const cdnBase = process.env.NEXT_PUBLIC_CDN_URL || process.env.CDN_PUBLIC_URL;
  if (!cdnBase) return false;

  try {
    return new URL(url).origin === new URL(cdnBase).origin;
  } catch {
    return false;
  }
}

export const getMediaUrl = (
  url: string | null | undefined,
  cacheTag?: string | null,
): string => {
  if (!url) return '';

  if (isCdnUrl(url)) {
    return cacheTag ? `${url}?${cacheTag}` : url;
  }

  const normalizedUrl = normaliseImagePath(url);
  return cacheTag ? `${normalizedUrl}?${cacheTag}` : normalizedUrl;
};

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.

Step 5: Catalog Import URLs

File: apps/web/src/payload/services/catalog-import/bulk-media-import.ts

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:

ts
export function buildMediaFileUrl(filename: string, prefix?: string): string {
  const cdnBase = process.env.CDN_PUBLIC_URL?.replace(/\/$/, '') ?? '';
  const key = prefix ? `${prefix}/${filename}` : filename;
  return `${cdnBase}/${key}`;
}

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

  1. Apply the code changes across apps/web.
  2. Add the CDN variables to apps/web/.env.production.
  3. Build and push the Docker image through CI.
  4. Update the production deployment's environment and image tag.
  5. Redeploy the stack.
  6. Upload a test image through the Payload admin and confirm the stored url uses the CDN hostname.

Verification Checklist

TestExpected result
New upload in Payload adminmedia.url starts with CDN_PUBLIC_URL
GET /api/media/:idurl and sizes.*.url are CDN URLs
Storefront product imageNetwork requests go to izlozbica.b-cdn.net
Origin fetch for the same object keyHTTP 200
Admin uploadFile appears in the Garage bucket
Synthetic test imagehttps://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.

ApproachWhen it fits
Leave them as-isOld proxy URLs keep working during migration; revisit later
Re-save individual recordsFixes records you touch, leaves the rest unchanged
Run a backfill scriptRewrites 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

SymptomLikely causeFix
Bunny 404, origin 200Wrong object key in the CDN URLCheck prefix and filename logic in generateFileURL
Bunny 404, origin 404File missing from the bucketVerify the object exists at the origin first
Bunny 403 or pull failedOrigin not publicly readableRe-check Garage website mode and the Traefik web-read route
INVALID_IMAGE_OPTIMIZE_REQUESTgetMediaUrl stripped the CDN hostFix getMediaUrl and add the Bunny host to remotePatterns
URL still uses /api/media/file/...Missing disablePayloadAccessControlUpdate the storage plugin config
CDN_PUBLIC_URL is required errorEnv var missing in the containerAdd it to production env and redeploy
CDN works, upload failsWrong S3_ENDPOINT or credentialsKeep S3_ENDPOINT=https://s3.izlozbica.si and verify keys
NEXT_PUBLIC_CDN_URL appears ignoredImage built before the env changeRebuild 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:

  1. 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.
  2. 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:

bash
docker inspect izlozbica-prod-s3 --format '{{json .Config.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

Run these smoke tests after the fix:

bash
curl -I https://s3.izlozbica.si/izlozbica-media/<filename>   # 200
curl -I https://izlozbica.b-cdn.net/<filename>                # 200

DNS_PROBE_FINISHED_NXDOMAIN

If a hostname doesn't resolve publicly yet, use curl --resolve to target the intended IP directly while DNS propagates:

bash
curl --resolve demo.izlozbica.top:443:127.0.0.1 https://demo.izlozbica.top

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.

bash
curl -k -sS -o /dev/null -w '%{http_code}\n' \
  https://<tenant>.izlozbica.top/api/media/file/<filename>
# Expected: 200

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`.

bash
curl -sS -o /dev/null -w '%{http_code}\n' \
  https://ankiu.izlozbica.si/api/media/file/<filename>
# Expected: 200

If objects already exist in izlozbica-media, no re-seed is needed once routing is corrected.

File Reference

FileChange
apps/web/.env.productionAdd CDN_PUBLIC_URL and NEXT_PUBLIC_CDN_URL
apps/web/payload.config.tsAdd disablePayloadAccessControl and generateFileURL
apps/web/next.config.tsAllow Bunny hostnames in remotePatterns
apps/web/src/payload/utilities/images/getMediaUrl.tsPreserve absolute CDN URLs
apps/web/src/payload/services/catalog-import/bulk-media-import.tsEmit CDN URLs for imports
apps/web/src/tests/unit/bulk-media-import.logic.test.tsUpdate URL expectations

FAQ

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.

Thanks, Matija