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

Inhalt
- Das Problem mit Azurite
- Warum MinIO die richtige Wahl ist
- Warum AWS SDK statt MinIO SDK?
- Presigned URLs statt SAS-Tokens
- Port-Konflikt: MinIO 9000 vs. Zitadel Login V2
- Docker-Compose-Setup: MinIO und Bucket-Initialisierung
- Zitadel Bootstrap: OIDC-Apps automatisch anlegen
- Was schiefgelaufen war
- Der Bootstrap-Mechanismus
- Stabiles Extension-ID-Problem
- Warum Vite Client-IDs zur Build-Zeit einbettet
- Zwei versteckte Bugs nach der Migration
- Bug 1: MinIO gibt 403 bei Presigned URLs zurück
- Bug 2: E-Mail-Adresse fehlt im Extension-Popup
- Was sich konkret geändert hat
- Alle Artikel der Serie
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:
| Parameter | Lokal (MinIO) | Hetzner Object Storage |
|---|---|---|
S3_ENDPOINT | http://minio:9000 | https://fsn1.your-objectstorage.com |
S3_ACCESS_KEY | minioadmin | Hetzner API Key |
S3_SECRET_KEY | minioadmin | Hetzner Secret Key |
S3_BUCKET_NAME | screenshots | my-production-bucket |
S3_REGION | us-east-1 | eu-central |
Kein Codeunterschied. Nur .env-Werte austauschen, neu starten, fertig.
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 consoleMinIO 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-patEin 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 buildStabiles 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 0Aus 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) # gjmeppkmccnmkidjndcpfcealflcdmdgWarum 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=hostDer 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
| Vorher | Nachher |
|---|---|
| Azurite (Azure-spezifisch) | MinIO (S3-kompatibel) |
@azure/storage-blob SDK | @aws-sdk/client-s3 SDK |
| SAS-Tokens | Presigned URLs |
| Manuelle App-Anlage in Zitadel | zitadel-bootstrap Container |
| Stale Client-IDs nach Reset | Automatisch aktualisierte .env-Dateien |
| Wechselnde Extension-ID | Stabiler Key im Manifest, feste Redirect-URI |
| 403 bei MinIO-Presigned-URLs | Separater Presign-Client mit öffentlichem Endpoint |
| Leere E-Mail im Popup | E-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 productionKein Code, kein Build, keine neue Abhängigkeit. Nur .env.
Alle Artikel der Serie
- Vision und Systemübersicht: Chrome Extension, RAG-Architektur, Projekthintergrund: Artikel lesen
- RAG-System Aufbau: Qdrant, Embeddings, Cosine-Ähnlichkeit in TypeScript: Artikel lesen
- AI Provider Abstraktion: Ollama vs. OpenAI, Interface-Design, kein Vendor-Lock-in: Artikel lesen
- Chrome Extension MV3: Drei isolierte Laufzeitkontexte, Message Passing, Strategy Pattern: Artikel lesen
- Docker Compose Strategie: Override-Pattern, von lokal zu Azure: Artikel lesen
- Ollama lokal vs. Docker: Die Entscheidung und ihre Konsequenzen: Artikel lesen
- Ollama Auto-Pull Entrypoint: Automatisiertes Modell-Setup beim Container-Start: Artikel lesen
- tsconfig und Vite:
Node16vs.bundler, warum Vite eigene Regeln hat: Artikel lesen - Instagram Caption mit MutationObserver vollständig laden: Artikel lesen
- Chrome Extension Foundation mit Health-Dot und Retry-Queue: Artikel lesen
- Phase 2 Features: Shadow DOM Overlay, Tailwind v4, Duplicate Detection: Artikel lesen
- Race Condition bei der Plattformerkennung: Wie ein UI-Event die Instagram-Erkennung bricht: Artikel lesen
- PostId-Extraktion in zwei Instagram-Layouts: querySelector vs. Ancestor-Traversal: Artikel lesen
- Instagram Karussell vollständig erfassen mit MutationObserver: Artikel lesen
- Notiz und Tags beim Screenshot-Speichern: Artikel lesen
- Instagram Tastatur-Shortcuts blockieren Chrome Extension Eingaben: Artikel lesen
- Lowercase-Normalisierung und Duplikat-Erkennung im Tag-Input: Artikel lesen
- Zitadel Login V2 in Docker Compose: drei versteckte Fehler: Artikel lesen
- PKCE OAuth in einer Chrome MV3 Extension: Artikel lesen
- React Frontend mit react-oidc-context und Zitadel: Artikel lesen
- Vite Build-Time-Umgebungsvariablen in Docker: Artikel lesen
- Event-Driven Ingestion mit BullMQ und Redis: Artikel lesen
- 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.



