SuperSeller3000/docs/superpowers/specs/2026-05-19-infrastructure.md
Simon Kuehn 371213dbbb docs: update design doc + add infrastructure runbook
- Remove Ollama as AI backend (never used); Mistral is primary throughout
- Add infrastructure.md: full Docker setup, queue architecture, prod
  deployment steps, env vars — prod has no staging ERP container

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 05:59:21 +00:00

16 KiB
Raw Permalink Blame History

SuperSeller3000 — Infrastruktur & Deployment

Zuletzt aktualisiert: 2026-05-19
Zielumgebung: VPS (Ubuntu/Debian), Docker Compose
Domain: ss3k.schaunwama.de

Dieses Dokument beschreibt den vollständigen Stand der Docker-Infrastruktur und alle Schritte, um das System auf einem neuen Server hochzuziehen.


1. Übersicht der Services

Service Image / Build Zweck
app Build docker/app/Dockerfile PHP 8.4-FPM — Symfony-Applikation
caddy caddy:2-alpine Reverse Proxy, Auto-HTTPS (Let's Encrypt)
postgres postgres:17-alpine Datenbank (Schemas: app, logs, logs_archive)
redis redis:7-alpine Queue-Backend + Session-Cache
worker-ai Build docker/app/Dockerfile Messenger Worker — Transport ai_pipeline
worker-orders Build docker/app/Dockerfile Messenger Worker — Transport orders
worker-channel Build docker/app/Dockerfile Messenger Worker — Transport channel_sync
cron Build docker/app/Dockerfile Log-Rotation täglich (app:logs:rotate)

Nicht im Prod-Setup: kein Staging-ERP-Container. Frappe ERP läuft extern (eigene Instanz, FRAPPE_ERP_BASE_URL in .env.local).


2. Queue-Architektur (Symfony Messenger + Redis Streams)

Drei isolierte Redis-Streams — ein ausgefallener Worker blockiert die anderen nie.

Transports & Retry-Strategie

Transport Redis Stream Worker max_retries Delay / Backoff
ai_pipeline ai_pipeline worker-ai 3 2 s · ×2
orders orders worker-orders 5 1 s · ×2
channel_sync channel_sync worker-channel 5 2 s · ×2 · max 60 s
failed failed persistent, nie verloren

Nach Erschöpfung aller Retries landet die Message im failed-Transport. Replay: messenger:failed:retry.

Messages pro Transport

ai_pipelineworker-ai

Die Pipeline-Schritte werden sequenziell als separate Messages dispatcht — jeder Handler dispatcht den Nachfolger.

Message Handler-Logik
PhotoUploadMessage Startet Pipeline A: VisionAgent (Mistral Pixtral) liest Typenschild → Hersteller, Modell, SN
SpecsResearchMessage SpecsResearchAgent: Tavily-Suche mit Modellbezeichnung → Specs-Freitext; Pflichtfelder aus ArticleType.AttributeDefinitions ({{fields}}-Platzhalter im Prompt)
JsonCodingMessage JsonCodingAgent: Specs-Text → strukturiertes JSON gegen ArticleType-Schema
ValidationMessage Pflichtfelder vollständig? → weiter; sonst Retry ab JsonCodingMessage (max. 3×, missing_fields im Prompt) → needs_review
DraftArticleMessage Article anlegen (status: draft), Inventurnummer vergeben, Attribute + Foto persistieren
EbayTextMessage EbayTextAgent: eBay-Titel + Beschreibung aus Attributen generieren, an Article speichern
PxeInventoryMessage Startet Pipeline B (PXE): PXE-Dump direkt an JsonCodingAgent — SpecsResearch entfällt

Model-Cache: Nach PhotoUploadMessage prüft findCompletedByModelNumber() die DB. Treffer → alle Daten kopieren, SpecsResearch/JsonCoding/EbayText überspringen.
Fehlerfall: PipelineJobFailureListener fängt WorkerMessageFailedEvent ab und setzt AIPipelineJob.status = failed mit dem echten Fehlertext.

ordersworker-orders

Message Handler-Logik
OrderReceivedMessage Vollständiger 13-Schritt-Order-Flow: Idempotenz-Check → atomarer Inventory-Lock (stock - 1) → CustomerResolver (Matching-Kaskade) → Order anlegen → Frappe Sales Invoice → PDF abrufen → StorageManager → Invoice-Record → PDF per SMTP an Lieferant → UpdateStockOnChannelsMessage oder DeactivateListingMessage dispatchen → Order: completed

channel_syncworker-channel

Message Handler-Logik
PublishToChannelMessage ChannelAdapterRegistry → richtiger Adapter (z.B. EbayAdapter) → publishListing()ebay_listing_id an Article zurückschreiben
UpdateStockOnChannelsMessage updateStock() auf allen aktiven Plattformen des Artikels
DeactivateListingMessage deactivateListing() — wird ausgelöst wenn stock = 0 nach Verkauf
TrackingPushMessage pushTracking() → Plattform als versandt markieren → tracking_pushed_to_ebay_at setzen

AI-Backend-Ausfall

AI-Worker-Messages verbleiben in der Queue. Messenger wiederholt mit Backoff, nach 3 Versuchen → failed-Transport. Operativer Betrieb (Verkäufe, Rechnungen, Bestand) läuft völlig unabhängig durch. Replay via messenger:failed:retry wenn das AI-Backend wieder erreichbar ist.


3. Netzwerk

Alle Services kommunizieren über ein internes Docker-Bridge-Network. Nur Caddy exponiert Ports nach außen.

networks:
  default:
    driver: bridge
    ipam:
      config:
        - subnet: 172.18.0.0/24
          gateway: 172.18.0.1

Das Gateway 172.18.0.1 ist die Host-Adresse innerhalb des Docker-Netzes (erreichbar von allen Containern).


4. docker-compose.yml (Prod)

services:
  app:
    build:
      context: .
      dockerfile: docker/app/Dockerfile
    user: "1000:1000"
    environment:
      HOME: /tmp
    volumes:
      - .:/var/www
      - ./docker/app/php.ini:/usr/local/etc/php/conf.d/app.ini:ro
      - ./docker/app/zz-fpm-pool.conf:/usr/local/etc/php-fpm.d/zzz-pool.conf:ro
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy
    env_file:
      - path: .env
        required: true
      - path: .env.local
        required: false

  caddy:
    image: caddy:2-alpine
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./docker/caddy/Caddyfile:/etc/caddy/Caddyfile
      - .:/var/www
      - ./docker/app/php.ini:/usr/local/etc/php/conf.d/app.ini:ro
      - caddy_data:/data
    depends_on:
      - app

  postgres:
    image: postgres:17-alpine
    env_file: .env
    volumes:
      - postgres_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
      interval: 5s
      timeout: 5s
      retries: 5

  redis:
    image: redis:7-alpine
    command: redis-server --requirepass ${REDIS_PASSWORD}
    volumes:
      - redis_data:/data
    healthcheck:
      test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"]
      interval: 5s
      timeout: 5s
      retries: 5

  worker-ai:
    build:
      context: .
      dockerfile: docker/app/Dockerfile
    user: "1000:1000"
    environment:
      HOME: /tmp
    command: php bin/console messenger:consume ai_pipeline --time-limit=3600 --memory-limit=256M
    volumes:
      - .:/var/www
      - ./docker/app/php.ini:/usr/local/etc/php/conf.d/app.ini:ro
    depends_on:
      - postgres
      - redis
    env_file:
      - path: .env
        required: true
      - path: .env.local
        required: false
    restart: unless-stopped

  worker-orders:
    build:
      context: .
      dockerfile: docker/app/Dockerfile
    user: "1000:1000"
    environment:
      HOME: /tmp
    command: php bin/console messenger:consume orders --time-limit=3600 --memory-limit=256M
    volumes:
      - .:/var/www
      - ./docker/app/php.ini:/usr/local/etc/php/conf.d/app.ini:ro
    depends_on:
      - postgres
      - redis
    env_file:
      - path: .env
        required: true
      - path: .env.local
        required: false
    restart: unless-stopped

  worker-channel:
    build:
      context: .
      dockerfile: docker/app/Dockerfile
    user: "1000:1000"
    environment:
      HOME: /tmp
    command: php bin/console messenger:consume channel_sync --time-limit=3600 --memory-limit=256M
    volumes:
      - .:/var/www
      - ./docker/app/php.ini:/usr/local/etc/php/conf.d/app.ini:ro
    depends_on:
      - postgres
      - redis
    env_file:
      - path: .env
        required: true
      - path: .env.local
        required: false
    restart: unless-stopped

  cron:
    build:
      context: .
      dockerfile: docker/app/Dockerfile
    user: "1000:1000"
    environment:
      HOME: /tmp
    command: >
      sh -c "while true; do
        php bin/console app:logs:rotate;
        sleep 86400;
      done"      
    volumes:
      - .:/var/www
      - ./docker/app/php.ini:/usr/local/etc/php/conf.d/app.ini:ro
    depends_on:
      - postgres
    env_file:
      - path: .env
        required: true
      - path: .env.local
        required: false
    restart: unless-stopped

volumes:
  postgres_data:
  redis_data:
  caddy_data:

networks:
  default:
    driver: bridge
    ipam:
      config:
        - subnet: 172.18.0.0/24
          gateway: 172.18.0.1

Hinweis: docker-compose.override.yml existiert nur für lokale Entwicklung (Port-Bindings für Postgres/Redis zum direkten Zugriff vom Host). Auf dem Prod-Server nicht verwenden.


4. Dockerfile — docker/app/Dockerfile

FROM php:8.4-fpm-alpine

RUN apk add --no-cache \
        postgresql-dev \
        icu-dev \
        libzip-dev \
        unzip \
        git \
        $PHPIZE_DEPS \
    && docker-php-ext-install \
        pdo_pgsql \
        intl \
        zip \
        opcache \
    && pecl install redis \
    && docker-php-ext-enable redis \
    && apk del $PHPIZE_DEPS

COPY --from=composer:2 /usr/bin/composer /usr/bin/composer

WORKDIR /var/www

COPY docker/app/php.ini /usr/local/etc/php/conf.d/app.ini

5. PHP-Konfiguration

docker/app/php.ini

opcache.enable=0
memory_limit=256M
upload_max_filesize=20M
post_max_size=20M

opcache.enable=0 weil der Dev-Build keinen Preload hat — in Prod kann auf 1 + opcache.preload umgestellt werden, wenn gewünscht.

docker/app/zz-fpm-pool.conf

[www]
; SSE connections hold a worker for up to 90 s each — raise the pool ceiling
; so regular requests are not starved.
pm = dynamic
pm.max_children = 30
pm.start_servers = 4
pm.min_spare_servers = 2
pm.max_spare_servers = 8

6. Caddyfile (Prod) — docker/caddy/Caddyfile

{
    admin off
}

ss3k.schaunwama.de {
    root * /var/www/public
    php_fastcgi app:9000
    file_server
    encode gzip

    header {
        Strict-Transport-Security "max-age=31536000; includeSubDomains"
        X-Content-Type-Options "nosniff"
        X-Frame-Options "DENY"
    }
}

Prod enthält keinen erpstaging-Block. Frappe ERP läuft auf einer eigenen Instanz (extern, FRAPPE_ERP_BASE_URL in .env.local).

Aktueller Dev-Stand hat noch den erpstaging.schaunwama.de-Block für die Staging-ERP-Weiterleitung — der muss vor dem ersten Prod-Deploy entfernt werden.


7. Umgebungsvariablen

.env (ohne Credentials, in Git)

APP_ENV=prod
APP_SECRET=change_me_in_env_local
POSTGRES_DB=superseller
POSTGRES_USER=superseller
POSTGRES_PASSWORD=change_me
DATABASE_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}?serverVersion=17&charset=utf8"
REDIS_PASSWORD=change_me
REDIS_URL=redis://:${REDIS_PASSWORD}@redis:6379
MESSENGER_TRANSPORT_DSN=redis://:${REDIS_PASSWORD}@redis:6379/messages
MAILER_DSN=smtp://localhost
TAVILY_API_KEY=
MISTRAL_BASE_URL=https://api.mistral.ai
MISTRAL_API_KEY=
MISTRAL_VISION_MODEL=pixtral-12b-2409
MISTRAL_TEXT_MODEL=mistral-large-latest
AI_TEXT_MODEL=${MISTRAL_TEXT_MODEL}
AI_VISION_MODEL=${MISTRAL_VISION_MODEL}
EBAY_CLIENT_ID=
EBAY_CLIENT_SECRET=
EBAY_MARKETPLACE_ID=EBAY_DE
EBAY_API_BASE_URL=https://api.ebay.com
EBAY_OAUTH_BASE_URL=https://api.ebay.com
EBAY_VERIFICATION_TOKEN=
EBAY_ENDPOINT_URL=https://ss3k.schaunwama.de/webhooks/ebay
FRAPPE_ERP_BASE_URL=https://erp.example.com
FRAPPE_ERP_API_KEY=changeme
FRAPPE_ERP_API_SECRET=changeme
FRAPPE_GENERIC_ITEM_CODE=REFURB-HW
SUPPLIER_EMAIL=lieferant@example.com
SENDER_EMAIL=noreply@superseller3000.de

.env.local (nur auf Server, nie in Git)

Alle echten Credentials überschreiben hier die Platzhalter aus .env:

APP_SECRET=<random-32-char-hex>
POSTGRES_PASSWORD=<sicheres-passwort>
REDIS_PASSWORD=<sicheres-passwort>
MAILER_DSN=smtp://user:pass@mailserver:587
TAVILY_API_KEY=tvly-...
MISTRAL_API_KEY=sk-...
EBAY_CLIENT_ID=...
EBAY_CLIENT_SECRET=...
EBAY_VERIFICATION_TOKEN=...    # aus eBay Developer Portal
FRAPPE_ERP_BASE_URL=https://erp.meinefirma.de
FRAPPE_ERP_API_KEY=...
FRAPPE_ERP_API_SECRET=...
SUPPLIER_EMAIL=lieferant@meinefirma.de
SENDER_EMAIL=noreply@meinefirma.de

8. VPS-Voraussetzungen

# Docker + Compose Plugin
apt install docker.io docker-compose-plugin

# User 1000 muss existieren (PHP läuft als 1000:1000)
id -u superseller   # sollte 1000 sein; ggf. anpassen

# UFW: nur Caddy-Ports + SSH
ufw allow 22/tcp
ufw allow 80/tcp
ufw allow 443/tcp
ufw enable

9. Erste Inbetriebnahme (Schritt für Schritt)

# 1. Repo klonen
git clone <gitea-url>/superseller3000 /home/superseller/SuperSeller3000
cd /home/superseller/SuperSeller3000

# 2. .env.local mit echten Credentials anlegen (siehe Abschnitt 7)
nano .env.local

# 3. Caddyfile prüfen — kein erpstaging-Block!
#    (docker/caddy/Caddyfile muss nur den ss3k.schaunwama.de-Block enthalten)

# 4. Images bauen + DB hochfahren
docker compose up -d postgres redis
# warten bis postgres healthy

# 5. Composer install
docker compose run --rm app composer install --no-dev --optimize-autoloader

# 6. Datenbankmigrationen ausführen
docker compose run --rm app php bin/console doctrine:migrations:migrate --no-interaction

# 7. Cache aufwärmen
docker compose run --rm app php bin/console cache:warmup --env=prod

# 8. Ersten Admin-User anlegen
docker compose run --rm app php bin/console app:users:create --env=prod

# 9. Ersten API-Key anlegen (Klartext wird nur einmal angezeigt)
docker compose run --rm app php bin/console app:api-keys:create --env=prod

# 10. Alle Services starten
docker compose up -d

# 11. eBay-Webhook registrieren (URL in .env.local: EBAY_ENDPOINT_URL)
#     Im eBay Developer Portal: Notification URL = https://ss3k.schaunwama.de/webhooks/ebay
#     Verification Token = EBAY_VERIFICATION_TOKEN

10. Laufender Betrieb

Worker-Status prüfen

docker compose ps
docker compose logs -f worker-ai
docker compose logs -f worker-orders
docker compose logs -f worker-channel

Failed Messages (nach Erschöpfung aller Retries)

# Übersicht
docker compose exec app php bin/console messenger:failed:show

# Einzelne Message erneut versuchen
docker compose exec app php bin/console messenger:failed:retry

# Alle nochmal
docker compose exec app php bin/console messenger:failed:retry --all

Updates deployen

git pull
docker compose build app worker-ai worker-orders worker-channel cron
docker compose run --rm app composer install --no-dev --optimize-autoloader
docker compose run --rm app php bin/console doctrine:migrations:migrate --no-interaction
docker compose run --rm app php bin/console cache:warmup --env=prod
docker compose up -d

Logs (Admin-Panel)

https://ss3k.schaunwama.de/adminLog Entries — Fulltext-Suche, Filter nach Level/Channel/Zeitraum.
Rotation läuft täglich automatisch (Cron-Service): Einträge > 90 Tage → logs_archive, danach gelöscht.

Backup

# PostgreSQL-Dump
docker compose exec postgres pg_dump -U superseller superseller | gzip > backup_$(date +%Y%m%d).sql.gz

# Artikel-Fotos und Rechnungs-PDFs (Docker Volumes)
docker run --rm -v superseller3000_postgres_data:/data -v $(pwd):/backup \
    alpine tar czf /backup/volume_postgres_$(date +%Y%m%d).tar.gz /data

11. AI-Konfiguration

Primäres Backend: Mistral Cloud API

Standardkonfiguration — config/services.yaml bereits so ausgeliefert:

App\Infrastructure\AI\OllamaClientInterface:
    alias: App\Infrastructure\AI\MistralClient

.env.local:

MISTRAL_API_KEY=sk-...
# Modelle sind in .env vorbelegt:
# AI_TEXT_MODEL=${MISTRAL_TEXT_MODEL}   → mistral-large-latest
# AI_VISION_MODEL=${MISTRAL_VISION_MODEL} → pixtral-12b-2409

Web-Suche (SpecsResearchAgent): Tavily APITAVILY_API_KEY=tvly-... in .env.local.

Nach Schlüsseländerung: docker compose exec app php bin/console cache:clear.