---
title: "Ultimate CDN Setup for Garage S3 on a €5 VPS — Step-by-Step"
slug: "cdn-garage-s3-5-euro-vps"
published: "2026-06-21"
updated: "2026-06-24"
validated: "2026-06-24"
categories:
  - "Cloudflare"
tags:
  - "CDN for Garage S3"
  - "Garage S3 CDN"
  - "Bunny CDN"
  - "Cloudflare CDN"
  - "Payload CMS generateFileURL"
  - "Next.js remotePatterns"
  - "CDN_PUBLIC_URL"
  - "getMediaUrl helper"
  - "S3_ENDPOINT Garage"
  - "€5 VPS performance"
llm-intent: "reference"
audience-level: "intermediate"
framework-versions:
  - "garage (self-hosted s3)"
  - "bunny cdn"
  - "cloudflare"
  - "next.js"
  - "payload cms"
status: "stable"
llm-purpose: "CDN for Garage S3: guide to configure Bunny or Cloudflare, update Payload and Next.js, persist CDN_PUBLIC_URL in media, reduce VPS load—deploy and verify…"
llm-prereqs:
  - "Access to Garage (self-hosted S3)"
  - "Access to Bunny CDN"
  - "Access to Cloudflare"
  - "Access to Next.js"
  - "Access to Payload CMS"
llm-outputs:
  - "Completed outcome: CDN for Garage S3: guide to configure Bunny or Cloudflare, update Payload and Next.js, persist CDN_PUBLIC_URL in media, reduce VPS load—deploy and verify…"
---

**Summary Triples**
- (Garage S3, should be kept as, storage origin (not public delivery) behind a CDN)
- (CDN (Bunny or Cloudflare), must have origin set to, the Garage S3 HTTP endpoint (S3_ENDPOINT))
- (Payload CMS generateFileURL, must be updated to, use CDN_PUBLIC_URL when present to generate persisted CDN URLs)
- (Media metadata, should persist, CDN_PUBLIC_URL so both server and client use the CDN URL)
- (Next.js configuration, requires changes to, remotePatterns/next/image domains or loader to allow CDN hostnames)
- (Traefik/proxy, should be configured to, route CDN origin requests securely to Garage with correct Host and TLS)
- (DNS, must map, your CDN hostname (or zone) to the CDN provider and validate origin)
- (Testing, includes steps to, verify headers, origin responses, and that traffic is served from CDN cache)
- (404s after CDN switch, are usually caused by, mismatched URL generation or missing persisted CDN_PUBLIC_URL in stored media)
- (Rollback, should be handled by, reverting URL generation in Payload and clearing/redirecting CDN origin settings)

### {GOAL}
CDN for Garage S3: guide to configure Bunny or Cloudflare, update Payload and Next.js, persist CDN_PUBLIC_URL in media, reduce VPS load—deploy and verify…

### {PREREQS}
- Access to Garage (self-hosted S3)
- Access to Bunny CDN
- Access to Cloudflare
- Access to Next.js
- Access to Payload CMS

### {STEPS}
1. Confirm infrastructure and origin
2. Add CDN environment variables
3. Update Payload storage plugin
4. Allowlist CDN hosts in Next.js
5. Harden client-side URL helper
6. Update catalog import paths
7. Build, deploy, and update env
8. Verify and backfill existing media

<!-- llm:goal="CDN for Garage S3: guide to configure Bunny or Cloudflare, update Payload and Next.js, persist CDN_PUBLIC_URL in media, reduce VPS load—deploy and verify…" -->
<!-- llm:prereq="Access to Garage (self-hosted S3)" -->
<!-- llm:prereq="Access to Bunny CDN" -->
<!-- llm:prereq="Access to Cloudflare" -->
<!-- llm:prereq="Access to Next.js" -->
<!-- llm:prereq="Access to Payload CMS" -->
<!-- llm:output="Completed outcome: CDN for Garage S3: guide to configure Bunny or Cloudflare, update Payload and Next.js, persist CDN_PUBLIC_URL in media, reduce VPS load—deploy and verify…" -->

# Ultimate CDN Setup for Garage S3 on a €5 VPS — Step-by-Step
> CDN for Garage S3: guide to configure Bunny or Cloudflare, update Payload and Next.js, persist CDN_PUBLIC_URL in media, reduce VPS load—deploy and verify…
Matija Žiberna · 2026-06-21

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.

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

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

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

| Role | Example |
|---|---|
| S3 object key | `images/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

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

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 to `izlozbica-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$\`)` matches `s3.izlozbica.si` and wins unless reserved hosts are excluded or the S3 router is given higher priority. The fix is to sync `apps/web/deployment-templates/farmica-proxy/dynamic/izlozbica.yml` to `/root/farmica-proxy/dynamic/izlozbica.yml` on the host, with `izlozbica-s3` and `izlozbica-logs` running at `priority: 100` and the tenant rule excluding reserved hosts like `s3` and `logs`.

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

| File | Change |
|---|---|
| `apps/web/.env.production` | Add `CDN_PUBLIC_URL` and `NEXT_PUBLIC_CDN_URL` |
| `apps/web/payload.config.ts` | Add `disablePayloadAccessControl` and `generateFileURL` |
| `apps/web/next.config.ts` | Allow Bunny hostnames in `remotePatterns` |
| `apps/web/src/payload/utilities/images/getMediaUrl.ts` | Preserve absolute CDN URLs |
| `apps/web/src/payload/services/catalog-import/bulk-media-import.ts` | Emit CDN URLs for imports |
| `apps/web/src/tests/unit/bulk-media-import.logic.test.ts` | Update 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

## LLM Response Snippet
```json
{
  "goal": "CDN for Garage S3: guide to configure Bunny or Cloudflare, update Payload and Next.js, persist CDN_PUBLIC_URL in media, reduce VPS load—deploy and verify…",
  "responses": [
    {
      "question": "What does the article \"Ultimate CDN Setup for Garage S3 on a €5 VPS — Step-by-Step\" cover?",
      "answer": "CDN for Garage S3: guide to configure Bunny or Cloudflare, update Payload and Next.js, persist CDN_PUBLIC_URL in media, reduce VPS load—deploy and verify…"
    }
  ]
}
```