TechnologieWeb

Ein selbstgehosteter Imgproxy-Server spart Dutzende CDN-Abos

netcomputing.de Redaktion26. August 20268 Min. Lesezeit

Zusammenfassung

Agenturen mit Dutzenden Next.js-Websites zahlen bei Bild-CDNs wie Cloudinary schnell mehrere hundert Euro monatlich, obwohl jede Seite nur Bilder verkleinert und in WebP oder AVIF wandelt. Der quelloffene Server imgproxy (v4.0.14) läuft dafür als ein Docker-Container, den next/image per Custom Loader statt des eingebauten Next.js-Optimierers anspricht. HMAC-signierte URLs verhindern Missbrauch als offener Bilderproxy. Ergebnis: eine gemeinsame Infrastruktur für das ganze Portfolio statt eines CDN-Abos je Website.

Systemvoraussetzungen

Wer Bilder für zehn, zwanzig oder vierzig Marketingseiten verkleinern und in moderne Formate umwandeln lassen will, kann das über einen einzigen Docker-Container erledigen statt über ebenso viele Verträge mit einem Bild-CDN. Die folgende Tabelle nennt die Komponenten und Versionen, die der beschriebene Aufbau voraussetzt.

KomponenteVersion / Anforderung
imgproxyv4.0.14 (aktueller Stable-Release, Image unter ghcr.io/imgproxy/imgproxy)
Docker Enginemit Compose-Plugin (Befehl docker compose, nicht das alte separate docker-compose)
Next.jsApp Router mit next/image, Custom Loader wird seit Next.js 13 unterstützt
Node.jsLTS-Version ab 18, für das Signier-Skript und den Next.js-Build
UrsprungsspeicherHTTP(S)- oder S3-kompatible Quelle, als Präfix in IMGPROXY_ALLOWED_SOURCES eingetragen
SignierschlüsselIMGPROXY_KEY und IMGPROXY_SALT, je ein hexkodierter Zufallswert

Systemarchitektur

imgproxy ist ein quelloffener Server, der ein Bild erst beim tatsächlichen Abruf verkleinert, zuschneidet und in ein anderes Dateiformat umwandelt, statt vorher fertige Varianten auf Vorrat zu speichern. Für ein Portfolio aus vielen Next.js-Websites reicht dafür ein einziger Container, weil die Quelle des Bildes nicht im Container fest hinterlegt ist, sondern jedes Mal in der aufgerufenen URL steckt.

flowchart TD
    A[Besucher ruft eine Seite im Portfolio auf] --> B[Next-js-Server rendert die Seite mit next-image]
    B --> C[HTML enthaelt eine signierte Imgproxy-URL]
    C --> D[Browser fordert das Bild von dieser URL an]
    D --> E[Reverse Proxy leitet an den gemeinsamen Imgproxy-Container weiter]
    E --> F{Signatur gueltig}
    F -->|Ja| G[Imgproxy laedt das Original aus dem Ursprungsspeicher]
    F -->|Nein| Z[Antwort 403 Invalid signature]
    G --> H[Ursprungsspeicher liefert die Originaldatei]
    H --> I[Imgproxy skaliert konvertiert und komprimiert]
    I --> J[Antwort mit Cache-Control-Header]
    J --> K[Reverse Proxy und CDN-Cache speichern das Ergebnis]
    K --> L[Browser zeigt das optimierte Bild]

Der Ablauf beginnt beim Seitenaufruf. Der Next.js-Server rendert die Seite serverseitig und schreibt dabei keine fertige Bilddatei ins HTML, sondern eine signierte imgproxy-URL, die Zielbreite, Zielformat und eine Base64-kodierte Fassung der Original-Adresse enthält. Erst wenn der Browser diese URL wirklich anfragt, entscheidet ein Reverse Proxy vor dem imgproxy-Container, ob die Anfrage überhaupt bei imgproxy landet oder schon vorher aus einem Cache beantwortet wird. imgproxy prüft zuerst die Signatur, eine HMAC-Prüfsumme, die aus einem geheimen Schlüssel berechnet wird und Manipulationen an der URL erkennbar macht, und lehnt jede Anfrage ohne passende Signatur mit dem Status 403 ab, bevor überhaupt ein Bild geladen wird. Erst danach holt der Container das Originalbild vom Ursprungsspeicher, verarbeitet es und schickt das Ergebnis mit einem Cache-Control-Header zurück, der festlegt, wie lange ein Proxy oder Browser die Antwort wiederverwenden darf, ohne erneut bei imgproxy anzufragen.

Der Grund, warum ein gemeinsamer Container für ein Portfolio aus vielen Websites sinnvoll ist und für eine einzelne Website meist nicht: Die reine Rechenarbeit beim Verkleinern und Umkodieren eines Bildes braucht nur wenige hundert Millisekunden CPU-Zeit, aber jeder Container bringt einen festen Sockel an Betriebsaufwand mit sich, Updates einspielen, Speicher überwachen, Zugriff absichern. Bei einer einzelnen Website verteilt sich dieser Sockel auf eine Handvoll Bilder pro Tag, und ein fertiger Dienst mit Dashboard und Support ist dann oft die günstigere Lösung. Bei vierzig Websites im selben Portfolio verteilt sich derselbe Sockel auf vierzig Mal so viele Anfragen, während IMGPROXY_ALLOWED_SOURCES nur eine kommagetrennte Liste von URL-Präfixen braucht, um jede einzelne Website als zulässige Quelle einzutragen. Ein Anbieter eines Bild-CDNs verdient dagegen gerade an dem Teil, der sich am wenigsten nach Aufwand richtet: an der Anzahl der Transformationen, unabhängig davon, wie wenig Rechenzeit eine einzelne davon tatsächlich kostet.

Schritt-für-Schritt-Umsetzung

Docker Compose startet den gemeinsamen Imgproxy-Container

Der Container läuft als eigener Dienst neben den Next.js-Anwendungen und wird nur über einen internen Port erreichbar gemacht, ein Reverse Proxy übernimmt TLS und das öffentliche Routing.

services:
  imgproxy:
    image: ghcr.io/imgproxy/imgproxy:v4.0.14
    restart: unless-stopped
    ports:
      - "127.0.0.1:8080:8080"
    environment:
      IMGPROXY_KEY: ${IMGPROXY_KEY}
      IMGPROXY_SALT: ${IMGPROXY_SALT}
      IMGPROXY_ALLOWED_SOURCES: "s3://,https://*.example-cdn.de/"
      IMGPROXY_MAX_SRC_RESOLUTION: "30"
      IMGPROXY_MAX_SRC_FILE_SIZE: "26214400"
      IMGPROXY_TTL: "2592000"
      IMGPROXY_USE_ETAG: "true"
      IMGPROXY_AUTO_WEBP: "true"
      IMGPROXY_AUTO_AVIF: "true"
      IMGPROXY_TIMEOUT: "20"
    deploy:
      resources:
        limits:
          memory: 1g

IMGPROXY_KEY und IMGPROXY_SALT sind zwei unabhängige, hexkodierte Zufallswerte, zum Beispiel je mit openssl rand -hex 64 erzeugt. IMGPROXY_ALLOWED_SOURCES trägt für jede angeschlossene Website einen eigenen URL-Präfix ein, jeder Eintrag endet zwingend mit einem Schrägstrich, sonst lässt sich der Präfix umgehen. IMGPROXY_MAX_SRC_RESOLUTION und IMGPROXY_MAX_SRC_FILE_SIZE sind hier bewusst enger gesetzt als der Standardwert, weil ein gemeinsamer Container Redaktionsuploads aus mehreren Häusern gleichzeitig abfängt.

Ein Node-Skript erzeugt die signierte Bild-URL

Jede Website, die den Container nutzt, braucht eine Funktion, die aus der Original-Bildadresse und den gewünschten Maßen eine gültige, signierte URL baut. imgproxy verlangt dafür eine Base64-kodierte Fassung des Pfades und eine HMAC-SHA256-Signatur über Salt und Pfad, beides URL-sicher kodiert.

const crypto = require("crypto");

const IMGPROXY_BASE_URL = process.env.IMGPROXY_BASE_URL || "https://img.intern.example.de";
const key = Buffer.from(process.env.IMGPROXY_KEY, "hex");
const salt = Buffer.from(process.env.IMGPROXY_SALT, "hex");

function signedImgproxyUrl(sourceUrl, { width, height, format = "webp", quality = 80 }) {
  const encodedSource = Buffer.from(sourceUrl).toString("base64url");
  const options = [`rs:fill:${width}:${height}:0`, "g:sm", `q:${quality}`].join("/");
  const path = `/${options}/${encodedSource}.${format}`;

  const signature = crypto
    .createHmac("sha256", key)
    .update(Buffer.concat([salt, Buffer.from(path)]))
    .digest("base64url");

  return `${IMGPROXY_BASE_URL}/${signature}${path}`;
}

console.log(
  signedImgproxyUrl("https://cdn.intern.example.de/uploads/hero-2026.jpg", {
    width: 1200,
    height: 630,
  })
);

Die Reihenfolge ist verbindlich: erst Salt, dann der komplette Pfad aus Verarbeitungsoptionen und kodierter Quelle, erst danach die Signatur voranstellen. Wer nachträglich noch eine Option wie einen Blur-Wert ergänzt, ohne neu zu signieren, bekommt von imgproxy zuverlässig eine 403-Antwort, weil eine Signatur exakt zu einem Pfad gehört und nicht zu einer Bildquelle im Allgemeinen.

Der Next.js-Loader ersetzt den eingebauten Bildoptimierer

Next.js optimiert Bilder standardmäßig über eine eigene serverseitige Route. Ein Custom Loader schaltet diese Route ab und liefert stattdessen direkt eine URL, die auf den selbstgehosteten Container zeigt.

// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
  images: {
    loader: "custom",
    loaderFile: "./src/imgproxy-loader.js",
  },
};

module.exports = nextConfig;

Der eigentliche Haken dabei: Next.js ruft die Loader-Funktion nicht nur beim serverseitigen Rendern auf, sondern auch im Browser, sobald die Bildkomponente unterschiedliche Bildschirmbreiten berechnet. Node-eigene Module wie crypto aus dem vorigen Abschnitt gibt es im Browser-Bundle nicht, ein Loader, der sie direkt einbindet, bricht dort ab. Für den produktiven Einsatz signiert deshalb entweder eine browserfähige Bibliothek wie das offizielle @imgproxy/imgproxy-js-core-Paket, das auf der Web-Crypto-Schnittstelle statt auf Node-Crypto aufbaut, oder die Website liefert bereits fertig signierte URLs aus einer serverseitig aufgelösten Datenquelle aus und der Loader reicht sie nur noch durch:

// src/imgproxy-loader.js
export default function imgproxyLoader({ src, width, quality }) {
  // src enthaelt hier bereits die vollstaendige, serverseitig signierte
  // imgproxy-URL aus der Datenquelle (z. B. Baserow-Feld oder CMS-Antwort).
  // Der Loader haengt nur noch die vom Next-js-Bildkomponenten
  // berechnete Zielbreite und Qualitaet als Parameter an.
  const url = new URL(src);
  url.searchParams.set("w", String(width));
  url.searchParams.set("q", String(quality || 80));
  return url.toString();
}

Häufige Fehlerquellen

  • 403 Invalid signature. Das Signier-Skript und der Container lesen unterschiedliche Werte für IMGPROXY_KEY oder IMGPROXY_SALT, oder die Verarbeitungsoptionen wurden nach dem Signieren noch verändert. Fix: Key und Salt aus derselben Quelle beziehen, die auch der Container per Compose einliest, und den vollständigen Pfad erst nach dem letzten Änderungsschritt signieren.
  • 422 Unprocessable Entity beim ersten Test mit einem großen Originalfoto. Das Bild überschreitet IMGPROXY_MAX_SRC_RESOLUTION oder IMGPROXY_MAX_SRC_FILE_SIZE. Fix: Limit im Compose-File anheben, wenn die Redaktion tatsächlich sehr hochauflösende Fotos hochlädt, oder die Grenze bewusst niedrig lassen und die Uploadgröße stattdessen im CMS begrenzen.
  • Eine Bildquelle wird abgelehnt, obwohl die URL im Browser erreichbar ist. IMGPROXY_ALLOWED_SOURCES listet den Host ohne abschließenden Schrägstrich, etwa https://cdn.example.de statt https://cdn.example.de/. Ohne den Schrägstrich ließe sich der Präfix mit einem angehängten fremden Host umgehen, deshalb prüft imgproxy hier strikt. Fix: jeden Eintrag mit Schrägstrich abschließen.
  • Der Next.js-Loader wirft im Browser einen Fehler, weil ein Node-Modul fehlt. Der Custom Loader hat versucht, das Node-eigene crypto-Modul im Client-Bundle zu benutzen, next/image ruft den Loader aber auch im Browser auf, um Breakpoints zu berechnen. Fix: Signierung über eine browserfähige Bibliothek lösen oder Bilder serverseitig fertig signiert an die Seite übergeben.
  • Der gemeinsame Container wird bei einer Lastspitze auf einer einzigen Website für das ganze Portfolio langsam. Ein Container bedient standardmäßig alle angeschlossenen Websites gleichzeitig. Fix: IMGPROXY_REQUESTS_QUEUE_SIZE setzen, damit überzählige Anfragen mit dem Status 429 statt mit langen Wartezeiten beantwortet werden, und bei Bedarf mehrere Container-Replikas hinter demselben Reverse Proxy betreiben.

Häufig gestellte Fragen

Lohnt sich ein selbstgehosteter Imgproxy-Server auch für eine einzelne Website mit wenig Traffic?

Für eine einzelne Website mit wenig Traffic lohnt sich ein selbstgehosteter Imgproxy-Server in den meisten Fällen nicht. Ein fertiger Dienst übernimmt Updates, Überwachung und Ausfallsicherheit gegen eine feste oder nutzungsabhängige Gebühr, und bei einer Handvoll Bildern pro Tag liegt diese Gebühr meist unter dem Aufwand, einen eigenen Container laufend zu betreuen. Der Vorteil des eigenen Containers entsteht erst, sobald mehrere Websites sich denselben Betriebsaufwand teilen.

Kann ein einziger Imgproxy-Container mehrere unabhängige Websites gleichzeitig bedienen?

Ein einziger imgproxy-Container bedient mehrere unabhängige Websites gleichzeitig, weil die Bildquelle nicht im Container fest hinterlegt ist, sondern bei jeder Anfrage neu in der aufgerufenen URL steckt. IMGPROXY_ALLOWED_SOURCES nimmt dafür pro Website einen eigenen, kommagetrennten URL-Präfix auf, und derselbe Signierschlüssel gilt für alle angeschlossenen Websites, sofern sie ihn gemeinsam nutzen dürfen.

Was liefert imgproxy zurück, wenn das Ursprungsbild nicht erreichbar ist?

Ist das Ursprungsbild nicht erreichbar, antwortet imgproxy in der Regel mit dem HTTP-Status 404, sofern die Quelle gar nicht antwortet oder einen Fehler unterhalb von Status 500 liefert. Antwortet die Quelle selbst mit einem Serverfehler ab Status 500, reicht imgproxy diesen Status an den Client durch, statt ihn in einen eigenen Fehlercode zu übersetzen. Für produktive Deployments lohnt sich deshalb ein eigenes Monitoring auf Fehlerraten der imgproxy-Antworten, weil ein einzelner ausgefallener Ursprungsspeicher sonst über viele Websites hinweg gleichzeitig auffällt.

Quellen

  1. imgproxy-Dokumentation: Signing a URL, imgproxy, abgerufen am 2026-08-26
  2. imgproxy v4.0.14 Release Notes, imgproxy (GitHub), abgerufen am 2026-08-26
  3. imgproxy-Blog: Image Optimization for Next.js with imgproxy, imgproxy, abgerufen am 2026-08-26
  4. Next.js-Dokumentation: next.config.js images (Custom Loader), Vercel, abgerufen am 2026-08-26
  5. @imgproxy/imgproxy-js-core auf npm, imgproxy / npm, abgerufen am 2026-08-26

Der Content wurde mit Hilfe von KI erstellt, vor allem in der Recherche und Vorformulierung. Prüfung und Abnahme durch unsere Redaktion.