EDEvangelos Dimitriadis
  • Über mich
  • Blog
  • Leistungen
  • Projekte
  • OpenSource
  • Uses
  • Kontakt
← Zurück zum Blog

Laravel-Queues und Scheduler auf AWS Fargate: was Toy-Repos auslassen

ED
Evangelos Dimitriadis
1. Juni 2026
Laravel-Queues und Scheduler auf AWS Fargate: was Toy-Repos auslassen

Foto von Winston Chen auf Unsplash

Lokal läuft alles. docker compose up, die App antwortet, ein Job wird verarbeitet, der Scheduler tickt. Dann geht dasselbe Image auf AWS Fargate, und drei Dinge passieren leise: die Queue wird länger statt kürzer, der Scheduler läuft nie, und der nächste Deploy verliert einen Job mitten in der Verarbeitung. Nichts davon wirft einen Fehler. Es funktioniert einfach nicht.

Im Vergleich von ECS und EKS ist die Empfehlung für den Mittelstand klar auf ECS Fargate gefallen. Diese Plattform hat aber keinen Host, kein systemd, keine Crontab und keinen Supervisor im klassischen Sinn. Genau die Bausteine, auf die sich ein Laravel-Deploy auf einem normalen Server verlässt, fehlen. Dieser Artikel zeigt, wie man eine Laravel-App auf Fargate so verdrahtet, dass Queues laufen, der Scheduler tickt und ein Deploy keine Jobs frisst.

Was er nicht ist: eine Einführung in Docker oder ECS. Wer noch nie einen Container deployed hat, liest zuerst Docker in Produktion und den ECS-vs-EKS-Vergleich. Versionsstand ist Laravel 13, Region eu-central-1 als Referenz, Stand Juni 2026.

Eine Laravel-App ist drei Prozesse, nicht einer

Auf einem klassischen Server laufen drei Dinge nebeneinander, ohne dass man groß darüber nachdenkt: php-fpm oder Octane bedient HTTP, ein oder mehrere queue:work-Prozesse hängen unter Supervisor, und cron ruft minütlich schedule:run auf. Drei Prozesstypen, ein Server, fertig.

Auf Fargate wird daraus dreimal dasselbe Image, aber als drei getrennte ECS-Services mit unterschiedlichem Command und unterschiedlicher Skalierung. Der Web-Service hängt am Load Balancer und skaliert auf Request-Last. Der Worker-Service hat keinen Load Balancer und skaliert auf Queue-Tiefe. Der Scheduler ist ein einzelner Prozess, der nie skaliert.

Die Versuchung, alles in einen Container zu packen, ist groß. Ein Supervisor, der FPM plus Worker plus cron in einem Fargate-Task hält, klingt nach weniger Aufwand. Er bricht aber die Grundannahmen der Plattform. Ein Container hat einen Hauptprozess und ein Health-Signal. Skaliert man den Web-Teil hoch, skalieren die Worker ungewollt mit. Stirbt der Worker, merkt es das Health-Signal nicht, weil das nur auf FPM schaut. Und beim Deploy bekommen FPM, Worker und cron gemeinsam ein SIGTERM und müssen sich dasselbe Zeitfenster zum Aufräumen teilen. Das geht eine Weile gut und fällt dann unter Last auseinander.

Die Regel ist simpel: ein Prozesstyp pro Service, ein Image für alle drei, das Verhalten bestimmt das Command und die Task-Definition.

Der Web-Service

Health-Check mit /up

Seit Laravel 11 liefert das Framework den /up-Endpoint frei Haus. Das ist der natürliche Health-Check für die ALB-Target-Group, kein selbstgebauter Status-Endpoint nötig. Ein Detail, das oft schiefgeht: Der Health-Check sollte prüfen, ob die App bootet, nicht ob die Datenbank erreichbar ist. Hängt man die DB an den Health-Check, nimmt ein kurzer Datenbank-Schluckauf den gesamten Web-Service aus dem Load Balancer, obwohl die App selbst völlig in Ordnung ist.

FPM oder Octane

Nginx plus php-fpm im Container ist der robuste Default und für die meisten Setups die richtige Wahl. Octane mit Swoole oder FrankenPHP bringt mehr Durchsatz, verlangt aber State-Disziplin, weil der Worker zwischen Requests im Speicher bleibt (Details im Octane-Artikel). Für Fargate ist FrankenPHP interessant, weil ein einzelner Prozess HTTP und PHP bedient und man sich den Nginx-Sidecar spart. Ich würde mit FPM starten und erst auf Octane wechseln, wenn der Durchsatz es wirklich verlangt.

config:cache im Image-Build

config:cache und route:cache gehören in den Docker-Build, nicht in den Entrypoint. Wird die Config zur Build-Zeit kompiliert, muss kein Task sie beim Start neu zusammenbauen, und der Start ist schneller. Das hat eine Konsequenz, die regelmäßig für Verwirrung sorgt: Mit gecachter Config liest Laravel die Werte aus dem kompilierten File, nicht mehr per env() zur Laufzeit. Ein env()-Aufruf tief im Code gibt dann null zurück. Alle Umgebungswerte müssen über config()-Files gehen, sonst bricht genau die Optimierung den Zugriff auf die eigenen Variablen.

So sieht die Web-Task-Definition aus, mit /up als Health-Check und awslogs für die Logs:

{
  "family": "laravel-web",
  "networkMode": "awsvpc",
  "requiresCompatibilities": ["FARGATE"],
  "cpu": "512",
  "memory": "1024",
  "executionRoleArn": "arn:aws:iam::123456789012:role/ecsTaskExecutionRole",
  "taskRoleArn": "arn:aws:iam::123456789012:role/laravel-task-role",
  "containerDefinitions": [
    {
      "name": "web",
      "image": "123456789012.dkr.ecr.eu-central-1.amazonaws.com/laravel:1.4.0",
      "command": ["php", "artisan", "octane:frankenphp", "--host=0.0.0.0", "--port=8000"],
      "essential": true,
      "portMappings": [{ "name": "http", "containerPort": 8000, "protocol": "tcp" }],
      "healthCheck": {
        "command": ["CMD-SHELL", "curl -f http://localhost:8000/up || exit 1"],
        "interval": 30,
        "timeout": 5,
        "retries": 3,
        "startPeriod": 15
      },
      "logConfiguration": {
        "logDriver": "awslogs",
        "options": {
          "awslogs-group": "/ecs/laravel-web",
          "awslogs-region": "eu-central-1",
          "awslogs-stream-prefix": "web"
        }
      }
    }
  ]
}

Der Queue Worker als eigener Service

queue:work oder Horizon

queue:work mit eigenem Supervisor-Pattern ist minimal und funktioniert, gibt aber keine Sicht in die Queue. Horizon legt ein Dashboard, Metriken, Tags und automatisches Balancing über mehrere Queues obendrauf, braucht dafür aber Redis oder Valkey als Connection. Für die meisten Mittelstand-Setups ist Horizon den Aufwand wert, allein wegen der Sicht auf Durchsatz und Wartezeit. Wenn ich nachts geweckt werde, weil die Queue volläuft, will ich nicht raten, welcher Job-Typ klemmt.

Das Queue-Backend

Redis oder Valkey auf ElastiCache. Stand Juni 2026 ist Valkey der von AWS empfohlene Default für neue Cluster, rund 20 Prozent günstiger als Redis OSS und protokollkompatibel. Für eine neue Laravel-App gibt es kaum einen Grund, noch Redis OSS zu wählen. SQS ist die Alternative, wenn man die Queue ganz aus dem eigenen Betrieb herausziehen will, kostet aber die Horizon-Sicht, weil Horizon Redis braucht.

Memory-Hygiene mit max-time und max-jobs

Worker-Prozesse leben lang und sammeln dabei Speicher an, weil PHP zwischen Jobs nicht alles freigibt. Irgendwann läuft der Prozess ins Memory-Limit und wird hart gekillt. --max-time und --max-jobs lassen den Worker nach einer definierten Laufzeit oder Job-Zahl sauber neu starten, bevor das passiert. --max-time=3600 plus --max-jobs=1000 ist ein vernünftiger Startwert. Der häufigste Anfängerfehler an dieser Stelle ist ein anderer: queue:work als Hintergrundprozess im Web-Container starten. Der Worker stirbt dann irgendwann unbemerkt, weil das Health-Signal nur FPM prüft, und niemand merkt es, bis die Queue überläuft.

Die Worker-Definition mit Horizon als Command, plus die queue:work-Variante als Kommentar:

{
  "name": "worker",
  "image": "123456789012.dkr.ecr.eu-central-1.amazonaws.com/laravel:1.4.0",
  "command": ["php", "artisan", "horizon"],
  "essential": true,
  "stopTimeout": 120,
  "logConfiguration": {
    "logDriver": "awslogs",
    "options": {
      "awslogs-group": "/ecs/laravel-worker",
      "awslogs-region": "eu-central-1",
      "awslogs-stream-prefix": "worker"
    }
  }
}

Ohne Horizon ersetzt man das Command durch ["php", "artisan", "queue:work", "redis", "--max-time=3600", "--max-jobs=1000", "--tries=3"] und lässt den Service über die desired count skalieren.

Das Scheduler-Problem auf Fargate

Hier liegt das größte Loch. Laravels Scheduler erwartet einen Cron-Eintrag, der minütlich php artisan schedule:run aufruft. Auf Fargate gibt es keine Crontab und keinen Host, in den man sie schreiben könnte. Wer * * * * * php artisan schedule:run in eine Datei schreibt, sucht den falschen Ort, weil es ihn auf Fargate nicht gibt. Drei Wege funktionieren.

Option A: ein dedizierter schedule:work-Service

schedule:work ist ein langlebiger Prozess, der intern minütlich schedule:run ausführt. Ein einziger Fargate-Task mit diesem Command ersetzt die Crontab vollständig. Die wichtigste Regel dabei, und der praktisch wertvollste Satz dieses Artikels: Die desired count muss exakt 1 sein. Bei zwei Tasks läuft jeder geplante Job doppelt. Reports gehen doppelt raus, Sync-Jobs laufen doppelt, und im schlechtesten Fall merkt man es erst an doppelten Rechnungen.

Als zusätzliche Absicherung definiert man die geplanten Tasks mit onOneServer. Das stützt sich auf einen Cache-Lock in Redis oder Valkey und schützt gegen den kurzen Moment beim Rolling Deploy, in dem für ein paar Sekunden zwei Scheduler-Tasks gleichzeitig laufen können:

// routes/console.php
use Illuminate\Support\Facades\Schedule;
 
Schedule::command('reports:daily')
    ->dailyAt('06:00')
    ->onOneServer()
    ->withoutOverlapping();
 
Schedule::command('subscriptions:renew')
    ->hourly()
    ->onOneServer();

Der Trade-off: ein dauerhaft laufender, fast immer idle Task, der trotzdem vCPU und Memory kostet. Bei einem kleinen Task ist das vernachlässigbar.

Option B: EventBridge Scheduler mit RunTask

EventBridge Scheduler ist ein serverloser Cron, der pro Minute einen kurzlebigen ECS-Task mit dem Command schedule:run startet. Kein dauerhaft laufender Container, Abrechnung nur für die kurzen Runs. Das klingt eleganter, hat aber Haken. Der Task-Start kostet jede Minute Latenz, schedule:run muss in deutlich unter 60 Sekunden durchlaufen, und der RunTask startet gelegentlich nicht, etwa wegen Capacity- oder Netzwerk-Themen. Man braucht also Retry-Logik und sollte die Fehlstarts überwachen.

resource "aws_scheduler_schedule" "laravel_scheduler" {
  name                = "laravel-schedule-run"
  schedule_expression = "rate(1 minute)"
 
  flexible_time_window {
    mode = "OFF"
  }
 
  target {
    arn      = aws_ecs_cluster.laravel.arn
    role_arn = aws_iam_role.scheduler.arn
 
    ecs_parameters {
      task_definition_arn = aws_ecs_task_definition.scheduler.arn
      launch_type         = "FARGATE"
 
      network_configuration {
        subnets          = var.private_subnet_ids
        security_groups  = [aws_security_group.laravel_tasks.id]
        assign_public_ip = false
      }
    }
 
    retry_policy {
      maximum_retry_attempts = 2
    }
  }
}

Option C: Supercronic als Sidecar

Supercronic ist ein cron-kompatibler Runner für Container, der minütlich schedule:run triggert. Pragmatisch, wenn ein Team an seiner Crontab-Syntax hängt, fügt aber eine weitere Komponente hinzu, die man pflegen muss.

Empfehlung

OptionIdle-KostenKomplexitätBestes Szenario
schedule:work-Service (count 1)dauerhaft 1 TaskniedrigDefault für die meisten Setups
EventBridge mit RunTasknur pro Runmittelviele Jobs, Kostendruck, kurze schedule:run-Laufzeit
Supercronic-SidecargeringmittelTeams mit bestehender Crontab-Logik

Für die meisten Mittelstand-Apps ist Option A die richtige Wahl. Sie ist am einfachsten zu verstehen und am schwersten falsch zu konfigurieren, solange die desired count auf 1 steht. Unabhängig von der Option gilt eine Regel: Lange Arbeit gehört nicht in schedule:run selbst, sondern wird von dort als Queue-Job dispatched. Der Scheduler triggert, der Worker arbeitet. Ein Report, der drei Minuten rechnet, blockiert sonst den nächsten Scheduler-Lauf.

Graceful Shutdown und Deploy-Sicherheit

SIGTERM, stopTimeout, SIGKILL

Beim Stoppen eines Tasks, also bei jedem Deploy, jedem Scale-In und jedem Spot-Reclaim, sendet ECS ein SIGTERM an den Hauptprozess. Reagiert der Prozess nicht innerhalb von stopTimeout, folgt ein SIGKILL. Der Default ist 30 Sekunden, praktisch sind bis zu 120 Sekunden möglich (Fargate Spot verlangt unter 120). Seit Dezember 2025 liest ECS Fargate zusätzlich das STOPSIGNAL aus der Image-Konfiguration, falls ein Prozess auf ein anderes Signal sauber reagiert. Ohne Angabe bleibt es SIGTERM.

Worker und Horizon sauber beenden

queue:work fängt SIGTERM ab und beendet den gerade laufenden Job, bevor es stoppt. Damit das klappt, muss stopTimeout größer sein als die typische Job-Laufzeit. Die Rechnung ist unbarmherzig: Ein Job, der 50 Sekunden braucht, plus der Default-stopTimeout von 30 Sekunden, ergibt ein SIGKILL mitten im Job. Der Job gilt dann als nicht abgeschlossen und läuft erneut, im schlechteren Fall mit Seiteneffekten, die schon halb passiert sind. Deshalb steht in der Worker-Definition oben stopTimeout: 120.

Horizon bringt horizon:terminate mit, das den Master sauber beendet und laufende Jobs auslaufen lässt. Eine Stolperfalle in Deploy-Skripten: Das Kommando kehrt asynchron sofort zurück, es wartet nicht, bis die Worker fertig sind. Bei ECS ist der saubere Weg ohnehin ein anderer. Man schickt SIGTERM an den Horizon-Master (das macht ECS beim Stoppen von selbst) und gibt ihm über stopTimeout genug Zeit. Ein manuelles terminate im Entrypoint braucht man dafür nicht.

Idempotenz als Sicherheitsnetz

Weil ein Job bei hartem Abbruch erneut laufen kann, müssen Jobs idempotent sein. Das ist keine Fargate-Eigenheit, aber Fargate-Deploys machen den Fall häufig statt selten. Ein Job, der eine Zahlung auslöst und kein Dedup kennt, ist auf Fargate eine Frage der Zeit, nicht des Ob. Eindeutige Job-Keys, withoutOverlapping, und ein Check, ob die Arbeit schon erledigt ist, kosten wenig und ersparen viel.

Secrets und Konfiguration

Das Anti-Pattern ist verbreitet: DB-Passwort und APP_KEY als Klartext in der environment-Sektion der Task-Definition. Jeder mit ecs:DescribeTaskDefinition liest sie dann im Klartext. Richtig ist die secrets-Sektion, die auf AWS Secrets Manager oder den SSM Parameter Store zeigt. In der Task-Definition steht nur die ARN, den Wert injiziert ECS beim Start als Umgebungsvariable:

"secrets": [
  {
    "name": "APP_KEY",
    "valueFrom": "arn:aws:ssm:eu-central-1:123456789012:parameter/laravel/app-key"
  },
  {
    "name": "DB_PASSWORD",
    "valueFrom": "arn:aws:secretsmanager:eu-central-1:123456789012:secret:laravel/db-AbCdEf:password::"
  }
]

Die Wahl zwischen den beiden ist eine Kostenfrage. Secrets Manager kann rotieren und kostet pro Secret, ideal für DB-Credentials, die RDS automatisch rotiert. Der SSM Parameter Store mit SecureString ist günstiger und reicht für Werte, die selten wechseln, etwa den APP_KEY. Der gehört auf keinen Fall ins Image, weil das Rotation unmöglich macht und ein Leak-Risiko ist.

Und noch einmal die Wechselwirkung mit config:cache: Die per ECS injizierten Secrets müssen in config()-Files landen, nicht per env() tief im Code abgefragt werden. Mit gecachter Config kommt sonst null zurück, und der Fehler tritt erst in Produktion auf, weil lokal ohne Cache gearbeitet wird.

Migrations und Zero-Downtime-Deploys

migrate im Container-Entrypoint ist ein Klassiker, der unter Last beißt. Bei einem Rolling Deploy mit drei neuen Web-Tasks startet jeder Task gleichzeitig migrate. Im besten Fall blockiert der Datenbank-Lock und zwei Tasks warten, im schlechteren kollidieren die Migrationen. Richtig ist ein einmaliger ECS-RunTask mit migrate --force als eigener Schritt der Pipeline, nach dem Image-Push und vor dem Service-Update:

aws ecs run-task \
  --cluster laravel-production \
  --task-definition laravel-migrate \
  --launch-type FARGATE \
  --network-configuration "awsvpcConfiguration={subnets=[$SUBNETS],securityGroups=[$SG],assignPublicIp=DISABLED}" \
  --overrides '{"containerOverrides":[{"name":"app","command":["php","artisan","migrate","--force"]}]}'

Erst wenn dieser Task durch ist, aktualisiert die Pipeline die drei Services auf das neue Image. Die CI/CD-Pipeline führt diesen Schritt aus.

Zwei Rolling-Update-Parameter steuern das Verhalten während des Deploys. minimumHealthyPercent und maximumPercent legen fest, wie viele alte Tasks laufen bleiben. 100 und 200 bedeutet, erst alle neuen hochfahren, dann alte runter: volle Kapazität, aber kurzzeitig doppelte Kosten. 50 und 100 ist sparsamer, reduziert aber die Kapazität während des Deploys. Dazu kommt eine Disziplin auf Schema-Ebene: Während eines Deploys nur additive Migrationen, also Spalte hinzufügen statt umbenennen. Sonst sieht der alte Code für einen Moment ein Schema, das er nicht kennt. Das ist das Expand-Contract-Muster, hier nur angerissen.

Worker-Autoscaling auf Queue-Tiefe

Worker skaliert man auf Queue-Tiefe, nicht auf CPU. Der Grund ist die Natur der Arbeit: Ein Worker, der auf einen langsamen externen API-Call wartet, hat niedrige CPU-Last und eine wachsende Queue. CPU-basiertes Autoscaling meldet in dieser Situation, alles sei ruhig, während die Queue volläuft. Es skaliert in die falsche Richtung.

Bei SQS ist die native Metrik ApproximateNumberOfMessagesVisible, perfekt für Target-Tracking. Bei Redis oder Valkey mit Horizon veröffentlicht man die Queue-Länge oder die Horizon-Wartezeit als Custom-CloudWatch-Metrik, etwa über eine kleine Lambda, die minütlich die Queue-Tiefe ausliest. Eine Target-Tracking-Policy auf die SQS-Metrik sieht so aus:

resource "aws_appautoscaling_policy" "worker_queue_depth" {
  name               = "worker-scale-on-queue"
  policy_type        = "TargetTrackingScaling"
  resource_id        = "service/laravel-production/laravel-worker"
  scalable_dimension = "ecs:service:DesiredCount"
  service_namespace  = "ecs"
 
  target_tracking_scaling_policy_configuration {
    target_value = 100  # angepeilte Nachrichten pro Worker-Task
 
    customized_metric_specification {
      metric_name = "ApproximateNumberOfMessagesVisible"
      namespace   = "AWS/SQS"
      statistic   = "Average"
      dimensions {
        name  = "QueueName"
        value = "laravel-default"
      }
    }
  }
}

Einen Mindest-Worker sollte man immer laufen lassen, statt auf null zu skalieren. Sonst zahlt die erste Nachricht nach einer Ruhephase die Task-Start-Latenz, und das fühlt sich für den Nutzer wie ein Hänger an. Scale-to-Zero ist nur sinnvoll, wenn Latenz wirklich egal ist. Der Scheduler bleibt von alldem außen vor, er skaliert nie und bleibt bei count 1.

Observability

Logs gehen über den awslogs-Treiber nach CloudWatch Logs, das ist der einfache Default. Für strukturierte Logs und Routing zu mehreren Zielen ist FireLens mit Fluent Bit die nächste Stufe. Wichtig auf Laravel-Seite: der Logging-Channel auf stderr, damit Logs als Container-Logs landen und nicht in einer Datei im flüchtigen Container-Filesystem verschwinden, die mit dem Task stirbt.

Das Horizon-Dashboard gibt die Live-Sicht auf Durchsatz, Wartezeit und fehlgeschlagene Jobs. Die failed_jobs-Tabelle braucht einen Alarm, denn ein wachsender Failed-Count ist ein Incident, kein Detail. Mindestens zwei CloudWatch-Alarme gehören gesetzt: einer auf die Queue-Tiefe, der anschlägt, wenn die Queue volläuft, und einer auf die Failed-Jobs-Rate. Mehr dazu im kommenden Artikel zu Observability in Produktion.

Anti-Patterns

Die Muster, die ich in technischen Audits immer wieder sehe:

Alles in einem Container. FPM, Worker und cron unter einem Supervisor in einem Fargate-Task. Bricht Health-Signal, Skalierung und Deploy-Verhalten auf einmal.

queue:work im Web-Container. Der Worker als Hintergrundprozess neben FPM stirbt unbemerkt, weil das Health-Signal nur den Webserver prüft.

Scheduler mit desired count 2. Jeder geplante Job läuft doppelt. Der häufigste Grund für doppelte Mails und doppelte Sync-Läufe.

migrate im Entrypoint. Race über N Tasks beim Rolling Deploy. Gehört in einen einmaligen RunTask.

Secrets als Klartext-Env. Lesbar für jeden mit DescribeTaskDefinition. Gehört in Secrets Manager oder SSM.

Kein stopTimeout-Tuning. Default 30 Sekunden plus Jobs über 30 Sekunden ergibt SIGKILL mitten im Job, bei jedem Deploy.

Verlass auf SIGKILL. Ohne SIGTERM-Handling und ohne Idempotenz verliert oder dupliziert jeder Deploy Jobs.

Host-Cron auf Fargate. Existiert nicht, es gibt keinen Host. Wer eine Crontab schreiben will, ist auf der falschen Plattform oder braucht eine der drei Scheduler-Optionen.

Lange Arbeit direkt in schedule:run. Blockiert den Scheduler, gehört in einen dispatchten Queue-Job.

Referenz-Setup

Das Szenario: eine Laravel-13-App, eine ALB-fronted Web-Schicht, Horizon für Queues auf Valkey, ein dedizierter Scheduler, RDS PostgreSQL, Secrets im SSM Parameter Store. Drei ECS-Services aus einem Image.

Drei Services aus einem Image

Der Web-Service läuft mit FrankenPHP oder nginx plus FPM, hängt an der ALB-Target-Group mit /up als Health-Check und skaliert auf Request-Count. Der Worker-Service läuft mit horizon, hat keinen Load Balancer, skaliert auf Queue-Tiefe und hat stopTimeout auf 120 gesetzt. Der Scheduler-Service läuft mit schedule:work, hat eine fixe desired count von 1 und nutzt onOneServer-Locking. Drei Services, ein Image, drei Commands.

Deploy-Pipeline

Die Reihenfolge ist die halbe Miete. Image bauen mit config:cache und route:cache, nach ECR pushen, den RunTask mit migrate --force ausführen, und erst dann die drei Services rollend auf das neue Image aktualisieren. Wer die Migration nach dem Service-Update laufen lässt, hat für einen Moment neuen Code auf altem Schema.

# Ausschnitt aus dem Dockerfile
RUN php artisan config:cache \
 && php artisan route:cache \
 && php artisan event:cache

EventBridge-Alternative

Wer den dauerhaft laufenden Scheduler-Task sparen will, ersetzt den Scheduler-Service durch die EventBridge-Schedule aus Option B. Der Rest des Setups bleibt identisch. Das lohnt sich vor allem, wenn der Kostendruck hoch ist und schedule:run schnell durchläuft.

Zur Größenordnung: drei kleine Fargate-Tasks plus ein kleiner Valkey-Node landen für eine typische Mittelstand-App im niedrigen dreistelligen Bereich pro Monat. Die Pricing-Tiefe steckt im ECS-vs-EKS-Artikel, die Werte gelten für eu-central-1, und für die eigene Region prüft man sie am besten im AWS Pricing Calculator.

Fazit

ECS Fargate ist für Laravel im Mittelstand die richtige Plattform, aber sie verlangt ein Umdenken: Die App ist drei Prozesse, nicht ein Container. Die drei Stellen, an denen Starter-Repos und Forge-Klone schweigen, sind genau die drei, die in Produktion zuerst weh tun. Der Scheduler ohne Host-Cron, der Worker ohne Graceful Shutdown, der Deploy ohne saubere Migration.

Wer diese drei sauber verdrahtet, hat ein Setup, das Deploys übersteht, ohne Jobs zu verlieren, und das mit der Last mitskaliert. Der Aufwand steckt nicht in der Komplexität, sondern in den Details, die man erst kennt, wenn man sie einmal falsch gemacht hat. Genau diesen Teil kapselt ein gewartetes IaC-Setup einmal richtig, statt ihn in jedem Projekt neu zusammenzusuchen.


Läuft Ihre Laravel-App auf Fargate, Forge oder einem eigenen Setup, und Sie sind sich beim Queue-, Scheduler- oder Deploy-Verhalten nicht sicher? Kontaktieren Sie mich für einen technischen Audit, der das in zwei bis drei Tagen durchleuchtet.

An einem wartbaren IaC-Kit für Production-Laravel auf AWS (CDK und Terraform) arbeite ich gerade. Wer früh Bescheid wissen will, kann sich unverbindlich auf die Liste setzen lassen. Kein Newsletter-Dauerfeuer, nur eine Nachricht, wenn es so weit ist.

Über michBlogProjekteKontaktImpressumDatenschutzerklärung

Made in Gerlingen, 2026