All articles
12 September 2026
9 min read

A PHP monolith on ECS Fargate

By Tim Rutte, Cloud & Software ArchitectTopicAWS & Cloud

A single white shipping container on a plain surface, its locking rods blue.

When a PHP application moves from a server into the cloud, the question of containers comes up early, and the question of Kubernetes shortly after. For a single monolith with one team behind it, that is too much in most cases, and it delays the move by months.

This article describes the smaller route: put a PHP monolith into containers and run it on ECS Fargate. No machines of your own, no cluster administration, no second operating model. That is enough for a surprising number of systems, and it leaves the door to everything else open.

The precondition is that the application no longer holds state on its disk. While sessions and uploaded files sit locally, moving into containers is moving the problems.

Why Fargate and not Kubernetes

The honest justification is not technical superiority, it is arithmetic about operational effort.

Kubernetes solves problems a single monolith does not have: many services, many teams, network rules between services, a need for uniform procedures across languages. It is good at that, and there it is worth it. The price is a second system that has to be operated, updated and understood, and that price applies whether or not you need the benefits.

Fargate leaves exactly that part out. There are no machines for anybody to maintain: you describe a container, say how many should run, and that is it. What is missing is the finer control, and for a monolith you usually do not need it.

The rule I draw from that: Kubernetes pays off from the point where several teams want to ship independently of each other. Before that it is effort disguised as preparation. And the route there stays open later, because the container image stays the same.

The precondition for this gets settled before the first image is built: Sessions, uploads, state.

The image: what belongs in it and what does not

A PHP image for a monolith is unspectacular, and the mistakes happen in the same three places every time.

# Separate build from runtime: the result contains no Composer, no dev
# dependencies and no front-end sources.
FROM composer:2 AS dependencies
WORKDIR /app
COPY composer.json composer.lock ./
RUN composer install --no-dev --no-scripts --optimize-autoloader

FROM php:8.3-fpm-alpine
RUN docker-php-ext-install pdo_mysql opcache bcmath \
 && apk add --no-cache fcgi

COPY --from=dependencies /app/vendor ./vendor
COPY . .

# Opcache settings for production: the files never change inside the
# container, so PHP does not have to check on every request.
COPY docker/opcache.ini /usr/local/etc/php/conf.d/

USER www-data

One: the image contains no secrets. No .env, no credentials, no certificates. An image ends up in a registry many people can read from, and it stays there for years.

Two: the image contains no state. No directory for uploads, no sessions, no caches holding anything important. Containers get replaced, and what sits inside them is gone then.

Three: the image does not run as root. One line that is almost always missing from the first version and hard to retrofit later, because file permissions depend on it.

Plus a detail that often gets overlooked: inside a container the files never change. So the opcache can stop checking for modifications, which measurably saves work per request. On a server that was dangerous, here it is not.

Configuration and secrets

If the .env must not be in the image, configuration has to come from outside. ECS distinguishes two routes for that, and the distinction is not a formality.

"environment": [
  { "name": "APP_ENV", "value": "prod" },
  { "name": "DB_HOST", "value": "db.internal" }
],
"secrets": [
  { "name": "DB_PASSWORD",
    "valueFrom": "arn:aws:secretsmanager:eu-central-1:...:secret/db" }
]

The difference: what sits under environment is visible in the task definition, to anybody with read access and in any log output that dumps environment variables. What sits under secrets is fetched at start and appears nowhere in plain text.

The rule is simple: anything you can log in somewhere with belongs under secrets. Everything else under environment, because it is readable there and therefore traceable.

For an existing PHP application expecting a .env, the smallest route is usually a start script writing that file from the environment variables. It is not a pretty solution and it saves rebuilding the configuration layer in the middle of a migration. It gets a date, like every interim solution.

Logs: from files to standard output

An application logging to files loses its logs in a container as soon as it gets replaced. The change is small and belongs to the move.

// Monolog: output instead of file, and in a format that can be filtered.
// JSON, so that search does not have to guess at text.
$log = new Logger('app');
$handler = new StreamHandler('php://stdout', Level::Info);
$handler->setFormatter(new JsonFormatter());
$log->pushHandler($handler);

// Plus, on every line: the request identifier. Without it, the lines of
// eight containers cannot be told apart.
$log->pushProcessor(fn (LogRecord $r) => $r->with(extra: [
    'request' => $_SERVER['HTTP_X_AMZN_TRACE_ID'] ?? '-',
]));

The second part matters more. As soon as several containers run, log lines are interleaved, and without a per-request identifier, investigation stops being possible. The load balancer provides one, you only have to pass it through.

Something that stands out while doing this: PHP-FPM writes its own lines, the web server does too. Three sources in one container are fine, as long as all of them write to output and it is recognizable which source a line has.

The health check, and why it has to check more than "up"

ECS replaces containers reported as unhealthy, and the load balancer takes them out of rotation. Both are only as good as the check behind them.

The first reflex is a page returning OK. That checks whether PHP is running, and nothing else. A container whose database connection has dropped counts as healthy and keeps receiving requests.

// /health: checks the dependencies a request needs, and nothing beyond.
public function health(): Response
{
    try {
        $this->db->query('SELECT 1');
        $this->cache->get('health');
    } catch (Throwable $e) {
        // 503 so the load balancer takes this container out of rotation
        // rather than letting requests run into it.
        return new Response(503, ['status' => 'error']);
    }

    return new Response(200, ['status' => 'ok']);
}

Two limits on that, both from experience. The check does not call third-party systems. If the payment provider goes down, not all containers should count as unhealthy and get replaced; that turns a partial outage into a total one. The check is cheap. It runs every fifteen seconds per container, and a check doing a database query across three tables becomes load itself at twenty containers.

Rollout and the way back

The rollout is the part that gets better than before without any effort. ECS replaces containers gradually: new ones start, get checked, receive traffic, old ones get drained.

Three settings decide whether that looks calm in production.

The grace period in the health check. A PHP container is ready in two seconds, but when caches get built at start it takes longer. If the grace period is too short, ECS replaces containers that were merely not finished yet, and does so in a loop.

The drain period. A container being taken out of rotation still has requests in flight. The period has to be longer than the longest normal request, otherwise every rollout aborts a handful of operations.

The number of versions running at once. While old and new containers run side by side, both have to work with the same database state. That is the same condition as in any rollout without interruption, and it is why schema changes come in two steps.

The way back is the previous task definition, and that is the most pleasant part of the whole model: it is still there, and a switch back takes as long as a rollout.

What a release without downtime looks like even without orchestration is covered in Blue/green and canary without Kubernetes.

What is different afterwards, including on the bill

After the move, operations change in three places, and not all of them are cheaper.

Machine maintenance disappears. Operating system, updates, disk space: that work goes away. For small teams that is the real gain, bigger than any technical property.

The application exists more than once. What used to be one server is now at least two containers in two zones. That turns the loss of a zone from an incident into a non-event. It costs too: two small containers are more expensive than one twice their size.

The bill becomes predictable and not automatically smaller. Fargate charges for allocated memory and compute, regardless of utilization. Carry the old server sizing over and you pay more than before. The gain only appears once the allocation is brought in line with measured load, and that is a task of its own after the move.

Which is exactly why measurement comes before the move: knowing the response times and load peaks of the old setup lets you justify the allocation rather than guess it. How a cloud bill develops over twelve months when it is taken seriously is covered in its own article.

How an AWS migration works overall is on its own page; the step into containers is one of several there, and not the first.

This article belongs to a series about systems that already exist. The retrospective orders every article in it by situation.