Neu veröffentlicht: E-Commerce mit Power Pages, Stripe & Analytics

· Architektur  · 10 minuten Lesezeit

MinIO statt Azurite: S3-kompatible Objektspeicherung lokal und auf Hetzner

Azurite bindet das Projekt an Azure Blob Storage. MinIO spricht dieselbe S3-API wie Hetzner Object Storage. Ein Env-Var-Tausch genügt für den Wechsel in die Produktion. Gleichzeitig: wie der Zitadel-Bootstrap-Container OIDC-Apps automatisch anlegt.

Azurite bindet das Projekt an Azure Blob Storage. MinIO spricht dieselbe S3-API wie Hetzner Object Storage. Ein Env-Var-Tausch genügt für den Wechsel in die Produktion. Gleichzeitig: wie der Zitadel-Bootstrap-Container OIDC-Apps automatisch anlegt.

Inhalt

Das Problem mit Azurite

Das System speicherte Screenshots bisher in Azurite, dem lokalen Emulator für Azure Blob Storage. Das hat anfangs gereicht, weil Azurite einfach zu starten ist und das Backend nur einen Azure-SDK-Client brauchte.

Das Problem kam, als ich anfing, über Deployment nachzudenken. Die Produktionsumgebung soll auf Hetzner laufen. Hetzner bietet Object Storage, kompatibel mit der AWS S3 API, aber nicht mit Azure Blob. Das bedeutet: lokal Azure SDK, auf Hetzner S3 SDK. Zwei Code-Pfade, zwei Konfigurationssysteme, zwei Fehlerquellen.

Das war nicht akzeptabel.

Warum MinIO die richtige Wahl ist

MinIO ist ein selbst gehosteter Objektspeicher, der vollständig mit der S3 API kompatibel ist. Hetzner Object Storage ist ebenfalls S3-kompatibel. Der Unterschied zwischen lokal und Produktion ist ausschließlich die Konfiguration:

ParameterLokal (MinIO)Hetzner Object Storage
S3_ENDPOINThttp://minio:9000https://fsn1.your-objectstorage.com
S3_ACCESS_KEYminioadminHetzner API Key
S3_SECRET_KEYminioadminHetzner Secret Key
S3_BUCKET_NAMEscreenshotsmy-production-bucket
S3_REGIONus-east-1eu-central

Kein Codeunterschied. Nur .env-Werte austauschen, neu starten, fertig.

Diagramm: MinIO lokal vs. Hetzner Object Storage in Produktion Abbildung: Lokal läuft MinIO auf Port 9090 im Docker-Compose-Stack. In Produktion zeigt S3_ENDPOINT auf Hetzner Object Storage. Das Backend spricht in beiden Fällen dieselbe S3 API. Kein Codeunterschied, nur Env-Vars ändern sich.

Warum AWS SDK statt MinIO SDK?

Die naheliegende Frage: Warum @aws-sdk/client-s3 und nicht das native MinIO SDK?

Das MinIO SDK ist für MinIO-spezifische Features optimiert. Das AWS SDK ist der de-facto-Standard für S3-kompatible Speicher und wird explizit in der Hetzner-Dokumentation als empfohlener Client für ihr Object Storage genannt. Jeder S3-kompatible Dienst, ob MinIO, Hetzner, Cloudflare R2 oder Backblaze B2, funktioniert mit demselben AWS SDK-Client.

Der einzige MinIO-spezifische Parameter ist forcePathStyle: true. Standardmäßig erwartet der AWS SDK virtuelle Host-basierte URLs: {bucket}.{endpoint}/{key}. MinIO und Hetzner verwenden stattdessen Pfad-basierte URLs: {endpoint}/{bucket}/{key}. Ein einziger Boolean löst das.

// src/services/blob-storage.ts
const s3 = new S3Client({
  endpoint: process.env.S3_ENDPOINT,
  region: process.env.S3_REGION ?? "us-east-1",
  credentials: {
    accessKeyId: process.env.S3_ACCESS_KEY!,
    secretAccessKey: process.env.S3_SECRET_KEY!,
  },
  // Required for MinIO and Hetzner: path-style URLs instead of virtual-hosted
  forcePathStyle: true,
});

Presigned URLs statt SAS-Tokens

Azure Blob Storage verwendet SAS-Tokens (Shared Access Signatures) für zeitlich begrenzte Direktzugriffs-URLs. S3 hat dasselbe Konzept, nennt es aber Presigned URLs.

Das Prinzip ist identisch: Das Backend generiert eine URL, die für eine bestimmte Zeit (hier: 1 Stunde) gültig ist und dem Browser direkten Zugriff auf das Objekt erlaubt, ohne Backend-Proxy.

import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { GetObjectCommand } from "@aws-sdk/client-s3";

async function getImageUrl(objectKey: string): Promise<string> {
  const command = new GetObjectCommand({
    Bucket: process.env.S3_BUCKET_NAME,
    Key: objectKey,
  });
  // URL is valid for 1 hour
  const url = await getSignedUrl(s3, command, { expiresIn: 3600 });

  // Rewrite internal Docker hostname to public host for browser access
  const publicUrl = process.env.S3_PUBLIC_URL;
  if (publicUrl) {
    const internalUrl = new URL(url);
    const externalUrl = new URL(publicUrl);
    internalUrl.hostname = externalUrl.hostname;
    internalUrl.port = externalUrl.port;
    internalUrl.protocol = externalUrl.protocol;
    return internalUrl.toString();
  }
  return url;
}

Der S3_PUBLIC_URL-Trick ist lokal nötig, weil MinIO intern als http://minio:9000 erreichbar ist, der Browser aber http://localhost:9090 erwartet. In Produktion entfällt das. Hetzner ist von außen direkt erreichbar.

Port-Konflikt: MinIO 9000 vs. Zitadel Login V2

MinIO läuft standardmäßig auf Port 9000. Zitadel Login V2 (Next.js) ebenfalls. Auf demselben Host ist nur einer von beiden möglich.

Die Lösung ist ein einfaches Port-Mapping im docker-compose.override.yml:

minio:
  ports:
    - "9090:9000"   # S3 API (host:container — avoids clash with Zitadel Login V2 on :9000)
    - "9091:9001"   # MinIO web console

MinIO läuft intern auf Port 9000, ist vom Host aber nur über 9090 erreichbar. Die MinIO Web-Konsole ist unter http://localhost:9091 verfügbar (Login: minioadmin / minioadmin).

Docker-Compose-Setup: MinIO und Bucket-Initialisierung

Der Stack besteht aus zwei Services:

minio:
  image: minio/minio:latest
  container_name: minio_local
  ports:
    - "9090:9000"
    - "9091:9001"
  volumes:
    - minio_data:/data
  environment:
    - MINIO_ROOT_USER=minioadmin
    - MINIO_ROOT_PASSWORD=minioadmin
  command: server /data --console-address ":9001"
  healthcheck:
    test: ["CMD", "mc", "ready", "local"]
    interval: 10s

# One-shot container: creates the "screenshots" bucket on first start
minio-init:
  image: minio/mc:latest
  depends_on:
    minio:
      condition: service_healthy
  entrypoint: >
    sh -c "
      mc alias set local http://minio:9000 minioadmin minioadmin &&
      mc mb --ignore-existing local/screenshots &&
      echo 'MinIO bucket ready'
    "
  restart: "no"

minio-init läuft einmal, erstellt den Bucket, und wird dann nicht mehr gestartet (restart: "no"). Das --ignore-existing-Flag macht es idempotent. Ein bereits vorhandener Bucket ist kein Fehler.

Zitadel Bootstrap: OIDC-Apps automatisch anlegen

Parallel zur MinIO-Migration habe ich das größte manuelle Setup-Problem gelöst: die Zitadel-OIDC-Apps mussten bisher von Hand im Zitadel-Console angelegt werden. Vergisst man das nach einem Volume-Reset, schlägt der Login fehl mit Errors.App.NotFound. Kein hilfreicher Fehler.

Was schiefgelaufen war

Die Client-IDs in frontend/.env und extension/.env stammten noch von einer vorherigen Zitadel-Instanz. Nach einem Volume-Reset wird eine neue Instanz erstellt. Die IDs ändern sich. Die alten IDs sind ungültig.

Die Diagnose per Datenbankabfrage:

-- Shows only: Management-API, Admin-API, Auth-API, Management Console
SELECT id, name FROM projections.apps7 WHERE is_deleted = false;

Keine frontend-App, keine extension-App. Der OAuth-Request schlägt daher sofort fehl.

Der Bootstrap-Mechanismus

In Zitadel v4 gibt es ZITADEL_FIRSTINSTANCE_PATPATH, eine Umgebungsvariable, die Zitadel anweist, den PAT des IAM-Owner-Maschinennutzers beim ersten Start in eine Datei zu schreiben:

zitadel:
  environment:
    # IAM_OWNER machine user — PAT written to shared volume on first init
    - ZITADEL_FIRSTINSTANCE_ORG_MACHINE_MACHINE_USERNAME=admin-service
    - ZITADEL_FIRSTINSTANCE_ORG_MACHINE_MACHINE_NAME=Admin Service
    - ZITADEL_FIRSTINSTANCE_ORG_MACHINE_PAT_EXPIRATIONDATE=2030-01-01T00:00:00Z
    - ZITADEL_FIRSTINSTANCE_PATPATH=/admin-pat/admin.pat
  volumes:
    - zitadel_admin_pat:/admin-pat

Ein zitadel-bootstrap-Container liest dann diesen PAT und legt die OIDC-Apps über die Management API an:

zitadel-bootstrap:
  image: python:3.12-alpine
  volumes:
    - zitadel_admin_pat:/admin-pat:ro
    - ./scripts:/scripts:ro
    - ./frontend:/frontend-env     # bootstrap patches frontend/.env in-place
    - ./extension:/extension-env   # bootstrap patches extension/.env in-place
  command: >
    sh -c "apk add --no-cache curl && sh /scripts/bootstrap-zitadel.sh"
  depends_on:
    - zitadel
  restart: "no"

Das Skript scripts/bootstrap-zitadel.sh wartet auf Zitadel, liest den PAT, erstellt das Projekt Local Insight sowie die beiden OIDC-Apps, und patcht die .env-Dateien direkt:

# Create frontend OIDC app (SPA / PKCE)
RESP=$(curl -sf -X POST \
  -H "Authorization: Bearer $PAT" \
  -H "Content-Type: application/json" \
  "$ZITADEL_URL/management/v1/projects/$PROJECT_ID/apps/oidc" \
  -d '{
    "name": "frontend",
    "redirectUris": ["http://localhost:5173/callback"],
    "appType": "OIDC_APP_TYPE_WEB",
    "authMethodType": "OIDC_AUTH_METHOD_TYPE_NONE",
    "grantTypes": ["OIDC_GRANT_TYPE_AUTHORIZATION_CODE"],
    "devMode": true
  }')
FRONTEND_CLIENT_ID=$(echo "$RESP" | python3 -c "import sys,json; print(json.load(sys.stdin)['clientId'])")

# Patch .env file directly on the host (mounted directory)
sed -i "s/^VITE_ZITADEL_CLIENT_ID=.*/VITE_ZITADEL_CLIENT_ID=$FRONTEND_CLIENT_ID/" "$FRONTEND_ENV"

devMode: true erlaubt unsichere Redirect-URIs (z. B. http://localhost). Es deaktiviert nicht die Prüfung, ob die gesendete Redirect-URI in der registrierten Liste steht. Genau hier steckte der nächste Fehler.

Nach dem Bootstrap muss der Frontend-Container neu gebaut werden, damit Vite die neuen Client-IDs einbettet:

docker compose build --no-cache frontend && docker compose up -d frontend
cd extension && npm run build

Stabiles Extension-ID-Problem

Die Chrome Extension hat einen eigenen OAuth-Flow über chrome.identity.launchWebAuthFlow. Das Problem: chrome.identity.getRedirectURL() gibt nicht https://chromiumapp.org/ zurück, sondern https://<extension-id>.chromiumapp.org/, mit der tatsächlichen Extension-ID als Subdomain. Zitadel vergleicht diese URI exakt mit der registrierten Liste. devMode: true hilft hier nicht.

Bei einer unpacked Extension wird die ID aus dem Verzeichnispfad gehasht und ändert sich, sobald die Extension von einem anderen Pfad geladen wird. Das macht eine statische Registrierung unmöglich, es sei denn, man stabilisiert die ID.

Die Lösung: ein RSA-Public-Key im Manifest. Chrome leitet die Extension-ID aus dem ersten SHA-256-Hash der DER-kodierten Public-Key ab. Gleicher Key, gleiche ID, unabhängig von Pfad oder Maschine.

// extension/manifest.config.ts
export default defineManifest({
  // ...
  // Stable extension ID for PKCE OAuth (ID: gjmeppkmccnmkidjndcpfcealflcdmdg)
  // Redirect URI: https://gjmeppkmccnmkidjndcpfcealflcdmdg.chromiumapp.org/
  key: "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAr+U5882h...",
});

Mit diesem Key lädt die Extension immer mit derselben ID. Die Redirect-URI ist in Zitadel fest registrierbar:

# bootstrap-zitadel.sh: Extension app with stable redirect URI
"redirectUris": ["https://gjmeppkmccnmkidjndcpfcealflcdmdg.chromiumapp.org/"]

Den Key generiert man einmalig:

openssl genrsa 2048 | openssl rsa -pubout -outform DER | base64 -w 0

Aus dem Public Key lässt sich die Extension-ID deterministisch berechnen: SHA-256 des DER-kodierten Keys, erste 128 Bits, jedes Nibble wird zu einem Buchstaben von a bis p. Das Python-Script dafür:

import hashlib, base64

pub_key_der = base64.b64decode(open("pub.b64").read())
digest = hashlib.sha256(pub_key_der).digest()
ext_id = "".join(chr(ord("a") + (b >> 4)) + chr(ord("a") + (b & 0xF)) for b in digest[:16])
print(ext_id)  # gjmeppkmccnmkidjndcpfcealflcdmdg

Warum Vite Client-IDs zur Build-Zeit einbettet

Vite liest VITE_*-Variablen beim Build, nicht zur Laufzeit. Das ist eine bewusste Design-Entscheidung: kein Server-Side-Rendering, keine Runtime-Config-Endpunkte für statische SPAs. Der Nachteil: nach einem Zitadel-Reset muss der Container neu gebaut werden. Der Vorteil: keine separate Config-API, keine Laufzeit-Fehler durch fehlende Umgebungsvariablen.

Zwei versteckte Bugs nach der Migration

Beim ersten vollständigen Durchlauf tauchten zwei Fehler auf, die erst nach der Grundmigration sichtbar wurden.

Bug 1: MinIO gibt 403 bei Presigned URLs zurück

Das Backend generiert Presigned URLs für den Browser-Direktzugriff auf gespeicherte Screenshots. Die URL sah syntaktisch korrekt aus, aber MinIO antwortete mit 403 Forbidden.

Die Ursache ist subtil: Das AWS SDK signiert die URL mit dem Endpoint, den der Client kennt. Im Docker-Netzwerk ist MinIO als http://minio:9000 erreichbar. Das ist derinterne Hostname. Der erzeugte Presigned URL sieht dann so aus:

http://minio:9000/screenshots/posts/abc.png?X-Amz-Signature=...&X-Amz-SignedHeaders=host

Der Versuch, den Hostnamen nachträglich auf localhost:9090 umzuschreiben, liegt nahe, löst das Problem aber nicht. Die AWS-Signatur deckt die host-Header ab (erkennbar an X-Amz-SignedHeaders=host). Sobald der Host nach der Signierung geändert wird, schlägt die HMAC-Verifikation fehl.

Die korrekte Lösung: Einen separaten S3-Client für die Presigned-URL-Generierung anlegen, der von Anfang an den öffentlichen Endpoint (S3_PUBLIC_URL) als Basis nutzt:

// backend/src/services/blob-storage.ts

/** Client for internal operations (upload, bucket management) */
function getClient(): S3Client {
  return makeClient(S3_ENDPOINT); // http://minio:9000 — Docker-internal hostname
}

/**
 * Client for presigned URLs.
 * Signs directly with S3_PUBLIC_URL so the generated URL already contains
 * the publicly reachable hostname. Post-signing rewriting would
 * break the HMAC signature and cause 403 errors.
 */
function getPresignClient(): S3Client {
  return makeClient(S3_PUBLIC_URL ?? S3_ENDPOINT);
}

In Produktion ist S3_PUBLIC_URL nicht gesetzt. Hetzner Object Storage ist von außen direkt erreichbar, der Endpoint ist bereits der öffentliche.

Bug 2: E-Mail-Adresse fehlt im Extension-Popup

Nach dem Login zeigte das Popup-Fenster der Extension ein grünes Lämpchen (eingeloggt), aber keine E-Mail-Adresse.

Der Grund: Der Code las email aus dem Access Token JWT. Zitadel legt die E-Mail-Adresse des Nutzers in den ID Token, einem OIDC-Standard, der bewusst zwischen Autorisierung (Access Token) und Identität (ID Token) trennt.

// Before: reads email from wrong token (access token)
const payload = parseJwtPayload(data.access_token);
email: (payload.email as string) ?? "",

// After: reads email from ID token
const idPayload = data.id_token ? parseJwtPayload(data.id_token) : {};
email: (idPayload.email as string) ?? (idPayload.preferred_username as string) ?? "",

Bei einem Token-Refresh gibt Zitadel keinen neuen ID Token zurück. Die E-Mail wird daher aus dem zuvor gespeicherten Token übernommen:

const existingTokens = await getStoredTokens();
email: (idPayload.email as string) ?? existingTokens?.email ?? "",

Was sich konkret geändert hat

VorherNachher
Azurite (Azure-spezifisch)MinIO (S3-kompatibel)
@azure/storage-blob SDK@aws-sdk/client-s3 SDK
SAS-TokensPresigned URLs
Manuelle App-Anlage in Zitadelzitadel-bootstrap Container
Stale Client-IDs nach ResetAutomatisch aktualisierte .env-Dateien
Wechselnde Extension-IDStabiler Key im Manifest, feste Redirect-URI
403 bei MinIO-Presigned-URLsSeparater Presign-Client mit öffentlichem Endpoint
Leere E-Mail im PopupE-Mail aus ID Token statt Access Token

Der Wechsel zu Hetzner Object Storage in der Produktion erfordert jetzt nur noch:

S3_ENDPOINT=https://fsn1.your-objectstorage.com
S3_ACCESS_KEY=<hetzner-key>
S3_SECRET_KEY=<hetzner-secret>
S3_REGION=eu-central
S3_BUCKET_NAME=my-production-bucket
# S3_PUBLIC_URL not needed in production

Kein Code, kein Build, keine neue Abhängigkeit. Nur .env.


Alle Artikel der Serie

  1. Vision und Systemübersicht: Chrome Extension, RAG-Architektur, Projekthintergrund: Artikel lesen
  2. RAG-System Aufbau: Qdrant, Embeddings, Cosine-Ähnlichkeit in TypeScript: Artikel lesen
  3. AI Provider Abstraktion: Ollama vs. OpenAI, Interface-Design, kein Vendor-Lock-in: Artikel lesen
  4. Chrome Extension MV3: Drei isolierte Laufzeitkontexte, Message Passing, Strategy Pattern: Artikel lesen
  5. Docker Compose Strategie: Override-Pattern, von lokal zu Azure: Artikel lesen
  6. Ollama lokal vs. Docker: Die Entscheidung und ihre Konsequenzen: Artikel lesen
  7. Ollama Auto-Pull Entrypoint: Automatisiertes Modell-Setup beim Container-Start: Artikel lesen
  8. tsconfig und Vite: Node16 vs. bundler, warum Vite eigene Regeln hat: Artikel lesen
  9. Instagram Caption mit MutationObserver vollständig laden: Artikel lesen
  10. Chrome Extension Foundation mit Health-Dot und Retry-Queue: Artikel lesen
  11. Phase 2 Features: Shadow DOM Overlay, Tailwind v4, Duplicate Detection: Artikel lesen
  12. Race Condition bei der Plattformerkennung: Wie ein UI-Event die Instagram-Erkennung bricht: Artikel lesen
  13. PostId-Extraktion in zwei Instagram-Layouts: querySelector vs. Ancestor-Traversal: Artikel lesen
  14. Instagram Karussell vollständig erfassen mit MutationObserver: Artikel lesen
  15. Notiz und Tags beim Screenshot-Speichern: Artikel lesen
  16. Instagram Tastatur-Shortcuts blockieren Chrome Extension Eingaben: Artikel lesen
  17. Lowercase-Normalisierung und Duplikat-Erkennung im Tag-Input: Artikel lesen
  18. Zitadel Login V2 in Docker Compose: drei versteckte Fehler: Artikel lesen
  19. PKCE OAuth in einer Chrome MV3 Extension: Artikel lesen
  20. React Frontend mit react-oidc-context und Zitadel: Artikel lesen
  21. Vite Build-Time-Umgebungsvariablen in Docker: Artikel lesen
  22. Event-Driven Ingestion mit BullMQ und Redis: Artikel lesen
  23. MinIO statt Azurite: S3-kompatible Objektspeicherung lokal und auf Hetzner (dieser Artikel)

Du migrierst von Azure Blob Storage auf eine S3-kompatible Lösung und hast Fragen zur SDK-Wahl oder zum Presigned-URL-Mechanismus? Lass uns das gemeinsam anschauen.

Zurück zum Blog

Ähnliche Beiträge

Alle Beiträge ansehen
Event-Driven Ingestion mit BullMQ und Redis

Event-Driven Ingestion mit BullMQ und Redis

POST /ingest blockierte die Extension, bis Embedding und Qdrant-Upsert fertig waren. Mit BullMQ und Redis wird der Ingest asynchron: 202 sofort, Verarbeitung im Hintergrund, Statusabfrage über GET /captures/:id/status.