EDEvangelos Dimitriadis
  • About me
  • Blog
  • Services
  • Projects
  • OpenSource
  • Uses
  • Contact
← Back to the blog

Zero-Downtime Deploys for Laravel on ECS

ED
Evangelos Dimitriadis
June 29, 2026
Zero-Downtime-Deploys für Laravel auf ECS

Photo by Paul Krüger on Unsplash

A deploy takes maybe fifteen seconds. In those fifteen seconds, every request currently running can die, every job currently being processed can abort, and every user currently loading a page can see an error. Zero-downtime does not mean nothing happens. It means the user notices none of it.

ECS makes the rolling update easy. One checkbox, new tasks replace old ones, done. But zero-downtime is more than that checkbox. The actual work lies in the migrations, in the requests and jobs that are in flight during the deploy, and in the question of what happens when the new code is broken. This article builds on the Fargate setup and uses the alarms from the observability article for automatic rollback.

Versions are Laravel 13, as of June 2026, region eu-central-1 as reference.

What Zero-Downtime Actually Means

Three guarantees sit behind the word: no aborted request, no lost job, no broken frontend. The hard part is that all three are put to the test in the same brief moment.

That moment is the rolling window. For a few seconds to minutes, old and new code run at the same time, against the same database, behind the same load balancer. Anything that breaks in this window is an incident. This is exactly where most deploys that are "supposed to be zero-downtime" fail.

The mental shift that makes everything easier: the question is not "how do I deploy new code", but "how do I make sure old and new code run peacefully side by side for a moment". Think that way, and you solve the right problems.

The ECS Rolling Mechanics

On a rolling update, ECS replaces the old tasks with new ones step by step. Two parameters control how. minimumHealthyPercent sets how much capacity must stay healthy at minimum, maximumPercent how far above the target capacity ECS may scale during the deploy. 100 and 200 means: bring up all new tasks first, then take the old ones down. Full capacity the whole time, at the cost of briefly double the spend.

For that to work, ECS has to know when a new task is truly ready. That is the health check. The /up endpoint is the natural ALB target group check, and it needs a sufficient startPeriod so the task can boot before the check starts counting. Too short a window, and ECS routes traffic to a task that is still coming up.

At the other end sits the deregistration delay, also called connection draining. Before an old task dies, the load balancer takes it out of rotation and lets the running requests finish. The default is 300 seconds, which is too long for most web apps and is often lowered to 30 to 60 seconds.

And if something goes wrong, the deployment circuit breaker steps in. If a new task does not come up after a threshold of attempts, the deploy counts as failed and rolls back automatically:

{
  "deploymentConfiguration": {
    "minimumHealthyPercent": 100,
    "maximumPercent": 200,
    "deploymentCircuitBreaker": {
      "enable": true,
      "rollback": true
    }
  }
}

The Migration Problem

This is the hardest part. In the rolling window, the database schema has to fit both, the old and the new code. A migration that changes the schema so the old code breaks takes the whole app down before the new code even runs everywhere.

The Golden Rule of Backward-Compatible Migration

Every migration must complete while the old code is still running, without breaking it. That means additive, never destructive in the same deploy. Adding a column is safe, the old code simply ignores it. Renaming or dropping a column is dangerous, because the old code still expects it.

And the migration runs as a single migrate --force RunTask before the service update, not in the container entrypoint. Put migrate in the entrypoint, and with N new tasks you have N simultaneous migrations and thus a race condition, as described in the Fargate article.

Expand-Contract by Example

Take the classic that naively guarantees downtime: renaming a column, say name to full_name. In one step, that breaks every old task still reading name. The pattern that solves it is called expand-contract or parallel change, and it splits the change into four steps across multiple deploys.

First the expand phase, which adds the new column additively:

// Deploy 1: expand
return new class extends Migration {
    public function up(): void
    {
        Schema::table('users', function (Blueprint $table) {
            $table->string('full_name')->nullable();
        });
    }
};

Then you deploy code that writes to both columns and reads from the new one, with a fallback to the old. After that a backfill that moves the old values into the new column. And only in a later, separate deploy comes the contract phase, once no running code needs the old column anymore:

// Deploy 2 (later): contract
return new class extends Migration {
    public function up(): void
    {
        Schema::table('users', function (Blueprint $table) {
            $table->dropColumn('name');
        });
    }
};

Four steps instead of one, and not a second of downtime. That is more work than a single migration, but it is the difference between a quiet release and an incident.

In-Flight Requests and Jobs

For the web service, the deregistration delay does the work. The load balancer sends no new requests to the dying task and lets the open ones finish. FPM completes them cleanly, Octane finishes the in-flight requests on reload.

For the workers it is the graceful shutdown. On stop, ECS sends a SIGTERM, and after stopTimeout a SIGKILL. queue:work finishes the currently running job before it stops, and Horizon shuts the master down cleanly. For that to work, stopTimeout must be larger than the typical job runtime. The default of 30 seconds is not enough if a job takes longer. The details are in the Fargate article.

Above all this sits idempotency. Because a job can run again on a hard abort, jobs must be idempotent. That is not theoretical caution but relevant on every deploy, because every deploy is a potential abort point.

Frontend, Sessions, and Cache

The frontend has its own rolling problem: a user who loaded a page before the deploy holds HTML in the browser that references the old assets. After the deploy, that HTML must not point into the void. The solution is versioned assets. The Vite manifest gives every build hashed file names, so old HTML keeps loading its old assets and new HTML the new ones. Both versions coexist, nothing breaks.

Sessions and cache have the same principle behind them. Sessions belong in a shared store, that is Redis or the database, not in a task's local file system. Otherwise a user loses their session when switching to a new task, because it lived on the old one. The same goes for the cache. Local cache per task leads to inconsistent behavior while old and new tasks run in parallel. And config:cache belongs in the image build, so no task recompiles the config on start.

Octane: Do Not Double Up

Here teams with Octane make a common mistake. Octane with FrankenPHP, Swoole, or RoadRunner brings its own graceful reload. A reload signal lets the persistent workers finish their running requests and restart with the new code. That is already a zero-downtime mechanism.

The consequence: you must not double the mechanisms. If you deploy on ECS in a rolling fashion and let Octane restart cleanly, you have enough. An additional zero-downtime layer, like the ones some tools build for classic FPM via symlink switches, interferes with Octane's own reload and leads to strange behavior. On ECS, the rolling update plus Octane reload replaces exactly what the symlink switch was on a classic server. It needs no more, and more hurts.

Rollback When the New Code Is Broken

The best deploy process does not help if the new code is faulty and nobody can go back. ECS brings rollback built in, in two stages.

Deployment Circuit Breaker

The circuit breaker catches the obvious case: a task that does not even come up, for example because an environment variable is missing or the container crashes immediately. After a threshold of attempts, ECS marks the deploy as failed and rolls back automatically to the last working task definition. That is the protection against the broken build.

Alarm-Based Rollback

The subtler case is more dangerous: the task comes up, the health check is green, but the error rate rises, or latency explodes. The circuit breaker does not see this, because the task is technically running. Here CloudWatch alarms on real business metrics step in, not just CPU and RAM. Application Signals delivers the latency and error rates the alarm points at:

resource "aws_cloudwatch_metric_alarm" "deploy_error_rate" {
  alarm_name          = "laravel-deploy-error-rate"
  comparison_operator = "GreaterThanThreshold"
  evaluation_periods  = 2
  metric_name         = "5xxErrorRate"
  namespace           = "ECS/ApplicationSignals"
  period              = 60
  statistic           = "Average"
  threshold           = 5
  alarm_description    = "Triggers deploy rollback on elevated error rate"
}

With native blue/green, the old version stays up for a configurable bake time after the traffic switch. If an alarm fires during that time, the rollback is instant, because the old fleet is still standing.

Rolling, Native Blue/Green, or CodeDeploy

ECS offers three paths in 2026, and the choice depends on how much control you need over the traffic shift.

The rolling update is the simple default. New tasks replace old ones step by step. For most setups that is entirely enough, as long as the migrations are backward compatible.

Native blue/green is generally available in 2026 and the recommended path for most teams. A complete green fleet comes up in parallel, the traffic switches all at once, then the bake time runs with instant rollback on alarm, plus lifecycle hooks. All of this without external tooling, native in ECS.

CodeDeploy is only needed for fine-grained traffic shifts, that is canary, where a small percentage first gets the new code, or linear, where traffic moves in steps. More control, more complexity.

NeedChoice
Standard deploy, backward-compatible migrationsRolling update
Clean cut with bake time and fast rollbackNative blue/green
Gradual traffic shift, canary or linearCodeDeploy
Risky release you want to watch closelyNative blue/green plus business alarms

The Full Deploy Sequence

The order holds everything together, and it is not negotiable:

  1. Build the image, with config:cache and versioned assets
  2. Push to ECR
  3. migrate --force as a RunTask, additive migrations only
  4. Update the services to the new image, rolling or via blue/green
  5. Watch the bake time and alarms, automatic rollback kicks in if needed
  6. Cleanup migrations, that is the contract phase, only in a later, separate deploy

Step three before step four is the core: the migration runs before the new code arrives, and because it is additive, it does not disturb the old code. Step six separate from step three is the second half: cleanup happens only when no code needs the old structure anymore. The mechanics of the pipeline itself are in the CI/CD article.

Anti-Patterns

Destructive migration in the same deploy as the code. Breaks the old code in the rolling window. Renaming and dropping belong in the contract phase of a later deploy.

migrate in the container entrypoint. Race across N tasks. Belongs in a single RunTask before the service update.

No health check or too short a startPeriod. ECS routes traffic to a task still booting, and the user sees the error.

No deregistration delay. Running requests are cut off hard when the old task dies.

Local sessions or local cache. The user loses their session when switching to a new task. Both belong in a shared store.

No rollback plan. When the new code is broken, there is no way back. Circuit breaker and alarm rollback cost almost nothing and save the bad day.

Doubling the zero-downtime layer with Octane. Octane does its own graceful reload. An additional layer interferes with it.

All migrations at once, including cleanup. Expand and contract belong in separate deploys, otherwise backward compatibility is gone.

Conclusion

Zero-downtime on ECS is not a single feature, but the interplay of backward-compatible migrations, connection draining, graceful shutdown, and a rollback that kicks in automatically. Each piece on its own is manageable, and together they make a deploy the user does not notice.

The hardest discipline is expand-contract. Plan migrations so that old and new code run peacefully side by side for a moment, and you have solved the core problem. ECS takes care of most of the rest. With native blue/green and the circuit breaker, rollback is built in as of 2026, and the team's job comes down to setting the right alarms and cutting the migrations cleanly.


Do you deploy on ECS, but every release feels like a held breath? Contact me for a deploy audit that reviews and secures migrations, health checks, draining, and rollback strategy in a few days.

I am currently building an IaC kit for production Laravel on AWS, including a deploy pipeline with expand-contract discipline and alarm-based rollback. If you want to know early, you can get on the list without obligation. No newsletter barrage, just one message when it is ready.

About meBlogProjectsContactImprintPrivacy Policy

Made in Gerlingen, 2026